Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f7dd12b34 | ||
|
|
67af3c9c46 | ||
|
|
634184c2be | ||
|
|
6152848957 | ||
|
|
046f464378 | ||
|
|
8700c253cd | ||
|
|
519fa3a8f4 | ||
|
|
e1594cab76 | ||
|
|
daaa1c4cb9 | ||
|
|
786b1d91f6 | ||
|
|
3436cd0b90 | ||
|
|
efe1fd2a73 | ||
|
|
a6664ce9a2 | ||
|
|
399ec1f7b4 | ||
|
|
86bb8af32d | ||
|
|
78718b85ae | ||
|
|
9ee348fff0 | ||
|
|
1fbd1d1b2e | ||
|
|
1f2d6c78e8 | ||
|
|
649f8ca9cc | ||
| 3705b8daed | |||
|
|
202b964b24 |
855
app/app.vue
855
app/app.vue
@@ -1,696 +1,22 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
Paramètres d'affichage
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-mobile')"
|
||||
:aria-expanded="openDropdown === 'pieces-mobile'"
|
||||
>
|
||||
<span>Pièces</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('products-mobile')"
|
||||
:aria-expanded="openDropdown === 'products-mobile'"
|
||||
>
|
||||
<span>Produits</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') || isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('component-mobile')"
|
||||
:aria-expanded="openDropdown === 'component-mobile'"
|
||||
>
|
||||
<span>Composant</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-mobile')"
|
||||
:aria-expanded="openDropdown === 'resources-mobile'"
|
||||
>
|
||||
<span>Ressources liées</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar placeholder">
|
||||
<div
|
||||
class="bg-primary text-primary-content rounded-lg w-10 grid place-items-center"
|
||||
>
|
||||
<IconLucideBoxes class="w-6 h-6" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventaire Pro
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('pieces-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('pieces-desktop')"
|
||||
@focusin="setDropdown('pieces-desktop')"
|
||||
@focusout="scheduleDropdownClose('pieces-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
|
||||
:aria-expanded="openDropdown === 'pieces-desktop'"
|
||||
>
|
||||
Pièces
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('products-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('products-desktop')"
|
||||
@focusin="setDropdown('products-desktop')"
|
||||
@focusout="scheduleDropdownClose('products-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('products-desktop')"
|
||||
:aria-expanded="openDropdown === 'products-desktop'"
|
||||
>
|
||||
Produits
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('component-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('component-desktop')"
|
||||
@focusin="setDropdown('component-desktop')"
|
||||
@focusout="scheduleDropdownClose('component-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') ||
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('component-desktop')"
|
||||
:aria-expanded="openDropdown === 'component-desktop'"
|
||||
>
|
||||
Composant
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('resources-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('resources-desktop')"
|
||||
@focusin="setDropdown('resources-desktop')"
|
||||
@focusout="scheduleDropdownClose('resources-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-desktop')"
|
||||
:aria-expanded="openDropdown === 'resources-desktop'"
|
||||
>
|
||||
Ressources liées
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Bouton paramètres d'affichage -->
|
||||
<button
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<LayoutAppNavbar
|
||||
@open-settings="displaySettingsOpen = true"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<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 />
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Paramètres d'affichage -->
|
||||
<CommonConfirmModal />
|
||||
|
||||
<DisplaySettings
|
||||
:is-open="displaySettingsOpen"
|
||||
@close="closeDisplaySettings"
|
||||
@close="displaySettingsOpen = false"
|
||||
@update-settings="handleSettingsUpdate"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||
<div class="items-center grid-flow-col">
|
||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
||||
@@ -699,161 +25,26 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, navigateTo, useRuntimeConfig } from "#imports";
|
||||
import { useProfileSession } from "~/composables/useProfileSession";
|
||||
import IconLucideMenu from "~icons/lucide/menu";
|
||||
import IconLucideSettings from "~icons/lucide/settings";
|
||||
import IconLucideBoxes from "~icons/lucide/boxes";
|
||||
import IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucideCpu from "~icons/lucide/cpu";
|
||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { navigateTo, useRuntimeConfig } from '#imports'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
|
||||
// État du modal des paramètres d'affichage
|
||||
const displaySettingsOpen = ref(false);
|
||||
const { activeProfile, ensureSession, logout } = useProfileSession();
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const appVersion = computed(() => runtimeConfig.public?.appVersion ?? "0.1.0");
|
||||
const displaySettingsOpen = ref(false)
|
||||
const { ensureSession, logout } = useProfileSession()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
|
||||
|
||||
// Route active pour souligner l'onglet sélectionné dans la navbar
|
||||
const route = useRoute();
|
||||
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 handleSettingsUpdate = (_settings: unknown) => {
|
||||
// Placeholder for future persistence
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
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() ||
|
||||
"??"
|
||||
);
|
||||
});
|
||||
await logout()
|
||||
await navigateTo('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSession();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
});
|
||||
await ensureSession()
|
||||
})
|
||||
</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>
|
||||
|
||||
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -401,7 +401,7 @@
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
||||
|
||||
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@@ -437,200 +437,98 @@ import { ref, watch, computed } from 'vue'
|
||||
import PieceItem from './PieceItem.vue'
|
||||
import DocumentUpload from './DocumentUpload.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 IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} 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({
|
||||
component: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
collapseAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
toggleToken: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
component: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'edit-piece',
|
||||
'custom-field-update'
|
||||
])
|
||||
const emit = defineEmits(['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 selectedFiles = ref([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length))
|
||||
const componentDocuments = computed(() => props.component.documents || [])
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
const shouldInlinePdf = (document) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const appendPdfViewerParams = (src) => {
|
||||
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'
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
}
|
||||
|
||||
// --- Child components ---
|
||||
const childComponents = computed(() => {
|
||||
const list = props.component.subcomponents || props.component.subComponents || []
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
// --- Constructeurs ---
|
||||
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(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.component,
|
||||
@@ -651,332 +549,8 @@ const componentConstructeursDisplay = computed(() =>
|
||||
const formatConstructeurContact = (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 ids = uniqueConstructeurIds(value)
|
||||
|
||||
props.component.constructeurIds = [...ids]
|
||||
props.component.constructeurId = null
|
||||
props.component.constructeur = null
|
||||
@@ -985,42 +559,10 @@ const handleConstructeurChange = async (value) => {
|
||||
constructeurs.value,
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
)
|
||||
|
||||
await updateComponent()
|
||||
}
|
||||
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update / Event forwarding ---
|
||||
const updateComponent = () => {
|
||||
emit('update', {
|
||||
...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) => {
|
||||
emit('edit-piece', updatedPiece)
|
||||
}
|
||||
@@ -1250,87 +582,4 @@ const updatePieceCustomField = (fieldUpdate) => {
|
||||
emit('custom-field-update', fieldUpdate)
|
||||
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>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:locked-type-label="displayedRootTypeLabel"
|
||||
:allow-subcomponents="allowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
is-root
|
||||
/>
|
||||
</div>
|
||||
@@ -55,6 +56,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: Infinity,
|
||||
},
|
||||
restrictedMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -110,7 +115,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
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') {
|
||||
return field
|
||||
}
|
||||
@@ -140,7 +145,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
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') {
|
||||
return sub
|
||||
}
|
||||
@@ -241,7 +246,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const loaders: Promise<any>[] = []
|
||||
const loaders: Promise<unknown>[] = []
|
||||
if (!availablePieceTypes.value.length) {
|
||||
loaders.push(loadPieceTypes())
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import { useApi } from '~/composables/useApi';
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
@@ -243,22 +244,6 @@ const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const productLoadingByPath = 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) => {
|
||||
target[key] = value;
|
||||
};
|
||||
@@ -362,7 +347,7 @@ const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = ''
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || (definition as any).typePiece?.id || null;
|
||||
definition.typePieceId || definition.typePiece?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
@@ -392,7 +377,7 @@ const fetchProductOptions = async (assignment: StructureProductAssignment, term
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || (definition as any).typeProduct?.id || null;
|
||||
definition.typeProductId || definition.typeProduct?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
@@ -447,14 +432,14 @@ const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel ||
|
||||
(definition as any).typePiece?.name ||
|
||||
definition.typePiece?.name ||
|
||||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
(definition as any).typePiece?.code ||
|
||||
definition.typePiece?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
@@ -483,7 +468,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typeProductId ||
|
||||
(definition as any).typeProduct?.id ||
|
||||
definition.typeProduct?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -494,7 +479,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typeProductId || (definition as any).typeProduct?.id) {
|
||||
if (definition.typeProductId || definition.typeProduct?.id) {
|
||||
return (
|
||||
product.typeProductId === requiredTypeId ||
|
||||
product.typeProduct?.id === requiredTypeId
|
||||
@@ -550,14 +535,14 @@ const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel ||
|
||||
(definition as any).typeProduct?.name ||
|
||||
definition.typeProduct?.name ||
|
||||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
(definition as any).typeProduct?.code ||
|
||||
definition.typeProduct?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
@@ -617,7 +602,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
(definition as any).typePiece?.id ||
|
||||
definition.typePiece?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -628,7 +613,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typePieceId || (definition as any).typePiece?.id) {
|
||||
if (definition.typePieceId || definition.typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId ||
|
||||
piece.typePiece?.id === requiredTypeId
|
||||
|
||||
@@ -246,6 +246,16 @@ const emitSelection = (ids: string[]) => {
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
|
||||
const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
||||
if (Array.isArray(data)) {
|
||||
return data as ConstructeurSummary[]
|
||||
}
|
||||
if (data && typeof data === 'object' && 'id' in data) {
|
||||
return [data as ConstructeurSummary]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const ensureOptionsLoaded = async (force = false) => {
|
||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||
@@ -262,7 +272,7 @@ const ensureOptionsLoaded = async (force = false) => {
|
||||
|
||||
const result = await searchConstructeurs(searchTerm.value)
|
||||
if (result.success) {
|
||||
applyOptions(result.data || [])
|
||||
applyOptions(extractDataArray(result.data))
|
||||
lastSearchTerm = searchTerm.value
|
||||
}
|
||||
}
|
||||
@@ -283,7 +293,7 @@ const onSearch = () => {
|
||||
}
|
||||
const result = await searchConstructeurs(searchTerm.value)
|
||||
if (result.success) {
|
||||
applyOptions(result.data || [])
|
||||
applyOptions(extractDataArray(result.data))
|
||||
lastSearchTerm = searchTerm.value
|
||||
}
|
||||
}, 250)
|
||||
@@ -310,16 +320,18 @@ const closeCreateModal = () => {
|
||||
|
||||
const handleCreate = async () => {
|
||||
creating.value = true
|
||||
const payload = { ...createForm.value }
|
||||
if (!payload.phone) {
|
||||
delete payload.phone
|
||||
const payload: { name: string; email?: string; phone?: string } = {
|
||||
name: createForm.value.name,
|
||||
}
|
||||
if (!payload.email) {
|
||||
delete payload.email
|
||||
if (createForm.value.email) {
|
||||
payload.email = createForm.value.email
|
||||
}
|
||||
if (createForm.value.phone) {
|
||||
payload.phone = createForm.value.phone
|
||||
}
|
||||
const result = await createConstructeur(payload)
|
||||
creating.value = false
|
||||
if (result.success) {
|
||||
if (result.success && result.data && !Array.isArray(result.data)) {
|
||||
emitSelection([...selectedIds.value, result.data.id])
|
||||
searchTerm.value = ''
|
||||
closeCreateModal()
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { reactive, onMounted, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
customFields: {
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,10 @@
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -35,6 +35,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(product)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -51,12 +52,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isProductLocked(product)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeProduct(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -108,7 +119,7 @@
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
@@ -128,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
@@ -137,16 +148,27 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isFieldLocked(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isFieldLocked(field)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -181,6 +203,7 @@ type EditorProduct = {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
restrictedMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -330,6 +353,19 @@ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
||||
|
||||
const isFieldLocked = (field: EditorField): boolean => {
|
||||
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
|
||||
}
|
||||
|
||||
const isProductLocked = (product: EditorProduct): boolean => {
|
||||
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
|
||||
}
|
||||
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
@@ -438,6 +474,8 @@ watch(
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -472,6 +510,10 @@ const reorderFields = (from: number, to: number) => {
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(to, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<SearchSelect
|
||||
:model-value="modelValue"
|
||||
:model-value="modelValue ?? undefined"
|
||||
:options="productOptions"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<select
|
||||
v-model="node.typeComposantId"
|
||||
class="select select-bordered select-sm w-full"
|
||||
:disabled="isLocked"
|
||||
@change="handleComponentTypeSelect(node)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -42,6 +43,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Alias du sous-composant"
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,13 +54,18 @@
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isRoot"
|
||||
v-if="!isRoot && !isLocked"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 space-y-5">
|
||||
@@ -108,7 +115,7 @@
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste</option>
|
||||
@@ -117,7 +124,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<textarea
|
||||
@@ -125,15 +132,26 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCustomFieldLocked(index)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeCustomField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +162,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -179,6 +197,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(index)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -194,9 +213,18 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +235,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -243,6 +271,7 @@
|
||||
<select
|
||||
v-model="piece.typePieceId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isPieceLocked(index)"
|
||||
@change="handlePieceTypeSelect(piece)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -262,9 +291,14 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,7 +308,7 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<button
|
||||
v-if="canManageSubcomponents"
|
||||
v-if="canManageSubcomponents && !restrictedMode"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
@@ -317,6 +351,8 @@
|
||||
:product-types="productTypes"
|
||||
:allow-subcomponents="childAllowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
:is-locked="isSubcomponentLocked(index)"
|
||||
@remove="removeSubComponent(index)"
|
||||
/>
|
||||
</div>
|
||||
@@ -359,6 +395,8 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel?: string
|
||||
allowSubcomponents?: boolean
|
||||
maxSubcomponentDepth?: number
|
||||
restrictedMode?: boolean
|
||||
isLocked?: boolean
|
||||
}>(), {
|
||||
depth: 0,
|
||||
componentTypes: () => [],
|
||||
@@ -369,10 +407,52 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel: '',
|
||||
allowSubcomponents: true,
|
||||
maxSubcomponentDepth: Infinity,
|
||||
restrictedMode: false,
|
||||
isLocked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
||||
const initialProductIndices = ref<Set<number>>(new Set())
|
||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
||||
|
||||
const initializeLockedIndices = () => {
|
||||
if (props.restrictedMode) {
|
||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
||||
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
|
||||
|
||||
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
|
||||
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
|
||||
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
|
||||
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
|
||||
}
|
||||
}
|
||||
|
||||
initializeLockedIndices()
|
||||
|
||||
const isCustomFieldLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isPieceLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialPieceIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isProductLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialProductIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isSubcomponentLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
@@ -460,7 +540,7 @@ const getPieceTypeLabel = (id?: string) => {
|
||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const getProductTypeLabel = (id?: string) => {
|
||||
const _getProductTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(productTypeMap.value.get(id))
|
||||
}
|
||||
@@ -744,10 +824,9 @@ const customFieldReorderClass = (index: number) => {
|
||||
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
const nextIndex = Array.isArray(props.node.customFields)
|
||||
? props.node.customFields.length
|
||||
: 0
|
||||
props.node.customFields.push({
|
||||
const fields = props.node.customFields!
|
||||
const nextIndex = fields.length
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
@@ -766,7 +845,7 @@ const removeCustomField = (index: number) => {
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces.push({
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
@@ -782,7 +861,7 @@ const removePiece = (index: number) => {
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products.push({
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
@@ -830,6 +909,7 @@ const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
||||
}
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
@@ -91,11 +91,5 @@ onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
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>
|
||||
|
||||
41
app/components/common/ConfirmModal.vue
Normal file
41
app/components/common/ConfirmModal.vue
Normal 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>
|
||||
@@ -66,7 +66,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="labels.labelPlaceholder"
|
||||
@input="updateRequirement(index, { label: $event.target.value })"
|
||||
@input="handleLabelInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
|
||||
@input="handleMinInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
|
||||
@input="handleMaxInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.required ?? requiredFallback) === true"
|
||||
@change="updateRequirement(index, { required: $event.target.checked })"
|
||||
@change="handleRequiredChange(index, $event)"
|
||||
/>
|
||||
{{ labels.requiredLabel }}
|
||||
</label>
|
||||
@@ -123,7 +123,7 @@
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
|
||||
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
|
||||
@change="handleAllowNewModelsChange(index, $event)"
|
||||
/>
|
||||
{{ labels.allowNewModelsLabel }}
|
||||
</label>
|
||||
@@ -277,6 +277,37 @@ const parseOptionalNumber = (value: string) => {
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
// Type-safe event handlers
|
||||
const getInputValue = (event: Event): string => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.value ?? ''
|
||||
}
|
||||
|
||||
const getCheckboxValue = (event: Event): boolean => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.checked ?? false
|
||||
}
|
||||
|
||||
const handleLabelInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { label: getInputValue(event) })
|
||||
}
|
||||
|
||||
const handleMinInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleMaxInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleRequiredChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { required: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const handleAllowNewModelsChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const draggingRequirementIndex = ref<number | null>(null)
|
||||
const requirementDropTargetIndex = ref<number | null>(null)
|
||||
|
||||
@@ -297,6 +328,10 @@ const reorderRequirements = (from: number, to: number) => {
|
||||
}
|
||||
const updated = list.slice() as Requirement[]
|
||||
const [moved] = updated.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
updated.splice(to, 0, moved)
|
||||
requirements.value = applyOrderIndex(updated)
|
||||
resetRequirementDragState()
|
||||
|
||||
@@ -184,8 +184,7 @@ watch(
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
(newOptions) => {
|
||||
console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
|
||||
(_newOptions) => {
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
|
||||
368
app/components/layout/AppNavbar.vue
Normal file
368
app/components/layout/AppNavbar.vue
Normal 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>
|
||||
53
app/components/machine/MachineComponentsCard.vue
Normal file
53
app/components/machine/MachineComponentsCard.vue
Normal 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>
|
||||
76
app/components/machine/MachineDetailHeader.vue
Normal file
76
app/components/machine/MachineDetailHeader.vue
Normal 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>
|
||||
116
app/components/machine/MachineDocumentsCard.vue
Normal file
116
app/components/machine/MachineDocumentsCard.vue
Normal 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' }} • {{ 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>
|
||||
185
app/components/machine/MachineInfoCard.vue
Normal file
185
app/components/machine/MachineInfoCard.vue
Normal 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>
|
||||
34
app/components/machine/MachinePiecesCard.vue
Normal file
34
app/components/machine/MachinePiecesCard.vue
Normal 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>
|
||||
62
app/components/machine/MachineProductsCard.vue
Normal file
62
app/components/machine/MachineProductsCard.vue
Normal 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>
|
||||
193
app/components/machine/MachineSkeletonSummary.vue
Normal file
193
app/components/machine/MachineSkeletonSummary.vue
Normal 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 }} • 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>
|
||||
205
app/components/machine/create/MachineCreatePreview.vue
Normal file
205
app/components/machine/create/MachineCreatePreview.vue
Normal 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">•</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">•</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>
|
||||
59
app/components/machine/create/PreviewRequirementGroup.vue
Normal file
59
app/components/machine/create/PreviewRequirementGroup.vue
Normal 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>
|
||||
126
app/components/machine/create/RequirementComponentSelector.vue
Normal file
126
app/components/machine/create/RequirementComponentSelector.vue
Normal 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>
|
||||
130
app/components/machine/create/RequirementPieceSelector.vue
Normal file
130
app/components/machine/create/RequirementPieceSelector.vue
Normal 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>
|
||||
142
app/components/machine/create/RequirementProductSelector.vue
Normal file
142
app/components/machine/create/RequirementProductSelector.vue
Normal 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>
|
||||
@@ -97,6 +97,7 @@ import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
@@ -105,6 +106,7 @@ import {
|
||||
type ModelTypeListResponse,
|
||||
} from "~/services/modelTypes";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||
|
||||
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.";
|
||||
@@ -153,7 +155,7 @@ useHead(() => ({
|
||||
const extractErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object") {
|
||||
const maybeFetchError = error as {
|
||||
data?: any;
|
||||
data?: Record<string, unknown>;
|
||||
statusMessage?: string;
|
||||
message?: string;
|
||||
};
|
||||
@@ -208,8 +210,8 @@ const refresh = async ({
|
||||
total.value = response.total;
|
||||
offset.value = response.offset;
|
||||
limit.value = response.limit;
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
showError(extractErrorMessage(error));
|
||||
@@ -292,16 +294,19 @@ const openEditPage = (item: ModelType) => {
|
||||
});
|
||||
};
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = window.confirm(
|
||||
"Supprimer ce type ? Cette action est irréversible."
|
||||
);
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id);
|
||||
invalidateEntityTypeCache(item.category);
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
@@ -363,22 +368,6 @@ const relatedModalSubtitle = computed(() => {
|
||||
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 resolveRelatedConfig = (category: ModelCategory) => {
|
||||
@@ -401,22 +390,26 @@ const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: any): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object" || typeof item.id !== "string") {
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof item.name === "string" && item.name.trim()
|
||||
? item.name
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof item.reference === "string" && item.reference.trim()
|
||||
? item.reference
|
||||
: typeof item.code === "string" && item.code.trim()
|
||||
? item.code
|
||||
typeof record.reference === "string" && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === "string" && record.code.trim()
|
||||
? record.code
|
||||
: null;
|
||||
return {
|
||||
id: item.id,
|
||||
id: record.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
minlength="2"
|
||||
maxlength="120"
|
||||
required
|
||||
:disabled="restrictedMode"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||
</div>
|
||||
@@ -47,6 +48,7 @@
|
||||
rows="4"
|
||||
name="notes"
|
||||
maxlength="2000"
|
||||
:disabled="restrictedMode"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||
</div>
|
||||
@@ -81,6 +83,7 @@
|
||||
v-model="componentStructure"
|
||||
:allow-subcomponents="allowComponentSubcomponents"
|
||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -103,11 +106,21 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="restrictedMode && restrictedModeMessage"
|
||||
class="alert alert-info"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>{{ restrictedModeMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="disableSubmit"
|
||||
class="alert alert-warning"
|
||||
@@ -161,6 +174,8 @@ const props = withDefaults(defineProps<{
|
||||
componentSubcomponentMaxDepth?: number
|
||||
disableSubmit?: boolean
|
||||
disableSubmitMessage?: string
|
||||
restrictedMode?: boolean
|
||||
restrictedModeMessage?: string
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
@@ -170,6 +185,8 @@ const props = withDefaults(defineProps<{
|
||||
componentSubcomponentMaxDepth: 1,
|
||||
disableSubmit: false,
|
||||
disableSubmitMessage: '',
|
||||
restrictedMode: false,
|
||||
restrictedModeMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -192,6 +209,12 @@ const disableSubmitMessage = computed(() =>
|
||||
? props.disableSubmitMessage
|
||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||
)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
const restrictedModeMessage = computed(() =>
|
||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||
? props.restrictedModeMessage
|
||||
: '',
|
||||
)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
name: '',
|
||||
@@ -255,10 +278,11 @@ const resetForm = () => {
|
||||
? incoming.code
|
||||
: generateCodeFromName(form.name)
|
||||
form.category = incoming.category ?? props.initialCategory
|
||||
const incomingRecord = incoming as Record<string, unknown>
|
||||
form.notes = typeof incoming.notes === 'string'
|
||||
? incoming.notes
|
||||
: typeof (incoming as any).description === 'string'
|
||||
? (incoming as any).description
|
||||
: typeof incomingRecord.description === 'string'
|
||||
? incomingRecord.description
|
||||
: ''
|
||||
|
||||
errors.name = undefined
|
||||
|
||||
@@ -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
128
app/composables/useApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const extractTotal = (payload: any, fallbackLength: number) => {
|
||||
|
||||
export function useCategoryEditGuard (config: GuardConfig) {
|
||||
const { get } = useApi()
|
||||
const { showError } = useToast()
|
||||
const { showInfo } = useToast()
|
||||
|
||||
const linkedCount = ref(0)
|
||||
const linkedLoading = ref(false)
|
||||
@@ -57,18 +57,22 @@ export function useCategoryEditGuard (config: GuardConfig) {
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitBlocked = computed(
|
||||
() => linkedLoading.value || linkedCount.value > 0,
|
||||
const isRestrictedMode = computed(
|
||||
() => !linkedLoading.value && linkedCount.value > 0,
|
||||
)
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
const isSubmitBlocked = computed(
|
||||
() => linkedLoading.value,
|
||||
)
|
||||
|
||||
const restrictedModeMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
@@ -76,23 +80,32 @@ export function useCategoryEditGuard (config: GuardConfig) {
|
||||
return ''
|
||||
}
|
||||
if (linkedCount.value === 1) {
|
||||
return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
|
||||
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 `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
|
||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
||||
})
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const guardSubmitOrNotify = () => {
|
||||
if (!isSubmitBlocked.value) {
|
||||
return false
|
||||
}
|
||||
showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
|
||||
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
linkedCount,
|
||||
linkedLoading,
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
|
||||
@@ -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 = {
|
||||
id: string
|
||||
label: string
|
||||
export type ComponentHistoryActor = EntityHistoryActor
|
||||
export type ComponentHistoryEntry = EntityHistoryEntry
|
||||
|
||||
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 l’historique.'
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||
|
||||
const componentTypes = ref([])
|
||||
const loadingComponentTypes = ref(false)
|
||||
|
||||
export function useComponentTypes () {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name) => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadComponentTypes = async () => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'COMPONENT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200
|
||||
})
|
||||
|
||||
componentTypes.value = data.items.map(item => ({
|
||||
...item,
|
||||
description: item.description ?? item.notes ?? null
|
||||
}))
|
||||
|
||||
return { success: true, data: componentTypes.value }
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComponentType = async (payload) => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'COMPONENT',
|
||||
notes: payload.description ?? payload.notes,
|
||||
description: payload.description ?? null,
|
||||
structure: payload.structure
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null
|
||||
}
|
||||
|
||||
componentTypes.value.push(normalized)
|
||||
showSuccess(`Type de composant "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateComponentType = async (id, payload) => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
notes: payload.notes,
|
||||
code: payload.code,
|
||||
structure: payload.structure
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null
|
||||
}
|
||||
|
||||
const index = componentTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
componentTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de composant "${data.name}" mis à jour`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalized
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComponentType = async (id) => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
componentTypes.value = componentTypes.value.filter(type => type.id !== id)
|
||||
showSuccess('Type de composant supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getComponentTypes = () => componentTypes.value
|
||||
const isComponentTypeLoading = () => loadingComponentTypes.value
|
||||
|
||||
return {
|
||||
componentTypes,
|
||||
loadingComponentTypes,
|
||||
loadComponentTypes,
|
||||
createComponentType,
|
||||
updateComponentType,
|
||||
deleteComponentType,
|
||||
getComponentTypes,
|
||||
isComponentTypeLoading
|
||||
}
|
||||
}
|
||||
29
app/composables/useComponentTypes.ts
Normal file
29
app/composables/useComponentTypes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface ComponentType extends EntityType {
|
||||
structure: ComponentModelStructure | null
|
||||
}
|
||||
|
||||
export function useComponentTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'COMPONENT',
|
||||
label: 'composant',
|
||||
})
|
||||
|
||||
return {
|
||||
componentTypes: types as Ref<ComponentType[]>,
|
||||
loadingComponentTypes: loading,
|
||||
loadComponentTypes: loadTypes,
|
||||
createComponentType: createType,
|
||||
updateComponentType: updateType,
|
||||
deleteComponentType: deleteType,
|
||||
getComponentTypes: () => types.value as ComponentType[],
|
||||
isComponentTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,67 @@ import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from './useConstructeurs'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const composants = ref([])
|
||||
export interface Composant {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
typeComposantId?: string | null
|
||||
typeComposant?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
product?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
documents?: unknown[]
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ComposantListResult {
|
||||
success: boolean
|
||||
data?: { items: Composant[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ComposantSingleResult {
|
||||
success: boolean
|
||||
data?: Composant
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadComposantsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const composants = ref<Composant[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
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 extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useComposants () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useComposants() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (composant) => {
|
||||
const withResolvedConstructeurs = async (composant: Composant): Promise<Composant> => {
|
||||
if (!composant || typeof composant !== 'object') {
|
||||
return composant
|
||||
}
|
||||
@@ -59,12 +81,11 @@ export function useComposants () {
|
||||
const ids = uniqueConstructeurIds(
|
||||
composant.constructeurIds,
|
||||
composant.constructeurs,
|
||||
composant.constructeur,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(composant.constructeurs)
|
||||
&& composant.constructeurs.length > 0
|
||||
&& composant.constructeurs.every((item) => item && typeof item === 'object')
|
||||
Array.isArray(composant.constructeurs) &&
|
||||
composant.constructeurs.length > 0 &&
|
||||
composant.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
@@ -76,16 +97,7 @@ export function useComposants () {
|
||||
return composant
|
||||
}
|
||||
|
||||
/**
|
||||
* Load composants with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
*/
|
||||
const loadComposants = async (options = {}) => {
|
||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const {
|
||||
@@ -93,7 +105,7 @@ export function useComposants () {
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc'
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
@@ -118,79 +130,84 @@ export function useComposants () {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
return result as ComposantListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComposant = async (composantData) => {
|
||||
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||
const result = await post('/composants', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
||||
composants.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const displayName = result.data?.name
|
||||
|| composantData?.definition?.name
|
||||
|| composantData?.name
|
||||
|| 'Composant'
|
||||
const definition = (composantData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|
||||
const displayName =
|
||||
(result.data as Composant)?.name ||
|
||||
(definition?.name as string | undefined) ||
|
||||
composantData?.name ||
|
||||
'Composant'
|
||||
showSuccess(`Composant "${displayName}" créé avec succès`)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateComposantData = async (id, composantData) => {
|
||||
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||
const result = await patch(`/composants/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updated = await withResolvedConstructeurs(result.data)
|
||||
const index = composants.value.findIndex(comp => comp.id === id)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Composant)
|
||||
const index = composants.value.findIndex((comp) => comp.id === id)
|
||||
if (index !== -1) {
|
||||
composants.value[index] = updated
|
||||
}
|
||||
showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`)
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComposant = async (id) => {
|
||||
const deleteComposant = async (id: string): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/composants/${id}`)
|
||||
if (result.success) {
|
||||
const deletedComposant = composants.value.find(comp => comp.id === id)
|
||||
composants.value = composants.value.filter(comp => comp.id !== id)
|
||||
const deletedComposant = composants.value.find((comp) => comp.id === id)
|
||||
composants.value = composants.value.filter((comp) => comp.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -208,6 +225,6 @@ export function useComposants () {
|
||||
updateComposant: updateComposantData,
|
||||
deleteComposant,
|
||||
getComposants,
|
||||
isLoading
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
73
app/composables/useConfirm.ts
Normal file
73
app/composables/useConfirm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const constructeurs = ref([])
|
||||
export interface Constructeur {
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
success: boolean
|
||||
data?: Constructeur | Constructeur[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const uniqueConstructeurs = (items = []) => {
|
||||
const map = new Map()
|
||||
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
|
||||
const map = new Map<string, Constructeur>()
|
||||
items.forEach((item) => {
|
||||
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||
map.set(item.id, item)
|
||||
@@ -15,7 +30,7 @@ const uniqueConstructeurs = (items = []) => {
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const normalizeIds = (ids = []) => {
|
||||
const normalizeIds = (ids: unknown[] = []): string[] => {
|
||||
if (!Array.isArray(ids)) {
|
||||
return []
|
||||
}
|
||||
@@ -28,7 +43,7 @@ const normalizeIds = (ids = []) => {
|
||||
)
|
||||
}
|
||||
|
||||
const upsertConstructeurs = (items = []) => {
|
||||
const upsertConstructeurs = (items: Constructeur[] = []) => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
return
|
||||
}
|
||||
@@ -36,32 +51,19 @@ const upsertConstructeurs = (items = []) => {
|
||||
constructeurs.value = merged
|
||||
}
|
||||
|
||||
const getIndexedConstructeur = (id) =>
|
||||
const getIndexedConstructeur = (id: string): Constructeur | null =>
|
||||
constructeurs.value.find((item) => item && item.id === id) || null
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
const pendingFetches = new Map<string, Promise<Constructeur | null>>()
|
||||
|
||||
const pendingFetches = new Map()
|
||||
|
||||
export function useConstructeurs () {
|
||||
export function useConstructeurs() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const loadConstructeurs = async (search = '') => {
|
||||
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
|
||||
if (!search && !options.force && loaded.value) {
|
||||
return { success: true, data: constructeurs.value }
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
@@ -69,48 +71,51 @@ export function useConstructeurs () {
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
if (!search) loaded.value = true
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des fournisseurs:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchConstructeurs = async (search = '') => {
|
||||
const searchConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data) => {
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data])
|
||||
showSuccess(`Fournisseur "${result.data.name}" créé`)
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" créé`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la création du fournisseur:', error)
|
||||
showError('Impossible de créer le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureConstructeurs = async (ids = []) => {
|
||||
const ensureConstructeurs = async (ids: unknown[] = []): Promise<Constructeur[]> => {
|
||||
const normalizedIds = normalizeIds(ids)
|
||||
if (!normalizedIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const collected = []
|
||||
const missing = []
|
||||
const collected: Constructeur[] = []
|
||||
const missing: string[] = []
|
||||
normalizedIds.forEach((id) => {
|
||||
const existing = getIndexedConstructeur(id)
|
||||
if (existing) {
|
||||
@@ -129,7 +134,7 @@ export function useConstructeurs () {
|
||||
const task = get(`/constructeurs/${id}`)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
return result.data
|
||||
return result.data as Constructeur
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -145,7 +150,7 @@ export function useConstructeurs () {
|
||||
})
|
||||
|
||||
const fetched = await Promise.all(fetchTasks)
|
||||
const validFetched = fetched.filter((item) => item && item.id)
|
||||
const validFetched = fetched.filter((item): item is Constructeur => item !== null && item.id !== undefined)
|
||||
if (validFetched.length) {
|
||||
upsertConstructeurs(validFetched)
|
||||
}
|
||||
@@ -153,50 +158,52 @@ export function useConstructeurs () {
|
||||
|
||||
return normalizedIds
|
||||
.map((id) => getIndexedConstructeur(id))
|
||||
.filter((item) => Boolean(item))
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id, data) => {
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data])
|
||||
showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" mis à jour`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la mise à jour du fournisseur:', error)
|
||||
showError('Impossible de mettre à jour le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConstructeur = async (id) => {
|
||||
const deleteConstructeur = async (id: string): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/constructeurs/${id}`)
|
||||
if (result.success) {
|
||||
constructeurs.value = constructeurs.value.filter(item => item.id !== id)
|
||||
constructeurs.value = constructeurs.value.filter((item) => item.id !== id)
|
||||
showSuccess('Fournisseur supprimé')
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la suppression du fournisseur:', error)
|
||||
showError('Impossible de supprimer le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getConstructeurById = (id) => getIndexedConstructeur(id)
|
||||
const getConstructeurById = (id: string) => getIndexedConstructeur(id)
|
||||
|
||||
return {
|
||||
constructeurs,
|
||||
@@ -1,66 +1,75 @@
|
||||
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 customFieldValues = ref([])
|
||||
const customFieldValues = ref<CustomFieldValue[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Créer une valeur de champ personnalisé
|
||||
const createCustomFieldValue = async (customFieldValueData) => {
|
||||
const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(customFieldValueData)
|
||||
body: JSON.stringify(customFieldValueData),
|
||||
})
|
||||
return result
|
||||
} catch (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é
|
||||
const getCustomFieldValuesByEntity = async (entityType, entityId) => {
|
||||
const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
|
||||
method: 'GET'
|
||||
method: 'GET',
|
||||
})
|
||||
if (result.success) {
|
||||
customFieldValues.value = result.data
|
||||
customFieldValues.value = result.data as CustomFieldValue[]
|
||||
}
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une valeur de champ personnalisé
|
||||
const updateCustomFieldValue = async (id, updateData) => {
|
||||
const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData)
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
return result
|
||||
} catch (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é
|
||||
const upsertCustomFieldValue = async (
|
||||
customFieldId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
metadata = {},
|
||||
) => {
|
||||
customFieldId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values/upsert', {
|
||||
method: 'POST',
|
||||
@@ -69,26 +78,26 @@ export function useCustomFields () {
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
...metadata
|
||||
})
|
||||
...metadata,
|
||||
}),
|
||||
})
|
||||
return result
|
||||
} catch (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é
|
||||
const deleteCustomFieldValue = async (id) => {
|
||||
const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
})
|
||||
return result
|
||||
} catch (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,
|
||||
updateCustomFieldValue,
|
||||
upsertCustomFieldValue,
|
||||
deleteCustomFieldValue
|
||||
deleteCustomFieldValue,
|
||||
}
|
||||
}
|
||||
@@ -2,39 +2,55 @@ import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const documents = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
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 []
|
||||
export interface Document {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
path: string
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
}
|
||||
|
||||
const fileToBase64 = file =>
|
||||
export interface UploadContext {
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
}
|
||||
|
||||
export interface DocumentResult {
|
||||
success: boolean
|
||||
data?: Document | Document[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const documents = ref<Document[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
export function useDocuments () {
|
||||
export function useDocuments() {
|
||||
const { get, post, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (endpoint, { updateStore = false } = {}) => {
|
||||
const loadFromEndpoint = async (
|
||||
endpoint: string,
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get(endpoint)
|
||||
@@ -48,50 +64,83 @@ export function useDocuments () {
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error(`Erreur lors du chargement des documents (${endpoint}):`, error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (options = {}) => {
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (siteId, options = {}) => {
|
||||
if (!siteId) { return { success: false, error: 'Aucun site sélectionné' } }
|
||||
const loadDocumentsBySite = async (
|
||||
siteId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!siteId) {
|
||||
return { success: false, error: 'Aucun site sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/site/${siteId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByMachine = async (machineId, options = {}) => {
|
||||
if (!machineId) { return { success: false, error: 'Aucune machine sélectionnée' } }
|
||||
const loadDocumentsByMachine = async (
|
||||
machineId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Aucune machine sélectionnée' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByComponent = async (componentId, options = {}) => {
|
||||
if (!componentId) { return { success: false, error: 'Aucun composant sélectionné' } }
|
||||
const loadDocumentsByComponent = async (
|
||||
componentId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!componentId) {
|
||||
return { success: false, error: 'Aucun composant sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByProduct = async (productId, options = {}) => {
|
||||
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
|
||||
const loadDocumentsByProduct = async (
|
||||
productId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!productId) {
|
||||
return { success: false, error: 'Aucun produit sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByPiece = async (pieceId, options = {}) => {
|
||||
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
|
||||
const loadDocumentsByPiece = async (
|
||||
pieceId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!pieceId) {
|
||||
return { success: false, error: 'Aucune pièce sélectionnée' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const uploadDocuments = async ({ files = [], context = {} }, { updateStore = false } = {}) => {
|
||||
if (!files.length) { return { success: false, error: 'Aucun fichier sélectionné' } }
|
||||
const uploadDocuments = async (
|
||||
{ files, context = {} }: { files: File[]; context?: UploadContext },
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!files.length) {
|
||||
return { success: false, error: 'Aucun fichier sélectionné' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const created = []
|
||||
const created: Document[] = []
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
@@ -103,12 +152,12 @@ export function useDocuments () {
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
path: dataUrl,
|
||||
...context
|
||||
...context,
|
||||
})
|
||||
|
||||
const result = await post('/documents', payload)
|
||||
if (result.success) {
|
||||
created.push(result.data)
|
||||
created.push(result.data as Document)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
} else if (result.error) {
|
||||
showError(`Erreur sur ${file.name} : ${result.error}`)
|
||||
@@ -124,31 +173,38 @@ export function useDocuments () {
|
||||
|
||||
return { success: false, error: 'Aucun document ajouté' }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'upload des documents:', error)
|
||||
const err = error as Error
|
||||
console.error("Erreur lors de l'upload des documents:", error)
|
||||
showError("Échec de l'ajout des documents")
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDocument = async (id, { updateStore = false } = {}) => {
|
||||
if (!id) { return { success: false, error: 'Identifiant manquant' } }
|
||||
const deleteDocument = async (
|
||||
id: string | number,
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!id) {
|
||||
return { success: false, error: 'Identifiant manquant' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/documents/${id}`)
|
||||
if (result.success) {
|
||||
if (updateStore) {
|
||||
documents.value = documents.value.filter(doc => doc.id !== id)
|
||||
documents.value = documents.value.filter((doc) => doc.id !== id)
|
||||
}
|
||||
showSuccess('Document supprimé')
|
||||
}
|
||||
return result
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la suppression du document:', error)
|
||||
showError('Impossible de supprimer le document')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -164,6 +220,6 @@ export function useDocuments () {
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
deleteDocument
|
||||
deleteDocument,
|
||||
}
|
||||
}
|
||||
181
app/composables/useEntityCustomFields.ts
Normal file
181
app/composables/useEntityCustomFields.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
122
app/composables/useEntityDocuments.ts
Normal file
122
app/composables/useEntityDocuments.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
69
app/composables/useEntityHistory.ts
Normal file
69
app/composables/useEntityHistory.ts
Normal 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 }
|
||||
}
|
||||
103
app/composables/useEntityProductDisplay.ts
Normal file
103
app/composables/useEntityProductDisplay.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
188
app/composables/useEntityTypes.ts
Normal file
188
app/composables/useEntityTypes.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
458
app/composables/useMachineCreatePage.ts
Normal file
458
app/composables/useMachineCreatePage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
572
app/composables/useMachineCreatePreview.ts
Normal file
572
app/composables/useMachineCreatePreview.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Machine creation – preview computation and validation.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Builds the live preview model
|
||||
* and validates requirement selections before machine creation.
|
||||
*/
|
||||
|
||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
getComponentMachineAssignments,
|
||||
getPieceMachineAssignments,
|
||||
getPieceComponentAssignments,
|
||||
formatAssignmentList,
|
||||
} from '~/shared/utils/assignmentUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreatePreviewDeps {
|
||||
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
|
||||
sites: Ref<AnyRecord[]>
|
||||
selectedMachineType: ComputedRef<AnyRecord | null>
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
findProductById: (id: string) => AnyRecord | null
|
||||
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product type ID extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
|
||||
if (!component || typeof component !== 'object') return null
|
||||
return (
|
||||
(component.product as AnyRecord)?.typeProductId ||
|
||||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
component.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
|
||||
if (!piece || typeof piece !== 'object') return null
|
||||
return (
|
||||
(piece.product as AnyRecord)?.typeProductId ||
|
||||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
piece.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStatusBadgeClass = (status: string): string => {
|
||||
if (status === 'ready') return 'badge-success'
|
||||
if (status === 'warning') return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll / issue click helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
|
||||
|
||||
export const scrollToAnchor = (anchor: string): void => {
|
||||
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
const target = document.getElementById(anchor)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
highlightClasses.forEach((cls) => target.classList.add(cls))
|
||||
window.setTimeout(() => {
|
||||
highlightClasses.forEach((cls) => target.classList.remove(cls))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
export const handleIssueClick = (issue: AnyRecord): void => {
|
||||
if (!issue?.anchor) return
|
||||
scrollToAnchor(issue.anchor as string)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveComponentRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findComponentById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.composantId) {
|
||||
const component = findComponentById(entry.composantId as string)
|
||||
if ((component?.typeComposant as AnyRecord)?.name) {
|
||||
return (component!.typeComposant as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
export const resolvePieceRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findPieceById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.pieceId) {
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
if ((piece?.typePiece as AnyRecord)?.name) {
|
||||
return (piece!.typePiece as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product requirement stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeProductUsageFromSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
increment(getProductTypeIdFromComponent(component))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
increment(getProductTypeIdFromPiece(piece))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
const typeProductId = (
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
) as string | null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const buildProductRequirementStats = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): { stats: AnyRecord[]; usage: Map<string, number> } => {
|
||||
const usage = computeProductUsageFromSelections(type, deps)
|
||||
|
||||
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const typeProductId = (
|
||||
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
|
||||
) as string | null
|
||||
|
||||
const label = (
|
||||
(requirement.label as string)?.trim() ||
|
||||
(requirement.typeProduct as AnyRecord)?.name ||
|
||||
(requirement.typeProduct as AnyRecord)?.code ||
|
||||
'Produit requis'
|
||||
) as string
|
||||
|
||||
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
|
||||
|
||||
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = rawEntries.map((entry, index) => {
|
||||
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
|
||||
const subtitleParts: string[] = []
|
||||
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
|
||||
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
|
||||
const price = Number(product.supplierPrice)
|
||||
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
|
||||
const cLabel = (product!.constructeurs as AnyRecord[])
|
||||
.map((c) => c?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
|
||||
}
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: product ? 'complete' : 'pending',
|
||||
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
|
||||
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
|
||||
}
|
||||
})
|
||||
|
||||
const issues: AnyRecord[] = []
|
||||
if (count < min) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
|
||||
issues.push({
|
||||
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const total = normalizedEntries.length
|
||||
const status = issues.some((i) => i.kind === 'error')
|
||||
? 'error'
|
||||
: issues.some((i) => i.kind === 'warning')
|
||||
? 'warning'
|
||||
: 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
requirement,
|
||||
label,
|
||||
typeName,
|
||||
count,
|
||||
min,
|
||||
max,
|
||||
completed,
|
||||
total,
|
||||
entries: normalizedEntries,
|
||||
issues,
|
||||
allowNewModels: requirement.allowNewModels ?? true,
|
||||
status,
|
||||
}
|
||||
})
|
||||
|
||||
return { stats, usage }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const validateRequirementSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): AnyRecord => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.composantId) {
|
||||
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
|
||||
return
|
||||
}
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
if (!component) {
|
||||
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
|
||||
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.pieceId) {
|
||||
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
|
||||
return
|
||||
}
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
if (!piece) {
|
||||
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
|
||||
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const { stats: productStats } = buildProductRequirementStats(type, deps)
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
|
||||
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
|
||||
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
productStats.forEach((stat) => {
|
||||
((stat.issues || []) as AnyRecord[])
|
||||
.filter((issue) => issue.kind === 'error')
|
||||
.forEach((issue) => errors.push(issue.message as string))
|
||||
})
|
||||
|
||||
if (errors.length > 0) return { valid: false, error: errors[0] }
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main preview composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
|
||||
const machinePreview = computed(() => {
|
||||
const type = deps.selectedMachineType.value
|
||||
if (!type) return null
|
||||
|
||||
const trimmedName = (deps.newMachine.name || '').trim()
|
||||
const currentSite = deps.newMachine.siteId
|
||||
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
|
||||
: null
|
||||
const trimmedReference = (deps.newMachine.reference || '').trim()
|
||||
|
||||
const baseFields = [
|
||||
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
|
||||
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
|
||||
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
|
||||
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
|
||||
]
|
||||
|
||||
const baseIssues: AnyRecord[] = []
|
||||
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
|
||||
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
|
||||
|
||||
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
|
||||
|
||||
// Component groups
|
||||
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
|
||||
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
|
||||
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
|
||||
const assignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.composantId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
assignmentLabel,
|
||||
assignments: machineAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
|
||||
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Piece groups
|
||||
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
|
||||
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
|
||||
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
|
||||
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
|
||||
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
|
||||
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
|
||||
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.pieceId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
machineAssignmentLabel, componentAssignmentLabel,
|
||||
machineAssignments, componentAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
|
||||
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Product groups
|
||||
const { stats: productGroups } = buildProductRequirementStats(type, deps)
|
||||
|
||||
// Aggregate
|
||||
const aggregatedIssues = [
|
||||
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
|
||||
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
|
||||
]
|
||||
|
||||
const statuses = [
|
||||
baseStatus,
|
||||
...componentGroups.map((g) => g.status),
|
||||
...pieceGroups.map((g) => g.status),
|
||||
...productGroups.map((g: AnyRecord) => g.status as string),
|
||||
]
|
||||
|
||||
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
|
||||
componentGroups,
|
||||
pieceGroups,
|
||||
productGroups,
|
||||
type: {
|
||||
name: type.name,
|
||||
category: type.category || null,
|
||||
hasStructuredDefinition:
|
||||
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.productRequirements as unknown[])?.length || 0) > 0,
|
||||
},
|
||||
status: overallStatus,
|
||||
ready: overallStatus === 'ready',
|
||||
issues: aggregatedIssues,
|
||||
}
|
||||
})
|
||||
|
||||
const blockingPreviewIssues = computed(() => {
|
||||
if (!machinePreview.value) return []
|
||||
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
|
||||
})
|
||||
|
||||
const canCreateMachine = computed(() => {
|
||||
if (!machinePreview.value) return false
|
||||
return blockingPreviewIssues.value.length === 0
|
||||
})
|
||||
|
||||
return {
|
||||
machinePreview,
|
||||
blockingPreviewIssues,
|
||||
canCreateMachine,
|
||||
}
|
||||
}
|
||||
365
app/composables/useMachineCreateSelections.ts
Normal file
365
app/composables/useMachineCreateSelections.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Machine creation – requirement selection state management.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Manages the reactive selection state
|
||||
* for component / piece / product requirements when creating a new machine.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreateSelectionsDeps {
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
pieces: { value: AnyRecord[] }
|
||||
get: (url: string) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactive state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
|
||||
const pieceLoadingByKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Piece option caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
|
||||
`${requirement?.id || 'req'}:${entryIndex}`
|
||||
|
||||
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
|
||||
if (!id) return null
|
||||
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||
for (const bucket of buckets) {
|
||||
if (!Array.isArray(bucket)) continue
|
||||
const found = bucket.find((piece) => piece?.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const cachePieceIfMissing = (piece: AnyRecord): void => {
|
||||
if (!piece?.id) return
|
||||
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
|
||||
pieces.value = [...current, piece]
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (
|
||||
requirement: AnyRecord,
|
||||
entryIndex: number,
|
||||
term = '',
|
||||
): Promise<void> => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
if (pieceLoadingByKey.value[key]) return
|
||||
|
||||
const requirementTypeId =
|
||||
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term && term.trim()) params.set('name', term.trim())
|
||||
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByKey.value = {
|
||||
...pieceOptionsByKey.value,
|
||||
[key]: extractCollection(result.data) as AnyRecord[],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry getters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
composantId: source?.composantId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
pieceId: source?.pieceId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeProductId:
|
||||
source?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null,
|
||||
productId: source?.productId || null,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected piece IDs (for dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const selectedPieceIds = computed(() => {
|
||||
const ids: string[] = []
|
||||
Object.values(pieceRequirementSelections).forEach((entries) => {
|
||||
;(entries || []).forEach((entry) => {
|
||||
if (entry?.pieceId) ids.push(entry.pieceId as string)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection setters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const setComponentRequirementComponent = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
componentId: string,
|
||||
): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.composantId = componentId || null
|
||||
if (componentId) {
|
||||
const component = findComponentById(componentId)
|
||||
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
|
||||
} else {
|
||||
entry.typeComposantId = requirement?.typeComposantId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setPieceRequirementPiece = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
pieceId: string,
|
||||
): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.pieceId = pieceId || null
|
||||
if (pieceId) {
|
||||
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||
if (piece) cachePieceIfMissing(piece as AnyRecord)
|
||||
} else {
|
||||
entry.typePieceId = requirement?.typePieceId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
productId: string,
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
} else {
|
||||
entry.typeProductId =
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clearRequirementSelections = (): void => {
|
||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||
delete componentRequirementSelections[key]
|
||||
})
|
||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||
delete pieceRequirementSelections[key]
|
||||
})
|
||||
Object.keys(productRequirementSelections).forEach((key) => {
|
||||
delete productRequirementSelections[key]
|
||||
})
|
||||
}
|
||||
|
||||
const initializeRequirementSelections = (type: AnyRecord): void => {
|
||||
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
|
||||
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
|
||||
const productRequirements = (type.productRequirements || []) as AnyRecord[]
|
||||
|
||||
componentRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
componentRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createComponentSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
componentRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
pieceRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
const entries = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createPieceSelectionEntry(requirement),
|
||||
)
|
||||
pieceRequirementSelections[requirement.id as string] = entries
|
||||
entries.forEach((_: unknown, index: number) => {
|
||||
fetchPieceOptions(requirement, index).catch(() => {})
|
||||
})
|
||||
} else {
|
||||
pieceRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
productRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
productRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createProductSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
productRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
pieceOptionsByKey,
|
||||
pieceLoadingByKey,
|
||||
selectedPieceIds,
|
||||
getPieceKey,
|
||||
findPieceInCachedOptions,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct,
|
||||
clearRequirementSelections,
|
||||
initializeRequirementSelections,
|
||||
}
|
||||
}
|
||||
1410
app/composables/useMachineDetailData.ts
Normal file
1410
app/composables/useMachineDetailData.ts
Normal file
File diff suppressed because it is too large
Load Diff
298
app/composables/useMachineHierarchy.ts
Normal file
298
app/composables/useMachineHierarchy.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Builds a component/piece hierarchy tree from flat machine link arrays.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
|
||||
*/
|
||||
|
||||
import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
|
||||
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const collectConstructeurs = (
|
||||
allConstructeurs: AnyRecord[],
|
||||
...sources: unknown[]
|
||||
): AnyRecord[] => {
|
||||
const ids = uniqueConstructeurIds(...sources)
|
||||
if (!ids.length) return []
|
||||
|
||||
const pools = sources
|
||||
.flatMap((source) => {
|
||||
if (Array.isArray(source)) return [source]
|
||||
if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]]
|
||||
return []
|
||||
})
|
||||
.filter(Boolean) as AnyRecord[][]
|
||||
|
||||
// ConstructeurSummary and AnyRecord are structurally compatible at runtime
|
||||
const allPools = [...pools, allConstructeurs] as unknown as Array<ConstructeurSummary[]>
|
||||
return resolveConstructeurs(ids, ...allPools) as unknown as AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link array resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => {
|
||||
if (!source || typeof source !== 'object') return null
|
||||
for (const key of keys) {
|
||||
const value = (source as AnyRecord)[key]
|
||||
if (Array.isArray(value)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge trees (for incremental updates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
if (!updates.length) {
|
||||
return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]),
|
||||
)
|
||||
const merged = existing.map((piece) => {
|
||||
const update = updateMap.get(piece.id)
|
||||
if (!update) return piece
|
||||
return { ...piece, ...update, customFields: update.customFields ?? piece.customFields }
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((piece) => piece.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((component) => ({
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
}))
|
||||
}
|
||||
if (!updates.length) return existing
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((component) => [
|
||||
component.id,
|
||||
{
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
},
|
||||
]),
|
||||
)
|
||||
const merged: AnyRecord[] = existing.map((component) => {
|
||||
const update = updateMap.get(component.id)
|
||||
if (!update) {
|
||||
return {
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []),
|
||||
subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...component,
|
||||
...update,
|
||||
customFields: update.customFields ?? component.customFields,
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]),
|
||||
subComponents: mergeComponentTrees(
|
||||
(component.subComponents || []) as AnyRecord[],
|
||||
(update.subComponents || []) as AnyRecord[],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((component) => component.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build hierarchy from links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildMachineHierarchyFromLinks = (
|
||||
componentLinks: AnyRecord[] = [],
|
||||
pieceLinks: AnyRecord[] = [],
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
allConstructeurs: AnyRecord[] = [],
|
||||
): { components: AnyRecord[]; machinePieces: AnyRecord[] } => {
|
||||
const normalizeComponentLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId)
|
||||
|
||||
const normalizePieceLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId)
|
||||
|
||||
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
|
||||
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||
|
||||
const overrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const basePiece: AnyRecord = {
|
||||
...appliedPiece,
|
||||
id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`,
|
||||
pieceId,
|
||||
name: overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce',
|
||||
reference: overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null,
|
||||
prix: overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null,
|
||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
||||
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
|
||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
typeMachinePieceRequirement: requirement,
|
||||
typeMachinePieceRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
overrides,
|
||||
originalPiece,
|
||||
machinePieceLink: link,
|
||||
machinePieceLinkId,
|
||||
linkId: machinePieceLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId),
|
||||
parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId),
|
||||
parentComponentName,
|
||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
skeletonOnly: !pieceId,
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId)
|
||||
|
||||
return {
|
||||
...basePiece,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || basePiece.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null,
|
||||
productId: resolvedProductId || appliedPiece.productId || null,
|
||||
product: resolvedProduct || appliedPiece.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
|
||||
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||
|
||||
const compOverrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
||||
|
||||
const pieces = Array.isArray(link.pieceLinks)
|
||||
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
const subComponents = Array.isArray(link.childLinks)
|
||||
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const baseComponent: AnyRecord = {
|
||||
...appliedComponent,
|
||||
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
|
||||
composantId,
|
||||
name: componentName,
|
||||
reference: compOverrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null,
|
||||
prix: compOverrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null,
|
||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
||||
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
|
||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
typeMachineComponentRequirement: requirement,
|
||||
typeMachineComponentRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
overrides: compOverrides,
|
||||
machineComponentLinkOverrides: compOverrides,
|
||||
definitionOverrides: compOverrides,
|
||||
originalComposant: originalComponent,
|
||||
machineComponentLink: link,
|
||||
machineComponentLinkId,
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
sousComposants: subComponents,
|
||||
skeletonOnly: !composantId,
|
||||
}
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
||||
|
||||
return {
|
||||
...baseComponent,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || baseComponent.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null,
|
||||
productId: resolvedProductId || appliedComponent.productId || null,
|
||||
product: resolvedProduct || appliedComponent.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const rootComponents = (Array.isArray(componentLinks) ? componentLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map(createComponentNode)
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map((link) => createPieceNode(link, null))
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
return { components: rootComponents, machinePieces }
|
||||
}
|
||||
163
app/composables/useMachinePrint.ts
Normal file
163
app/composables/useMachinePrint.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Machine print selection and execution logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue.
|
||||
*/
|
||||
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface PrintSelection {
|
||||
machine: { info: boolean; customFields: boolean; documents: boolean }
|
||||
components: Record<string, boolean>
|
||||
pieces: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function useMachinePrint() {
|
||||
const printModalOpen = ref(false)
|
||||
const printSelection = reactive<PrintSelection>({
|
||||
machine: { info: true, customFields: true, documents: true },
|
||||
components: {},
|
||||
pieces: {},
|
||||
})
|
||||
|
||||
const ensurePrintSelectionEntries = (
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
printSelection.machine.info ??= true
|
||||
printSelection.machine.customFields ??= true
|
||||
printSelection.machine.documents ??= true
|
||||
|
||||
const ensureComponent = (component: AnyRecord) => {
|
||||
if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) {
|
||||
printSelection.components[component.id as string] = true
|
||||
}
|
||||
;((component.pieces || []) as AnyRecord[]).forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent)
|
||||
}
|
||||
|
||||
components.forEach(ensureComponent)
|
||||
machinePieces.forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setAllPrintSelection = (
|
||||
value: boolean,
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printSelection.machine.info = value
|
||||
printSelection.machine.customFields = value
|
||||
printSelection.machine.documents = value
|
||||
Object.keys(printSelection.components).forEach((key) => {
|
||||
printSelection.components[key] = value
|
||||
})
|
||||
Object.keys(printSelection.pieces).forEach((key) => {
|
||||
printSelection.pieces[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printModalOpen.value = true
|
||||
}
|
||||
|
||||
const closePrintModal = () => {
|
||||
printModalOpen.value = false
|
||||
}
|
||||
|
||||
const printMachine = (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
currentSelection: PrintSelection = printSelection,
|
||||
) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// machineReport.js has no type annotations; cast to avoid inferred never[] params
|
||||
const context = (buildMachinePrintContext as unknown as (config: Record<string, unknown>) => Record<string, unknown>)({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machinePieces,
|
||||
components,
|
||||
selection: currentSelection,
|
||||
})
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join('')
|
||||
|
||||
const htmlContent = buildMachinePrintHtml(context, styles)
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.position = 'fixed'
|
||||
iframe.style.right = '0'
|
||||
iframe.style.bottom = '0'
|
||||
iframe.style.width = '0'
|
||||
iframe.style.height = '0'
|
||||
iframe.style.border = '0'
|
||||
iframe.setAttribute('title', 'print-frame')
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const iframeWindow = iframe.contentWindow
|
||||
const iframeDocument = iframe.contentDocument || iframeWindow?.document
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
iframeDocument.open()
|
||||
iframeDocument.write(htmlContent)
|
||||
iframeDocument.close()
|
||||
|
||||
const triggerPrint = () => {
|
||||
iframeWindow.focus()
|
||||
iframeWindow.print()
|
||||
setTimeout(() => {
|
||||
iframe.remove()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (iframeDocument.readyState === 'complete') {
|
||||
setTimeout(triggerPrint, 50)
|
||||
} else {
|
||||
iframe.onload = () => setTimeout(triggerPrint, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrintConfirm = async (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
) => {
|
||||
closePrintModal()
|
||||
await nextTick()
|
||||
printMachine(machine, machineName, machineReference, machinePieces, components, printSelection)
|
||||
}
|
||||
|
||||
return {
|
||||
printModalOpen,
|
||||
printSelection,
|
||||
ensurePrintSelectionEntries,
|
||||
setAllPrintSelection,
|
||||
openPrintModal,
|
||||
closePrintModal,
|
||||
printMachine,
|
||||
handlePrintConfirm,
|
||||
}
|
||||
}
|
||||
838
app/composables/useMachineSkeletonEditor.ts
Normal file
838
app/composables/useMachineSkeletonEditor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
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 loaded = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value, relationKey) => {
|
||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry, index) => {
|
||||
return value.map((entry: Record<string, unknown>, _index: number) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
const relationField = relationKey.replace('Id', '')
|
||||
const relationValue = normalized[relationField]
|
||||
console.log(`[normalizeRequirementList] Entry ${index}:`, {
|
||||
relationKey,
|
||||
relationField,
|
||||
hasRelationKey: !!normalized[relationKey],
|
||||
relationValue,
|
||||
relationValueType: typeof relationValue
|
||||
})
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationId = extractRelationId(relationValue)
|
||||
console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
console.log(`[normalizeRequirementList] Normalized entry:`, normalized)
|
||||
return normalized
|
||||
return normalized as MachineTypeRequirement
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMachineType = (type) => {
|
||||
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
|
||||
if (!type || typeof type !== 'object') {
|
||||
return type
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...type,
|
||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||
}
|
||||
} as MachineType
|
||||
}
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
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 []
|
||||
}
|
||||
|
||||
export function useMachineTypesApi () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useMachineTypesApi() {
|
||||
const { showSuccess } = useToast()
|
||||
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
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items.map(normalizeMachineType)
|
||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
@@ -84,31 +82,32 @@ export function useMachineTypesApi () {
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineType = async (typeData) => {
|
||||
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/type_machines', typeData)
|
||||
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`)
|
||||
}
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineType = async (id, typeData) => {
|
||||
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await put(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||
@@ -116,34 +115,34 @@ export function useMachineTypesApi () {
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachineType = async (id) => {
|
||||
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedType = machineTypes.value.find(type => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter(type => type.id !== id)
|
||||
const deletedType = machineTypes.value.find((type) => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
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)
|
||||
if (!forceRefresh) {
|
||||
const localType = machineTypes.value.find(type => type.id === id)
|
||||
const localType = machineTypes.value.find((type) => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
@@ -153,12 +152,12 @@ export function useMachineTypesApi () {
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
// Mettre à jour le cache local
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
} else {
|
||||
} else if (normalized) {
|
||||
machineTypes.value.push(normalized)
|
||||
}
|
||||
return { success: true, data: normalized }
|
||||
@@ -166,12 +165,12 @@ export function useMachineTypesApi () {
|
||||
return result
|
||||
} catch (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 isLoading = () => loading.value
|
||||
const getMachineTypes = (): MachineType[] => machineTypes.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machineTypes,
|
||||
@@ -182,6 +181,6 @@ export function useMachineTypesApi () {
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
|
||||
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 loaded = ref(false)
|
||||
|
||||
const resolveLinkCollection = (source, keys) => {
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
@@ -22,16 +34,17 @@ const resolveLinkCollection = (source, keys) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeMachineResponse = (payload) => {
|
||||
const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const container = payload.machine && typeof payload.machine === 'object'
|
||||
? payload.machine
|
||||
: payload
|
||||
const raw = payload as Record<string, unknown>
|
||||
const container = raw.machine && typeof raw.machine === 'object'
|
||||
? raw.machine as Record<string, unknown>
|
||||
: raw
|
||||
|
||||
const normalized = { ...container }
|
||||
const normalized: Record<string, unknown> = { ...container }
|
||||
|
||||
if (!normalized.siteId) {
|
||||
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']) ??
|
||||
[]
|
||||
const pieceLinks = resolveLinkCollection(payload, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
const pieceLinks = resolveLinkCollection(raw, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
[]
|
||||
|
||||
normalized.componentLinks = componentLinks
|
||||
normalized.pieceLinks = pieceLinks
|
||||
|
||||
return normalized
|
||||
return normalized as Machine
|
||||
}
|
||||
|
||||
export function useMachines () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
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
|
||||
try {
|
||||
const result = await get('/machines')
|
||||
if (result.success) {
|
||||
const machineList = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.data?.member)
|
||||
? result.data.member
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member']
|
||||
: Array.isArray(result.data?.machines)
|
||||
? result.data.machines
|
||||
: Array.isArray(result.data?.data)
|
||||
? result.data.data
|
||||
: []
|
||||
const machineList = extractCollection(result.data)
|
||||
const normalized = machineList
|
||||
.map((item) => normalizeMachineResponse(item))
|
||||
.filter(Boolean)
|
||||
.filter((item): item is Machine => item !== null)
|
||||
machines.value = normalized
|
||||
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (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
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await post('/machines', normalizedPayload)
|
||||
if (result.success) {
|
||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (createdMachine) {
|
||||
machines.value.push(createdMachine)
|
||||
@@ -111,34 +115,31 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineFromType = async (machineData, typeMachine) => {
|
||||
// Créer la machine avec la structure héritée du type
|
||||
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
|
||||
const machineWithStructure = {
|
||||
...machineData,
|
||||
typeMachineId: typeMachine.id
|
||||
// La structure sera automatiquement héritée du type
|
||||
// Les composants et pièces seront créés automatiquement
|
||||
typeMachineId: typeMachine.id,
|
||||
}
|
||||
|
||||
return await createMachine(machineWithStructure)
|
||||
}
|
||||
|
||||
const updateMachineData = async (id, machineData) => {
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
const index = machines.value.findIndex(machine => machine.id === id)
|
||||
const index = machines.value.findIndex((machine) => machine.id === id)
|
||||
if (index !== -1 && updatedMachine) {
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
@@ -150,13 +151,13 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reconfigureSkeleton = async (machineId, payload) => {
|
||||
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Identifiant de machine manquant' }
|
||||
}
|
||||
@@ -165,28 +166,28 @@ export function useMachines () {
|
||||
try {
|
||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
||||
if (result.success) {
|
||||
const index = machines.value.findIndex(machine => machine.id === machineId)
|
||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||
if (index !== -1) {
|
||||
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],
|
||||
...(updatedMachine || {}),
|
||||
}
|
||||
...updatedMachine,
|
||||
} as Machine
|
||||
}
|
||||
showSuccess('Structure de la machine mise à jour avec succès')
|
||||
}
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMissingCustomFields = async (machineId, { showToast: shouldShowToast = true } = {}) => {
|
||||
const addMissingCustomFields = async (machineId: string, { showToast: shouldShowToast = true } = {}): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
const error = 'Identifiant de machine manquant'
|
||||
if (shouldShowToast) {
|
||||
@@ -206,46 +207,46 @@ export function useMachines () {
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l’ajout des champs personnalisés manquants:', error)
|
||||
console.error('Erreur lors de l\'ajout des champs personnalisés manquants:', error)
|
||||
if (shouldShowToast) {
|
||||
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
|
||||
try {
|
||||
const result = await del(`/machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedMachine = machines.value.find(machine => machine.id === id)
|
||||
machines.value = machines.value.filter(machine => machine.id !== id)
|
||||
const deletedMachine = machines.value.find((machine) => machine.id === id)
|
||||
machines.value = machines.value.filter((machine) => machine.id !== id)
|
||||
showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineById = (id) => {
|
||||
return machines.value.find(machine => machine.id === id)
|
||||
const getMachineById = (id: string): Machine | undefined => {
|
||||
return machines.value.find((machine) => machine.id === id)
|
||||
}
|
||||
|
||||
const getMachinesBySite = (siteId) => {
|
||||
return machines.value.filter(machine => machine.siteId === siteId)
|
||||
const getMachinesBySite = (siteId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||
}
|
||||
|
||||
const getMachinesByType = (typeMachineId) => {
|
||||
return machines.value.filter(machine => machine.typeMachineId === typeMachineId)
|
||||
const getMachinesByType = (typeMachineId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
|
||||
}
|
||||
|
||||
const getMachines = () => machines.value
|
||||
const isLoading = () => loading.value
|
||||
const getMachines = (): Machine[] => machines.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machines,
|
||||
@@ -261,6 +262,6 @@ export function useMachines () {
|
||||
getMachinesByType,
|
||||
getMachines,
|
||||
isLoading,
|
||||
addMissingCustomFields
|
||||
addMissingCustomFields,
|
||||
}
|
||||
}
|
||||
65
app/composables/useNavDropdown.ts
Normal file
65
app/composables/useNavDropdown.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
id: string
|
||||
label: string
|
||||
export type PieceHistoryActor = EntityHistoryActor
|
||||
export type PieceHistoryEntry = EntityHistoryEntry
|
||||
|
||||
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 l’historique.'
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||
|
||||
const pieceTypes = ref([])
|
||||
const loadingPieceTypes = ref(false)
|
||||
|
||||
export function usePieceTypes () {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name) => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadPieceTypes = async () => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'PIECE',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200
|
||||
})
|
||||
|
||||
pieceTypes.value = data.items.map(item => ({
|
||||
...item,
|
||||
description: item.description ?? item.notes ?? null
|
||||
}))
|
||||
|
||||
return { success: true, data: pieceTypes.value }
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createPieceType = async (payload) => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'PIECE',
|
||||
notes: payload.description ?? payload.notes,
|
||||
description: payload.description ?? null,
|
||||
structure: payload.structure
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null
|
||||
}
|
||||
|
||||
pieceTypes.value.push(normalized)
|
||||
showSuccess(`Type de pièce "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceType = async (id, payload) => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
notes: payload.notes,
|
||||
code: payload.code,
|
||||
structure: payload.structure
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null
|
||||
}
|
||||
|
||||
const index = pieceTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
pieceTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de pièce "${data.name}" mis à jour`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalized
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePieceType = async (id) => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
pieceTypes.value = pieceTypes.value.filter(type => type.id !== id)
|
||||
showSuccess('Type de pièce supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPieceTypes = () => pieceTypes.value
|
||||
const isPieceTypeLoading = () => loadingPieceTypes.value
|
||||
|
||||
return {
|
||||
pieceTypes,
|
||||
loadingPieceTypes,
|
||||
loadPieceTypes,
|
||||
createPieceType,
|
||||
updatePieceType,
|
||||
deletePieceType,
|
||||
getPieceTypes,
|
||||
isPieceTypeLoading
|
||||
}
|
||||
}
|
||||
29
app/composables/usePieceTypes.ts
Normal file
29
app/composables/usePieceTypes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface PieceType extends EntityType {
|
||||
structure: PieceModelStructure | null
|
||||
}
|
||||
|
||||
export function usePieceTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PIECE',
|
||||
label: 'pièce',
|
||||
})
|
||||
|
||||
return {
|
||||
pieceTypes: types as Ref<PieceType[]>,
|
||||
loadingPieceTypes: loading,
|
||||
loadPieceTypes: loadTypes,
|
||||
createPieceType: createType,
|
||||
updatePieceType: updateType,
|
||||
deletePieceType: deleteType,
|
||||
getPieceTypes: () => types.value as PieceType[],
|
||||
isPieceTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,68 @@ import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from './useConstructeurs'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const pieces = ref([])
|
||||
export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
productIds?: string[]
|
||||
product?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
documents?: unknown[]
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface PieceListResult {
|
||||
success: boolean
|
||||
data?: { items: Piece[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface PieceSingleResult {
|
||||
success: boolean
|
||||
data?: Piece
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadPiecesOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const pieces = ref<Piece[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
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 extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function usePieces () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function usePieces() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (piece) => {
|
||||
const withResolvedConstructeurs = async (piece: Piece): Promise<Piece> => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return piece
|
||||
}
|
||||
@@ -68,12 +91,11 @@ export function usePieces () {
|
||||
const ids = uniqueConstructeurIds(
|
||||
piece.constructeurIds,
|
||||
piece.constructeurs,
|
||||
piece.constructeur,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(piece.constructeurs)
|
||||
&& piece.constructeurs.length > 0
|
||||
&& piece.constructeurs.every((item) => item && typeof item === 'object')
|
||||
Array.isArray(piece.constructeurs) &&
|
||||
piece.constructeurs.length > 0 &&
|
||||
piece.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
@@ -85,16 +107,7 @@ export function usePieces () {
|
||||
return piece
|
||||
}
|
||||
|
||||
/**
|
||||
* Load pieces with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
*/
|
||||
const loadPieces = async (options = {}) => {
|
||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const {
|
||||
@@ -102,7 +115,7 @@ export function usePieces () {
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc'
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
@@ -110,11 +123,9 @@ export function usePieces () {
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
// API Platform uses property filters
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
// API Platform OrderFilter syntax: order[field]=direction
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
@@ -129,79 +140,84 @@ export function usePieces () {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
return result as PieceListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createPiece = async (pieceData) => {
|
||||
const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||
const result = await post('/pieces', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Piece)
|
||||
pieces.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const displayName = result.data?.name
|
||||
|| pieceData?.definition?.name
|
||||
|| pieceData?.name
|
||||
|| 'Pièce'
|
||||
const definition = (pieceData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|
||||
const displayName =
|
||||
(result.data as Piece)?.name ||
|
||||
(definition?.name as string | undefined) ||
|
||||
pieceData?.name ||
|
||||
'Pièce'
|
||||
showSuccess(`Pièce "${displayName}" créée avec succès`)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceData = async (id, pieceData) => {
|
||||
const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||
const result = await patch(`/pieces/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updated = await withResolvedConstructeurs(result.data)
|
||||
const index = pieces.value.findIndex(piece => piece.id === id)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Piece)
|
||||
const index = pieces.value.findIndex((piece) => piece.id === id)
|
||||
if (index !== -1) {
|
||||
pieces.value[index] = updated
|
||||
}
|
||||
showSuccess(`Pièce "${updated?.name || pieceData.name || ''}" mise à jour avec succès`)
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePiece = async (id) => {
|
||||
const deletePiece = async (id: string): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/pieces/${id}`)
|
||||
if (result.success) {
|
||||
const deletedPiece = pieces.value.find(piece => piece.id === id)
|
||||
pieces.value = pieces.value.filter(piece => piece.id !== id)
|
||||
const deletedPiece = pieces.value.find((piece) => piece.id === id)
|
||||
pieces.value = pieces.value.filter((piece) => piece.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -219,6 +235,6 @@ export function usePieces () {
|
||||
updatePiece: updatePieceData,
|
||||
deletePiece,
|
||||
getPieces,
|
||||
isLoading
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
id: string
|
||||
label: string
|
||||
export type ProductHistoryActor = EntityHistoryActor
|
||||
export type ProductHistoryEntry = EntityHistoryEntry
|
||||
|
||||
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 l’historique.'
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||
|
||||
const productTypes = ref([])
|
||||
const loadingProductTypes = ref(false)
|
||||
|
||||
export function useProductTypes () {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name) => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadProductTypes = async () => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'PRODUCT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
productTypes.value = data.items.map(item => ({
|
||||
...item,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
}))
|
||||
|
||||
return { success: true, data: productTypes.value }
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createProductType = async (payload) => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'PRODUCT',
|
||||
notes: payload.description ?? payload.notes,
|
||||
description: payload.description ?? null,
|
||||
structure: payload.structure,
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
productTypes.value.push(normalized)
|
||||
showSuccess(`Type de produit "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateProductType = async (id, payload) => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
notes: payload.notes,
|
||||
code: payload.code,
|
||||
structure: payload.structure,
|
||||
})
|
||||
|
||||
const normalized = {
|
||||
...data,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
const index = productTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
productTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de produit "${data.name}" mis à jour`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProductType = async (id) => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
productTypes.value = productTypes.value.filter(type => type.id !== id)
|
||||
showSuccess('Type de produit supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
productTypes,
|
||||
loadingProductTypes,
|
||||
loadProductTypes,
|
||||
createProductType,
|
||||
updateProductType,
|
||||
deleteProductType,
|
||||
}
|
||||
}
|
||||
27
app/composables/useProductTypes.ts
Normal file
27
app/composables/useProductTypes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface ProductType extends EntityType {
|
||||
structure: ProductModelStructure | null
|
||||
}
|
||||
|
||||
export function useProductTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PRODUCT',
|
||||
label: 'produit',
|
||||
})
|
||||
|
||||
return {
|
||||
productTypes: types as Ref<ProductType[]>,
|
||||
loadingProductTypes: loading,
|
||||
loadProductTypes: loadTypes,
|
||||
createProductType: createType,
|
||||
updateProductType: updateType,
|
||||
deleteProductType: deleteType,
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,53 @@ import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from './useConstructeurs'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const products = ref([])
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
typeProductId?: string | null
|
||||
typeProduct?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
supplierPrice?: number | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
documents?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProductListResult {
|
||||
success: boolean
|
||||
data?: { items: Product[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ProductSingleResult {
|
||||
success: boolean
|
||||
data?: Product
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadProductsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const products = ref<Product[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const replaceInCache = (item) => {
|
||||
const replaceInCache = (item: Product): boolean => {
|
||||
if (!item?.id) {
|
||||
return false
|
||||
}
|
||||
@@ -26,38 +63,23 @@ const replaceInCache = (item) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
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 extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useProducts () {
|
||||
export function useProducts() {
|
||||
const { showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (product) => {
|
||||
const withResolvedConstructeurs = async (product: Product): Promise<Product> => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return product
|
||||
}
|
||||
@@ -70,12 +92,11 @@ export function useProducts () {
|
||||
const ids = uniqueConstructeurIds(
|
||||
product.constructeurIds,
|
||||
product.constructeurs,
|
||||
product.constructeur,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(product.constructeurs)
|
||||
&& product.constructeurs.length > 0
|
||||
&& product.constructeurs.every((item) => item && typeof item === 'object')
|
||||
Array.isArray(product.constructeurs) &&
|
||||
product.constructeurs.length > 0 &&
|
||||
product.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
@@ -87,26 +108,23 @@ export function useProducts () {
|
||||
return product
|
||||
}
|
||||
|
||||
/**
|
||||
* Load products with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
* @param {boolean} [options.force=false] - Force reload even if already loaded
|
||||
*/
|
||||
const loadProducts = async (options = {}) => {
|
||||
const loadProducts = async (options: LoadProductsOptions = {}): Promise<ProductListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
force = false
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
@@ -140,17 +158,17 @@ export function useProducts () {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(`Impossible de charger les produits: ${result.error}`)
|
||||
}
|
||||
return result
|
||||
return result as ProductListResult
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement des produits:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(`Impossible de charger les produits: ${message}`)
|
||||
return { success: false, error: message }
|
||||
@@ -159,26 +177,27 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const createProduct = async (payload) => {
|
||||
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await post('/products', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
const added = replaceInCache(enriched)
|
||||
if (added) {
|
||||
total.value += 1
|
||||
}
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la création du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -187,23 +206,24 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const updateProduct = async (id, payload) => {
|
||||
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await patch(`/products/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -212,23 +232,23 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProduct = async (id) => {
|
||||
const deleteProduct = async (id: string): Promise<ProductSingleResult> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await del(`/products/${id}`)
|
||||
if (result.success) {
|
||||
const removed = products.value.find((product) => product.id === id)
|
||||
products.value = products.value.filter((product) => product.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
return { success: true }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la suppression du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -237,7 +257,7 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const getProduct = async (id, options = {}) => {
|
||||
const getProduct = async (id: string, options: { force?: boolean } = {}): Promise<ProductSingleResult> => {
|
||||
const shouldForce = !!options.force
|
||||
if (!shouldForce) {
|
||||
const cached = products.value.find((product) => product.id === id)
|
||||
@@ -249,14 +269,14 @@ export function useProducts () {
|
||||
try {
|
||||
const result = await get(`/products/${id}`)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
import type { Profile } from './useProfiles'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseUrl = process.server
|
||||
? (config.apiBaseUrl || config.public.apiBaseUrl || '')
|
||||
: (config.public.apiBaseUrl || '')
|
||||
const baseUrl = import.meta.server
|
||||
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
|
||||
: ((config.public.apiBaseUrl as string) || '')
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfileSession () {
|
||||
const activeProfile = useState('profileSession:active', () => null)
|
||||
const sessionLoaded = useState('profileSession:loaded', () => false)
|
||||
const loading = useState('profileSession:loading', () => false)
|
||||
export function useProfileSession() {
|
||||
const activeProfile = useState<Profile | null>('profileSession:active', () => null)
|
||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) { return undefined }
|
||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
||||
if (!import.meta.server) { return undefined }
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchCurrentProfile = async () => {
|
||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch(buildUrl('/session/profile'), {
|
||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
const err = error as { status?: number }
|
||||
if (err?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
@@ -42,29 +44,29 @@ export function useProfileSession () {
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = () => {
|
||||
const ensureSession = (): Promise<Profile | null> => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
const activateProfile = async (profileId) => {
|
||||
const activateProfile = async (profileId: string): Promise<void> => {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { profileId },
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
@@ -79,6 +81,6 @@ export function useProfileSession () {
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
logout
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
74
app/composables/useProfiles.ts
Normal file
74
app/composables/useProfiles.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { navigateTo, useRoute } from '#imports'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
@@ -41,6 +42,7 @@ export function useSiteManagement() {
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites()
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments()
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const showAddSiteModal = ref(false)
|
||||
const showEditSiteModal = ref(false)
|
||||
@@ -132,7 +134,8 @@ export function useSiteManagement() {
|
||||
if (index !== -1) {
|
||||
sites.value[index] = {
|
||||
...sites.value[index],
|
||||
...updated
|
||||
...updated,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,8 +169,9 @@ export function useSiteManagement() {
|
||||
)
|
||||
uploadingDocuments.value = false
|
||||
|
||||
if (uploadResult.success) {
|
||||
uploadedDocuments = uploadResult.data || []
|
||||
if (uploadResult.success && uploadResult.data) {
|
||||
const data = uploadResult.data
|
||||
uploadedDocuments = (Array.isArray(data) ? data : [data]) as SiteDocument[]
|
||||
selectedFiles.value = []
|
||||
}
|
||||
}
|
||||
@@ -250,9 +254,9 @@ export function useSiteManagement() {
|
||||
|
||||
const confirmDeleteSite = async (site: SiteWithDocuments) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`
|
||||
)
|
||||
!await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
const sites = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
export function useSites () {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
console.log('sites api result', result)
|
||||
|
||||
if (result.success) {
|
||||
const collection = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.data?.member)
|
||||
? result.data.member
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member']
|
||||
: Array.isArray(result.data?.data)
|
||||
? result.data.data
|
||||
: []
|
||||
sites.value = collection
|
||||
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success) {
|
||||
sites.value.push(result.data)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id, siteData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success) {
|
||||
const index = sites.value.findIndex(site => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/sites/${id}`)
|
||||
if (result.success) {
|
||||
const deletedSite = sites.value.find(site => site.id === id)
|
||||
sites.value = sites.value.filter(site => site.id !== id)
|
||||
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id) => {
|
||||
return sites.value.find(site => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
loadSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
123
app/composables/useSites.ts
Normal file
123
app/composables/useSites.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
contactPostalCode?: string
|
||||
contactCity?: string
|
||||
machines?: unknown[]
|
||||
documents?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface SiteResult {
|
||||
success: boolean
|
||||
data?: Site
|
||||
error?: string
|
||||
}
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSites() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
if (result.success) {
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success && result.data) {
|
||||
sites.value.push(result.data as Site)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id: string, siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success && result.data) {
|
||||
const index = sites.value.findIndex((site) => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data as Site
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id: string): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/sites/${id}`)
|
||||
if (result.success) {
|
||||
const deletedSite = sites.value.find((site) => site.id === id)
|
||||
sites.value = sites.value.filter((site) => site.id !== id)
|
||||
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id: string): Site | undefined => {
|
||||
return sites.value.find((site) => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
loadSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
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
|
||||
let nextId = 1
|
||||
|
||||
export function useToast () {
|
||||
const showToast = (message, type = 'info', duration = 3500) => {
|
||||
export function useToast() {
|
||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||
const id = nextId++
|
||||
const toast = {
|
||||
const toast: Toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true
|
||||
visible: true,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
@@ -28,25 +37,25 @@ export function useToast () {
|
||||
return id
|
||||
}
|
||||
|
||||
const showSuccess = (message, duration = 5000) => {
|
||||
const showSuccess = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message, duration = 5000) => {
|
||||
const showError = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
const showWarning = (message, duration = 6000) => {
|
||||
const showWarning = (message: string, duration = 6000): number => {
|
||||
return showToast(message, 'warning', duration)
|
||||
}
|
||||
|
||||
const showInfo = (message, duration = 5000) => {
|
||||
const showInfo = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'info', duration)
|
||||
}
|
||||
|
||||
const removeToast = (id) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index !== -1) {
|
||||
const removeToast = (id: number): void => {
|
||||
const index = toasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index !== -1 && toasts.value[index]) {
|
||||
toasts.value[index].visible = false
|
||||
setTimeout(() => {
|
||||
toasts.value.splice(index, 1)
|
||||
@@ -54,7 +63,7 @@ export function useToast () {
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const clearAll = (): void => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
@@ -66,6 +75,6 @@ export function useToast () {
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeToast,
|
||||
clearAll
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -40,19 +42,24 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
@@ -104,7 +111,7 @@ const loadCategory = async () => {
|
||||
code: response.code,
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
@@ -132,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie de composant créée avec succès.')
|
||||
await router.push('/component-category')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -539,26 +539,33 @@ import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/mo
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
@@ -569,7 +576,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
@@ -601,75 +608,8 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
@@ -679,49 +619,6 @@ const editionForm = reactive({
|
||||
})
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const documentIcon = (doc: any) =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
const formatSize = (size: number | null | undefined) => {
|
||||
if (size === null || size === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (size === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
const shouldInlinePdf = (document: any) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src || ''
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
const documentPreviewSrc = (document: any) => {
|
||||
if (!document?.path) {
|
||||
return ''
|
||||
}
|
||||
if (isPdfDocument(document)) {
|
||||
return appendPdfViewerParams(document.path)
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
@@ -761,12 +658,6 @@ const componentCatalogMap = computed(() =>
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const documentThumbnailClass = (document: any) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
}
|
||||
return 'h-16 w-16'
|
||||
}
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
@@ -778,20 +669,6 @@ const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (!doc?.path) {
|
||||
return
|
||||
}
|
||||
const target = String(doc.path)
|
||||
if (target.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = target
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
window.open(target, '_blank')
|
||||
}
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
@@ -831,7 +708,7 @@ const refreshDocuments = async () => {
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = result.data || []
|
||||
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
@@ -865,15 +742,7 @@ const refreshCustomFieldInputs = (
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
@@ -883,19 +752,6 @@ const canSubmit = computed(() => Boolean(
|
||||
!saving.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const fetchComponent = async () => {
|
||||
const id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -908,12 +764,10 @@ const fetchComponent = async () => {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
component.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadHistory(result.data.id)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
@@ -994,9 +848,17 @@ const submitEdition = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateComposant(component.value.id, payload)
|
||||
if (result.success) {
|
||||
const updatedComponent = result.data
|
||||
await saveCustomFieldValues(updatedComponent)
|
||||
if (result.success && result.data) {
|
||||
const updatedComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
[
|
||||
updatedComponent?.typeComposant?.customFields,
|
||||
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/component-catalog')
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -1006,260 +868,6 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildCustomFieldInputs = (
|
||||
structure: ComponentModelStructure | null,
|
||||
values: any[] | null,
|
||||
): CustomFieldInput[] => {
|
||||
const normalizedStructure = structure ? normalizeStructureForEditor(structure) : null
|
||||
const definitions = normalizeCustomFieldInputs(normalizedStructure)
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const mapById = new Map<string, any>()
|
||||
const mapByName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) {
|
||||
mapById.set(fieldId, entry)
|
||||
}
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) {
|
||||
mapByName.set(fieldName, entry)
|
||||
}
|
||||
})
|
||||
|
||||
const resolved: CustomFieldInput[] = definitions.map((definition) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: definition.customFieldId || definition.id,
|
||||
customFieldValueId: null,
|
||||
orderIndex: definition.orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedValue = extractStoredCustomFieldValue(matched)
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
value: formatDefaultValue(definition.type, resolvedValue),
|
||||
orderIndex: Math.min(
|
||||
definition.orderIndex ?? 0,
|
||||
typeof matched.customField?.orderIndex === 'number'
|
||||
? matched.customField.orderIndex
|
||||
: definition.orderIndex ?? 0,
|
||||
),
|
||||
}
|
||||
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = resolveRequiredFlag(rawField)
|
||||
const options = resolveOptions(rawField)
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||
? rawField.customFieldValueId
|
||||
: null
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) {
|
||||
return field.name.trim()
|
||||
}
|
||||
if (typeof field?.key === 'string' && field.key.trim()) {
|
||||
return field.key.trim()
|
||||
}
|
||||
if (typeof field?.label === 'string' && field.label.trim()) {
|
||||
return field.label.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof field?.type === 'string'
|
||||
? field.type
|
||||
: typeof field?.value?.type === 'string'
|
||||
? field.value.type
|
||||
: ''
|
||||
const value = rawType.toLowerCase()
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||
return field.defaultValue
|
||||
}
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
|
||||
return field.value
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return field.default
|
||||
}
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
|
||||
return (field.value as any).defaultValue
|
||||
}
|
||||
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
|
||||
return (field.value as any).value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) {
|
||||
return ''
|
||||
}
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return 'true'
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return 'false'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const resolveRequiredFlag = (field: any): boolean => {
|
||||
if (typeof field?.required === 'boolean') {
|
||||
return field.required
|
||||
}
|
||||
const nestedRequired = field?.value?.required
|
||||
if (typeof nestedRequired === 'boolean') {
|
||||
return nestedRequired
|
||||
}
|
||||
if (typeof nestedRequired === 'string') {
|
||||
const normalized = nestedRequired.toLowerCase()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveOptions = (field: any): string[] => {
|
||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source)) {
|
||||
const mapped = source
|
||||
.map((option: unknown) => {
|
||||
if (option === null || option === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
return option.trim()
|
||||
}
|
||||
if (typeof option === 'object') {
|
||||
const record = option || {}
|
||||
const keys = ['value', 'label', 'name']
|
||||
for (const key of keys) {
|
||||
const candidate = record[key]
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
const fallback = String(option).trim()
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
})
|
||||
.filter((option) => option.length > 0)
|
||||
if (mapped.length) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const extractStoredCustomFieldValue = (entry: any): any => {
|
||||
if (entry === null || entry === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') {
|
||||
return entry
|
||||
}
|
||||
if (typeof entry !== 'object') {
|
||||
return String(entry)
|
||||
}
|
||||
const direct = entry.value
|
||||
if (direct !== undefined && direct !== null) {
|
||||
if (typeof direct === 'object') {
|
||||
if (direct === null) {
|
||||
return ''
|
||||
}
|
||||
if ('value' in direct && direct.value !== undefined && direct.value !== null) {
|
||||
return direct.value
|
||||
}
|
||||
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) {
|
||||
return direct.defaultValue
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return direct
|
||||
}
|
||||
if (entry.defaultValue !== undefined && entry.defaultValue !== null) {
|
||||
return entry.defaultValue
|
||||
}
|
||||
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) {
|
||||
return entry.customFieldValue.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
@@ -1314,13 +922,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
@@ -1357,13 +966,14 @@ const fetchProductTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedProductTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedProductTypeMap.value = next
|
||||
@@ -1513,117 +1123,20 @@ const structureSelections = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (updatedComponent: any) => {
|
||||
if (!updatedComponent || !updatedComponent.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) {
|
||||
definitionMap.set(name, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerDefinitions(updatedComponent?.typeComposant?.customFields)
|
||||
registerDefinitions(updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
|
||||
|
||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) {
|
||||
return field.customFieldId
|
||||
}
|
||||
if (field.id) {
|
||||
return field.id
|
||||
}
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const definitionId = resolveDefinitionId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
definitionId,
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500, force: true }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
if (component.value?.id) {
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
// Defer bulk catalog loads — not needed for initial render
|
||||
Promise.allSettled([
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500 }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
]).catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -360,6 +360,10 @@ import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
toFieldString,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
@@ -747,15 +751,7 @@ const serializeStructureAssignments = (
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
@@ -766,19 +762,6 @@ const canSubmit = computed(() => Boolean(
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
@@ -830,13 +813,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
@@ -1161,7 +1145,7 @@ const resolveOptions = (field: any): string[] => {
|
||||
return option.trim()
|
||||
}
|
||||
if (typeof option === 'object') {
|
||||
const record = option || {}
|
||||
const record = option as Record<string, unknown>
|
||||
const keys = ['value', 'label', 'name']
|
||||
for (const key of keys) {
|
||||
const candidate = record[key]
|
||||
|
||||
@@ -127,7 +127,7 @@ import { formatPhone } from '~/utils/formatters/phone'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { showError } = useToast()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||
@@ -211,8 +211,10 @@ const saveConstructeur = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
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)
|
||||
if (!result.success && result.error) {
|
||||
showError(result.error)
|
||||
|
||||
@@ -705,13 +705,15 @@ const editMachine = (machine) => {
|
||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||
}
|
||||
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const confirmDeleteMachine = async (machine) => {
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`
|
||||
)
|
||||
await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const result = await deleteMachine(machine.id)
|
||||
|
||||
@@ -108,7 +108,7 @@ import IconLucidePackage from "~icons/lucide/package";
|
||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||
import IconLucideBox from "~icons/lucide/box";
|
||||
|
||||
const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
|
||||
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
||||
useMachineTypesApi();
|
||||
|
||||
const categories = ref([
|
||||
@@ -131,13 +131,15 @@ const filteredTypes = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const { confirm: confirmDialog } = useConfirm();
|
||||
|
||||
const confirmDeleteType = async (type) => {
|
||||
const { showError, showSuccess } = useToast();
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`
|
||||
)
|
||||
await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const result = await deleteMachineType(type.id);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,10 +202,12 @@ const editMachine = (machine) => {
|
||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||
}
|
||||
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const confirmDeleteMachine = async (machine) => {
|
||||
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 {
|
||||
const result = await deleteMachine(machine.id)
|
||||
if (result.success) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,8 @@
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -40,19 +42,24 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadPieceTypes } = usePieceTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
@@ -102,7 +109,7 @@ const loadCategory = async () => {
|
||||
code: response.code,
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
structure: (response.structure as PieceModelStructure | null) ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
@@ -130,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
showSuccess('Catégorie de pièce créée avec succès.')
|
||||
await router.push('/piece-category')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -479,37 +479,44 @@ import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
@@ -542,75 +549,8 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
@@ -623,8 +563,6 @@ const editionForm = reactive({
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const documentIcon = (doc: any) =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||
)
|
||||
@@ -637,52 +575,6 @@ const refreshCustomFieldInputs = (
|
||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
const formatSize = (size: number | null | undefined) => {
|
||||
if (size === null || size === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (size === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
const shouldInlinePdf = (document: any) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src || ''
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
const documentPreviewSrc = (document: any) => {
|
||||
if (!document?.path) {
|
||||
return ''
|
||||
}
|
||||
if (isPdfDocument(document)) {
|
||||
return appendPdfViewerParams(document.path)
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
const documentThumbnailClass = (document: any) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
}
|
||||
return 'h-16 w-16'
|
||||
}
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
@@ -694,20 +586,6 @@ const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (!doc?.path) {
|
||||
return
|
||||
}
|
||||
const target = String(doc.path)
|
||||
if (target.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = target
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
window.open(target, '_blank')
|
||||
}
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
@@ -747,7 +625,7 @@ const refreshDocuments = async () => {
|
||||
try {
|
||||
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
pieceDocuments.value = result.data || []
|
||||
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
@@ -848,15 +726,7 @@ watch(structureProducts, (products) => {
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
@@ -869,19 +739,6 @@ const canSubmit = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const fetchPiece = async () => {
|
||||
const id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -893,20 +750,23 @@ const fetchPiece = async () => {
|
||||
if (result.success) {
|
||||
piece.value = result.data
|
||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
piece.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadPieceTypeDetails(result.data)
|
||||
await loadHistory(result.data.id)
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||
loadPieceTypeDetailsFromCache(result.data)
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||
const typeId = currentPiece?.typePieceId
|
||||
|| extractRelationId(currentPiece?.typePiece)
|
||||
|| ''
|
||||
@@ -914,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
pieceTypeDetails.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const type = await getModelType(typeId)
|
||||
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||
if (cachedType) {
|
||||
pieceTypeDetails.value = cachedType
|
||||
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
return
|
||||
}
|
||||
// Fallback: fetch if not in cache (edge case)
|
||||
getModelType(typeId).then((type) => {
|
||||
if (type && typeof type === 'object') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
|
||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
}
|
||||
} catch (error) {
|
||||
}).catch(() => {
|
||||
pieceTypeDetails.value = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
@@ -1040,9 +907,17 @@ const submitEdition = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updatePiece(piece.value.id, payload)
|
||||
if (result.success) {
|
||||
const updatedPiece = result.data
|
||||
await saveCustomFieldValues(updatedPiece)
|
||||
if (result.success && result.data) {
|
||||
const updatedPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
[
|
||||
updatedPiece?.typePiece?.pieceCustomFields,
|
||||
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/pieces-catalog')
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -1052,276 +927,8 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildCustomFieldInputs = (
|
||||
structure: PieceModelStructure | null,
|
||||
values: any[] | null,
|
||||
): CustomFieldInput[] => {
|
||||
const definitions = normalizeCustomFieldInputs(structure)
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const mapById = new Map<string, any>()
|
||||
const mapByName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) {
|
||||
mapById.set(fieldId, entry)
|
||||
}
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) {
|
||||
mapByName.set(fieldName, entry)
|
||||
}
|
||||
})
|
||||
|
||||
const resolved: CustomFieldInput[] = definitions.map((definition) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: definition.customFieldId || definition.id,
|
||||
customFieldValueId: null,
|
||||
orderIndex: definition.orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedValue = matched.value ?? ''
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
value: formatDefaultValue(definition.type, resolvedValue),
|
||||
orderIndex: Math.min(
|
||||
definition.orderIndex ?? 0,
|
||||
typeof matched.customField?.orderIndex === 'number'
|
||||
? matched.customField.orderIndex
|
||||
: definition.orderIndex ?? 0,
|
||||
),
|
||||
}
|
||||
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = !!rawField.required
|
||||
const options = Array.isArray(rawField.options)
|
||||
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
|
||||
: []
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||
? rawField.customFieldValueId
|
||||
: null
|
||||
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) {
|
||||
return field.name.trim()
|
||||
}
|
||||
if (typeof field?.key === 'string' && field.key.trim()) {
|
||||
return field.key.trim()
|
||||
}
|
||||
if (typeof field?.label === 'string' && field.label.trim()) {
|
||||
return field.label.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const value = typeof field?.type === 'string' ? field.type.toLowerCase() : ''
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||
return field.defaultValue
|
||||
}
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
|
||||
return field.value
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return field.default
|
||||
}
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
|
||||
return (field.value as any).defaultValue
|
||||
}
|
||||
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
|
||||
return (field.value as any).value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) {
|
||||
return ''
|
||||
}
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return 'true'
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return 'false'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (updatedPiece: any) => {
|
||||
if (!updatedPiece || !updatedPiece.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) {
|
||||
definitionMap.set(name, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerDefinitions(updatedPiece?.typePiece?.pieceCustomFields)
|
||||
registerDefinitions(updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields)
|
||||
|
||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) {
|
||||
return field.customFieldId
|
||||
}
|
||||
if (field.id) {
|
||||
return field.id
|
||||
}
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const definitionId = resolveDefinitionId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
definitionId,
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||
loading.value = false
|
||||
if (piece.value?.id) {
|
||||
await refreshDocuments()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -308,6 +308,13 @@ import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -466,15 +473,7 @@ watch(selectedType, (type) => {
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}),
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
@@ -487,19 +486,6 @@ const canSubmit = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
@@ -557,14 +543,23 @@ const submitCreation = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createPiece(payload)
|
||||
if (result.success) {
|
||||
await saveCustomFieldValues(result.data)
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
if (result.success && result.data) {
|
||||
const createdPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
createdPiece.id,
|
||||
[
|
||||
createdPiece?.typePiece?.pieceCustomFields,
|
||||
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (selectedDocuments.value.length && createdPiece.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
{
|
||||
files: selectedDocuments.value,
|
||||
context: { pieceId: result.data.id },
|
||||
context: { pieceId: createdPiece.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
@@ -593,225 +588,4 @@ onMounted(async () => {
|
||||
await loadPieceTypes()
|
||||
})
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = !!rawField.required
|
||||
const options = Array.isArray(rawField.options)
|
||||
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
|
||||
: []
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||
? rawField.customFieldValueId
|
||||
: null
|
||||
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) {
|
||||
return field.name.trim()
|
||||
}
|
||||
if (typeof field?.key === 'string' && field.key.trim()) {
|
||||
return field.key.trim()
|
||||
}
|
||||
if (typeof field?.label === 'string' && field.label.trim()) {
|
||||
return field.label.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const value = typeof field?.type === 'string' ? field.type.toLowerCase() : ''
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||
return field.defaultValue
|
||||
}
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
|
||||
return field.value
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return field.default
|
||||
}
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
|
||||
return (field.value as any).defaultValue
|
||||
}
|
||||
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
|
||||
return (field.value as any).value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) {
|
||||
return ''
|
||||
}
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return 'true'
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return 'false'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
const saveCustomFieldValues = async (createdPiece: any) => {
|
||||
if (!createdPiece || !createdPiece.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) {
|
||||
definitionMap.set(name, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerDefinitions(createdPiece?.typePiece?.pieceCustomFields)
|
||||
registerDefinitions(createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields)
|
||||
|
||||
const customizeFieldId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) {
|
||||
return field.customFieldId
|
||||
}
|
||||
if (field.id) {
|
||||
return field.id
|
||||
}
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const definitionId = customizeFieldId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
definitionId,
|
||||
'piece',
|
||||
createdPiece.id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -382,10 +382,12 @@ const reload = async () => {
|
||||
await loadProducts({ force: true })
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (product: Record<string, any>) => {
|
||||
const confirmed = window.confirm(
|
||||
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||
)
|
||||
const confirmed = await confirm({
|
||||
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -40,19 +42,24 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadProductTypes } = useProductTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
@@ -102,7 +109,7 @@ const loadCategory = async () => {
|
||||
code: response.code,
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
structure: (response.structure as ProductModelStructure | null) ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
@@ -130,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('PRODUCT')
|
||||
showSuccess('Catégorie de produit créée avec succès.')
|
||||
await router.push('/product-category')
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -402,26 +402,33 @@ import { formatProductStructurePreview, normalizeProductStructureForSave } from
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
@@ -458,75 +465,8 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ProductHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
const historyDiffEntries = (entry: ProductHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ProductModelStructure | null,
|
||||
@@ -545,15 +485,7 @@ const editionForm = reactive({
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim().length > 0
|
||||
}),
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
@@ -562,60 +494,6 @@ const canSubmit = computed(() =>
|
||||
|
||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||
|
||||
const documentIcon = (doc: any) =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
|
||||
const formatSize = (size: number | null | undefined) => {
|
||||
if (size === null || size === undefined) {
|
||||
return '—'
|
||||
}
|
||||
if (size === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
const shouldInlinePdf = (document: any) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src || ''
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
|
||||
const documentPreviewSrc = (document: any) => {
|
||||
if (!document?.path) {
|
||||
return ''
|
||||
}
|
||||
if (isPdfDocument(document)) {
|
||||
return appendPdfViewerParams(document.path)
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
|
||||
const documentThumbnailClass = (document: any) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
}
|
||||
return 'h-16 w-16'
|
||||
}
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
@@ -630,20 +508,6 @@ const closePreview = () => {
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (!doc?.path) {
|
||||
return
|
||||
}
|
||||
const target = String(doc.path)
|
||||
if (target.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = target
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
window.open(target, '_blank')
|
||||
}
|
||||
|
||||
const loadProduct = async () => {
|
||||
const id = route.params.id
|
||||
@@ -653,18 +517,20 @@ const loadProduct = async () => {
|
||||
return
|
||||
}
|
||||
const result = await getProduct(id)
|
||||
if (result.success) {
|
||||
if (result.success && 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()
|
||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
product.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await hydrateForm()
|
||||
await refreshDocuments()
|
||||
await loadHistory(result.data.id)
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
hydrateForm()
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
product.value = null
|
||||
}
|
||||
@@ -723,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
|
||||
}
|
||||
|
||||
const loadProductType = async () => {
|
||||
// Try using the expanded typeProduct from entity response first
|
||||
const embedded = product.value?.typeProduct
|
||||
if (embedded && typeof embedded === 'object' && embedded.id) {
|
||||
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
|
||||
if (embeddedStructure) {
|
||||
productType.value = embedded
|
||||
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!product.value?.typeProductId) {
|
||||
productType.value = product.value?.typeProduct ?? null
|
||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -734,12 +611,12 @@ const loadProductType = async () => {
|
||||
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du type de produit:', error)
|
||||
productType.value = product.value?.typeProduct ?? null
|
||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateForm = async () => {
|
||||
const hydrateForm = () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
@@ -754,7 +631,8 @@ const hydrateForm = async () => {
|
||||
: ''
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
await ensureConstructeurs(editionForm.constructeurIds)
|
||||
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
||||
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,86 +646,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
|
||||
|
||||
const buildCustomFieldInputs = (
|
||||
productStructure: ProductModelStructure | null,
|
||||
values: any[] | null | undefined,
|
||||
): CustomFieldInput[] => {
|
||||
if (!productStructure || typeof productStructure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const definitions = Array.isArray(productStructure.customFields) ? productStructure.customFields : []
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const byId = new Map<string, any>()
|
||||
const byName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) {
|
||||
byId.set(fieldId, entry)
|
||||
}
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) {
|
||||
byName.set(fieldName, entry)
|
||||
}
|
||||
})
|
||||
|
||||
return definitions
|
||||
.map((definition, index) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? byId.get(definitionId) : null) || byName.get(definition.name)
|
||||
const type = typeof definition.type === 'string' ? definition.type : 'text'
|
||||
const options = Array.isArray(definition.options) ? definition.options : []
|
||||
const required = !!definition.required
|
||||
const orderIndex = typeof definition.orderIndex === 'number' ? definition.orderIndex : index
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
id: definition.id ?? null,
|
||||
name: definition.name,
|
||||
type,
|
||||
required,
|
||||
options,
|
||||
value: '',
|
||||
customFieldId: definition.customFieldId || definition.id || null,
|
||||
customFieldValueId: null,
|
||||
orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedValue = matched.value ?? ''
|
||||
return {
|
||||
id: definition.id ?? null,
|
||||
name: definition.name,
|
||||
type,
|
||||
required,
|
||||
options,
|
||||
value: formatDefaultValue(type, resolvedValue),
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id || null,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
orderIndex,
|
||||
}
|
||||
})
|
||||
.filter((field): field is CustomFieldInput => !!field?.name)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, value: any): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return String(value === true || String(value).toLowerCase() === 'true')
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
@@ -875,7 +673,12 @@ const submitEdition = async () => {
|
||||
const result = await updateProduct(product.value.id, payload)
|
||||
if (result.success && result.data?.id) {
|
||||
product.value = result.data
|
||||
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||
const failedFields = await _saveCustomFieldValues(
|
||||
'product',
|
||||
result.data.id,
|
||||
[],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
return
|
||||
@@ -890,46 +693,6 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (productId: string) => {
|
||||
const failed: string[] = []
|
||||
for (const field of customFieldInputs.value) {
|
||||
const value = field.value ?? ''
|
||||
if (field.customFieldValueId) {
|
||||
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const metadata = field.customFieldId
|
||||
? undefined
|
||||
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'product',
|
||||
productId,
|
||||
String(value ?? ''),
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProduct()
|
||||
})
|
||||
|
||||
@@ -249,6 +249,10 @@ import { formatProductStructurePreview, normalizeProductStructureForSave } from
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
interface ProductCatalogType extends ModelType {
|
||||
structure: ProductModelStructure | null
|
||||
@@ -276,17 +280,6 @@ const creationForm = reactive({
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
|
||||
const loadingTypes = computed(() => loadingProductTypes.value)
|
||||
@@ -337,7 +330,7 @@ watch(selectedType, (type) => {
|
||||
if (!creationForm.name) {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure))
|
||||
customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
@@ -362,49 +355,6 @@ const canSubmit = computed(() => Boolean(
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
|
||||
const required = !!rawField.required
|
||||
const options = Array.isArray(rawField.options)
|
||||
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
|
||||
: []
|
||||
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
|
||||
return { id, name, type, required, options, value, customFieldId, orderIndex }
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const clearForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
|
||||
@@ -169,8 +169,10 @@ const create = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const remove = async (profileId) => {
|
||||
if (!confirm('Supprimer ce profil ?')) { return }
|
||||
if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
|
||||
deleting.value = profileId
|
||||
try {
|
||||
await deleteProfile(profileId)
|
||||
|
||||
@@ -185,25 +185,16 @@ const toDisplayCount = (value, fallback) => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
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) {
|
||||
console.error('No type ID provided in route')
|
||||
showError('Aucun identifiant de type fourni')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getMachineTypeById(typeId)
|
||||
console.log('API Result:', result)
|
||||
|
||||
if (result.success) {
|
||||
type.value = result.data
|
||||
console.log('Type loaded successfully:', type.value)
|
||||
} else {
|
||||
console.error('Failed to load type:', result.error)
|
||||
showError('Type non trouvé')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -211,7 +202,6 @@ onMounted(async () => {
|
||||
showError('Erreur lors du chargement')
|
||||
} finally {
|
||||
loading.value = false
|
||||
console.log('Loading finished, loading.value:', loading.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -206,15 +206,9 @@ const saveChanges = async () => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typeId = route.params.id
|
||||
console.log('=== EDIT TYPE PAGE LOADING ===')
|
||||
console.log('Loading type with ID:', typeId)
|
||||
|
||||
const result = await getMachineTypeById(typeId, true)
|
||||
console.log('API Result:', result)
|
||||
|
||||
if (result.success) {
|
||||
type.value = result.data
|
||||
console.log('Type loaded successfully:', type.value)
|
||||
|
||||
// Initialiser les données éditées
|
||||
editedType.value = {
|
||||
@@ -236,7 +230,6 @@ onMounted(async () => {
|
||||
showError('Erreur lors du chargement')
|
||||
} finally {
|
||||
loading.value = false
|
||||
console.log('Loading finished, loading.value:', loading.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRequestFetch } from '#imports';
|
||||
import type { FetchOptions } from 'ofetch';
|
||||
import type {
|
||||
ComponentModelStructure,
|
||||
PieceModelStructure,
|
||||
@@ -75,10 +74,10 @@ function resolveBaseUrl() {
|
||||
return runtimeConfig.public.apiBaseUrl || '';
|
||||
}
|
||||
|
||||
function createOptions<T>(options: FetchOptions<T> = {}) {
|
||||
function createOptions(options: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
baseURL: resolveBaseUrl(),
|
||||
credentials: 'include' as const,
|
||||
credentials: 'include',
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const extractRelationId = (value: unknown): string | null => {
|
||||
}
|
||||
if (trimmed.includes('/')) {
|
||||
const parts = trimmed.split('/').filter(Boolean);
|
||||
return parts.length ? parts[parts.length - 1] : null;
|
||||
return parts.length ? (parts[parts.length - 1] ?? null) : null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user