Compare commits
18 Commits
v1.2.0
...
634184c2be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634184c2be | ||
|
|
6152848957 | ||
|
|
046f464378 | ||
|
|
8700c253cd | ||
|
|
519fa3a8f4 | ||
|
|
e1594cab76 | ||
|
|
daaa1c4cb9 | ||
|
|
786b1d91f6 | ||
|
|
3436cd0b90 | ||
|
|
efe1fd2a73 | ||
|
|
a6664ce9a2 | ||
|
|
399ec1f7b4 | ||
|
|
86bb8af32d | ||
|
|
78718b85ae | ||
|
|
9ee348fff0 | ||
|
|
1fbd1d1b2e | ||
|
|
1f2d6c78e8 | ||
|
|
649f8ca9cc |
857
app/app.vue
857
app/app.vue
@@ -1,698 +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">
|
||||
<div class="w-14">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventory
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('pieces-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('pieces-desktop')"
|
||||
@focusin="setDropdown('pieces-desktop')"
|
||||
@focusout="scheduleDropdownClose('pieces-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
|
||||
:aria-expanded="openDropdown === 'pieces-desktop'"
|
||||
>
|
||||
Pièces
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('products-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('products-desktop')"
|
||||
@focusin="setDropdown('products-desktop')"
|
||||
@focusout="scheduleDropdownClose('products-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('products-desktop')"
|
||||
:aria-expanded="openDropdown === 'products-desktop'"
|
||||
>
|
||||
Produits
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('component-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('component-desktop')"
|
||||
@focusin="setDropdown('component-desktop')"
|
||||
@focusout="scheduleDropdownClose('component-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') ||
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('component-desktop')"
|
||||
:aria-expanded="openDropdown === 'component-desktop'"
|
||||
>
|
||||
Composant
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('resources-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('resources-desktop')"
|
||||
@focusin="setDropdown('resources-desktop')"
|
||||
@focusout="scheduleDropdownClose('resources-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-desktop')"
|
||||
:aria-expanded="openDropdown === 'resources-desktop'"
|
||||
>
|
||||
Ressources liées
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Bouton paramètres d'affichage -->
|
||||
<button
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<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>
|
||||
@@ -701,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 IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucideCpu from "~icons/lucide/cpu";
|
||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||
import logoSrc from "~/assets/LOGO_CARRE_BLANC.png";
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -115,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
|
||||
}
|
||||
@@ -145,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
|
||||
}
|
||||
@@ -246,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
@@ -511,6 +511,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"
|
||||
|
||||
@@ -541,7 +541,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))
|
||||
}
|
||||
@@ -825,10 +825,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,
|
||||
@@ -847,7 +846,7 @@ const removeCustomField = (index: number) => {
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces.push({
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
@@ -863,7 +862,7 @@ const removePiece = (index: number) => {
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products.push({
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
@@ -911,6 +910,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,
|
||||
@@ -153,7 +154,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 +209,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,10 +293,12 @@ 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;
|
||||
}
|
||||
@@ -363,22 +366,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 +388,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,
|
||||
};
|
||||
|
||||
@@ -278,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,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export function useCategoryEditGuard (config: GuardConfig) {
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
|
||||
@@ -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,26 @@
|
||||
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 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 +29,7 @@ const uniqueConstructeurs = (items = []) => {
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const normalizeIds = (ids = []) => {
|
||||
const normalizeIds = (ids: unknown[] = []): string[] => {
|
||||
if (!Array.isArray(ids)) {
|
||||
return []
|
||||
}
|
||||
@@ -28,7 +42,7 @@ const normalizeIds = (ids = []) => {
|
||||
)
|
||||
}
|
||||
|
||||
const upsertConstructeurs = (items = []) => {
|
||||
const upsertConstructeurs = (items: Constructeur[] = []) => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
return
|
||||
}
|
||||
@@ -36,32 +50,16 @@ 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 = ''): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
@@ -70,47 +68,49 @@ export function useConstructeurs () {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
}
|
||||
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 +129,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 +145,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 +153,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,
|
||||
}
|
||||
}
|
||||
171
app/composables/useEntityTypes.ts
Normal file
171
app/composables/useEntityTypes.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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> }> = {}
|
||||
|
||||
function getOrCreateState(category: ModelCategory) {
|
||||
if (!stateByCategory[category]) {
|
||||
stateByCategory[category] = {
|
||||
types: ref<EntityType[]>([]),
|
||||
loading: ref(false),
|
||||
}
|
||||
}
|
||||
return stateByCategory[category]
|
||||
}
|
||||
|
||||
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 (): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category,
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
state.types.value = data.items.map(normalizeItem)
|
||||
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,
|
||||
}
|
||||
}
|
||||
1404
app/composables/useMachineDetailData.ts
Normal file
1404
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,80 +1,76 @@
|
||||
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 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, showInfo } = useToast()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async () => {
|
||||
const loadMachineTypes = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items.map(normalizeMachineType)
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -84,31 +80,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 +113,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 +150,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 +163,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 +179,6 @@ export function useMachineTypesApi () {
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
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 resolveLinkCollection = (source, keys) => {
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
@@ -22,16 +33,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,42 +59,32 @@ 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 () {
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachines = async () => {
|
||||
const loadMachines = async (): Promise<void> => {
|
||||
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`)
|
||||
}
|
||||
@@ -93,14 +95,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 +113,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 +149,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 +164,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 +205,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 +260,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,24 +108,13 @@ 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
|
||||
} = options
|
||||
|
||||
if (loading.value) {
|
||||
@@ -140,17 +150,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 +169,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 +198,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 +224,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 +249,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 +261,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,30 +1,40 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const sites = ref([])
|
||||
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)
|
||||
|
||||
export function useSites () {
|
||||
export function useSites() {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async () => {
|
||||
const loadSites = async (): Promise<void> => {
|
||||
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
|
||||
: []
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||
}
|
||||
@@ -35,63 +45,63 @@ export function useSites () {
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData) => {
|
||||
const createSite = async (siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success) {
|
||||
sites.value.push(result.data)
|
||||
if (result.success && result.data) {
|
||||
sites.value.push(result.data as Site)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id, siteData) => {
|
||||
const updateSite = async (id: string, siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success) {
|
||||
const index = sites.value.findIndex(site => site.id === id)
|
||||
if (result.success && result.data) {
|
||||
const index = sites.value.findIndex((site) => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data
|
||||
sites.value[index] = result.data as Site
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id) => {
|
||||
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)
|
||||
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
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id) => {
|
||||
return sites.value.find(site => site.id === id)
|
||||
const getSiteById = (id: string): Site | undefined => {
|
||||
return sites.value.find((site) => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
@@ -106,6 +116,6 @@ export function useSites () {
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 { useToast } from '~/composables/useToast'
|
||||
|
||||
@@ -108,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 ComponentModelStructure | null) ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
|
||||
@@ -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()
|
||||
@@ -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') {
|
||||
@@ -994,9 +850,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 +870,6 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildCustomFieldInputs = (
|
||||
structure: ComponentModelStructure | null,
|
||||
values: any[] | null,
|
||||
): CustomFieldInput[] => {
|
||||
const normalizedStructure = structure ? normalizeStructureForEditor(structure) : null
|
||||
const definitions = normalizeCustomFieldInputs(normalizedStructure)
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const mapById = new Map<string, any>()
|
||||
const mapByName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) {
|
||||
mapById.set(fieldId, entry)
|
||||
}
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) {
|
||||
mapByName.set(fieldName, entry)
|
||||
}
|
||||
})
|
||||
|
||||
const resolved: CustomFieldInput[] = definitions.map((definition) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: definition.customFieldId || definition.id,
|
||||
customFieldValueId: null,
|
||||
orderIndex: definition.orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedValue = extractStoredCustomFieldValue(matched)
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
value: formatDefaultValue(definition.type, resolvedValue),
|
||||
orderIndex: Math.min(
|
||||
definition.orderIndex ?? 0,
|
||||
typeof matched.customField?.orderIndex === 'number'
|
||||
? matched.customField.orderIndex
|
||||
: definition.orderIndex ?? 0,
|
||||
),
|
||||
}
|
||||
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = resolveRequiredFlag(rawField)
|
||||
const options = resolveOptions(rawField)
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||
? rawField.customFieldValueId
|
||||
: null
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) {
|
||||
return field.name.trim()
|
||||
}
|
||||
if (typeof field?.key === 'string' && field.key.trim()) {
|
||||
return field.key.trim()
|
||||
}
|
||||
if (typeof field?.label === 'string' && field.label.trim()) {
|
||||
return field.label.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof field?.type === 'string'
|
||||
? field.type
|
||||
: typeof field?.value?.type === 'string'
|
||||
? field.value.type
|
||||
: ''
|
||||
const value = rawType.toLowerCase()
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||
return field.defaultValue
|
||||
}
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
|
||||
return field.value
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return field.default
|
||||
}
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
|
||||
return (field.value as any).defaultValue
|
||||
}
|
||||
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
|
||||
return (field.value as any).value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) {
|
||||
return ''
|
||||
}
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return 'true'
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return 'false'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const resolveRequiredFlag = (field: any): boolean => {
|
||||
if (typeof field?.required === 'boolean') {
|
||||
return field.required
|
||||
}
|
||||
const nestedRequired = field?.value?.required
|
||||
if (typeof nestedRequired === 'boolean') {
|
||||
return nestedRequired
|
||||
}
|
||||
if (typeof nestedRequired === 'string') {
|
||||
const normalized = nestedRequired.toLowerCase()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveOptions = (field: any): string[] => {
|
||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source)) {
|
||||
const mapped = source
|
||||
.map((option: unknown) => {
|
||||
if (option === null || option === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
return option.trim()
|
||||
}
|
||||
if (typeof option === 'object') {
|
||||
const record = option || {}
|
||||
const keys = ['value', 'label', 'name']
|
||||
for (const key of keys) {
|
||||
const candidate = record[key]
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
const fallback = String(option).trim()
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
})
|
||||
.filter((option) => option.length > 0)
|
||||
if (mapped.length) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const extractStoredCustomFieldValue = (entry: any): any => {
|
||||
if (entry === null || entry === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') {
|
||||
return entry
|
||||
}
|
||||
if (typeof entry !== 'object') {
|
||||
return String(entry)
|
||||
}
|
||||
const direct = entry.value
|
||||
if (direct !== undefined && direct !== null) {
|
||||
if (typeof direct === 'object') {
|
||||
if (direct === null) {
|
||||
return ''
|
||||
}
|
||||
if ('value' in direct && direct.value !== undefined && direct.value !== null) {
|
||||
return direct.value
|
||||
}
|
||||
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) {
|
||||
return direct.defaultValue
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return direct
|
||||
}
|
||||
if (entry.defaultValue !== undefined && entry.defaultValue !== null) {
|
||||
return entry.defaultValue
|
||||
}
|
||||
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) {
|
||||
return entry.customFieldValue.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
@@ -1314,13 +924,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 +968,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,104 +1125,6 @@ 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(),
|
||||
|
||||
@@ -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
@@ -42,6 +42,7 @@ 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 { useToast } from '~/composables/useToast'
|
||||
|
||||
@@ -106,7 +107,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)
|
||||
|
||||
@@ -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,31 +479,38 @@ 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()
|
||||
@@ -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') {
|
||||
@@ -918,9 +775,9 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
const type = await getModelType(typeId)
|
||||
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 (_error) {
|
||||
pieceTypeDetails.value = null
|
||||
}
|
||||
}
|
||||
@@ -1040,9 +897,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,271 +917,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 { useToast } from '~/composables/useToast'
|
||||
|
||||
@@ -106,7 +107,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)
|
||||
|
||||
@@ -402,20 +402,27 @@ 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()
|
||||
@@ -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,9 +517,9 @@ 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)) {
|
||||
@@ -768,86 +632,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 +659,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 +679,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;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const toStringId = (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;
|
||||
};
|
||||
|
||||
794
app/shared/model/componentStructure.ts
Normal file
794
app/shared/model/componentStructure.ts
Normal file
@@ -0,0 +1,794 @@
|
||||
import {
|
||||
createEmptyComponentModelStructure,
|
||||
type ComponentModelCustomFieldType,
|
||||
type ComponentModelCustomField,
|
||||
type ComponentModelPiece,
|
||||
type ComponentModelProduct,
|
||||
type ComponentModelStructure,
|
||||
type ComponentModelStructureNode,
|
||||
} from '../types/inventory'
|
||||
|
||||
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export interface ModelStructurePreview {
|
||||
customFields: number
|
||||
pieces: number
|
||||
products: number
|
||||
subcomponents: number
|
||||
}
|
||||
|
||||
export const defaultStructure = (): ComponentModelStructure => ({
|
||||
...createEmptyComponentModelStructure(),
|
||||
})
|
||||
|
||||
const ensureStructureShape = (input: any): ComponentModelStructure => {
|
||||
const base = createEmptyComponentModelStructure()
|
||||
if (!isPlainObject(input)) {
|
||||
return base
|
||||
}
|
||||
|
||||
const clone: ComponentModelStructure = {
|
||||
...base,
|
||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
|
||||
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||
subcomponents: Array.isArray((input as any).subcomponents)
|
||||
? (input as any).subcomponents
|
||||
: Array.isArray((input as any).subComponents)
|
||||
? (input as any).subComponents
|
||||
: [],
|
||||
typeComposantId: typeof (input as any).typeComposantId === 'string' ? (input as any).typeComposantId : undefined,
|
||||
typeComposantLabel: typeof (input as any).typeComposantLabel === 'string'
|
||||
? (input as any).typeComposantLabel
|
||||
: undefined,
|
||||
modelId: typeof (input as any).modelId === 'string' ? (input as any).modelId : undefined,
|
||||
familyCode: typeof (input as any).familyCode === 'string' ? (input as any).familyCode : undefined,
|
||||
alias: typeof (input as any).alias === 'string' ? (input as any).alias : undefined,
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
export const cloneStructure = (input: any): ComponentModelStructure => {
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(input ?? defaultStructure()))
|
||||
return ensureStructureShape(cloned)
|
||||
} catch (_error) {
|
||||
return defaultStructure()
|
||||
}
|
||||
}
|
||||
|
||||
export const toStringArray = (input: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = input
|
||||
.map((value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value).trim()
|
||||
})
|
||||
.filter((value) => value.length > 0)
|
||||
return parsed.length ? parsed : undefined
|
||||
}
|
||||
|
||||
export const extractFieldValueObject = (field: any): Record<string, any> => {
|
||||
if (isPlainObject(field?.value)) {
|
||||
return field.value as Record<string, any>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields
|
||||
.map((field, index) => {
|
||||
const rawName =
|
||||
typeof field?.name === 'string'
|
||||
? field.name
|
||||
: typeof field?.key === 'string'
|
||||
? field.key
|
||||
: ''
|
||||
const name = rawName.trim()
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const valueObject = extractFieldValueObject(field)
|
||||
|
||||
const candidateType =
|
||||
typeof field?.type === 'string' && field.type
|
||||
? field.type
|
||||
: typeof valueObject?.type === 'string'
|
||||
? valueObject.type
|
||||
: ''
|
||||
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
|
||||
? (candidateType as ComponentModelCustomFieldType)
|
||||
: 'text'
|
||||
|
||||
const required =
|
||||
typeof valueObject?.required === 'boolean' ? valueObject.required : !!field?.required
|
||||
|
||||
let options: string[] | undefined
|
||||
if (type === 'select') {
|
||||
options =
|
||||
toStringArray(valueObject?.options) ||
|
||||
toStringArray((valueObject as any)?.choices) ||
|
||||
toStringArray(field?.options)
|
||||
|
||||
if (!options && typeof field?.optionsText === 'string') {
|
||||
const parsedFromText = field.optionsText
|
||||
.split(/\r?\n/)
|
||||
.map((option: string) => option.trim())
|
||||
.filter((option: string) => option.length > 0)
|
||||
options = parsedFromText.length ? parsedFromText : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result: ComponentModelCustomField = { name, type, required }
|
||||
if (options) {
|
||||
result.options = options
|
||||
}
|
||||
const defaultCandidate =
|
||||
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
|
||||
const resolvedDefault = (() => {
|
||||
if (defaultCandidate === undefined || defaultCandidate === null) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof defaultCandidate === 'object') {
|
||||
if (defaultCandidate === null) {
|
||||
return undefined
|
||||
}
|
||||
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
|
||||
return (defaultCandidate as Record<string, any>).defaultValue
|
||||
}
|
||||
if ('value' in (defaultCandidate as Record<string, any>)) {
|
||||
return (defaultCandidate as Record<string, any>).value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return defaultCandidate
|
||||
})()
|
||||
if (resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '') {
|
||||
result.defaultValue = String(resolvedDefault)
|
||||
}
|
||||
const id = typeof field?.id === 'string' ? field.id : undefined
|
||||
if (id) {
|
||||
result.id = id
|
||||
}
|
||||
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
|
||||
if (customFieldId) {
|
||||
result.customFieldId = customFieldId
|
||||
}
|
||||
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||
result.orderIndex = orderIndex
|
||||
return result
|
||||
})
|
||||
.filter((field): field is ComponentModelCustomField => !!field)
|
||||
}
|
||||
|
||||
export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pieces
|
||||
.map((piece) => {
|
||||
const rawTypePieceId = typeof piece?.typePieceId === 'string'
|
||||
? piece.typePieceId.trim()
|
||||
: typeof piece?.typePiece?.id === 'string'
|
||||
? piece.typePiece.id.trim()
|
||||
: ''
|
||||
const typePieceId = rawTypePieceId.length > 0 ? rawTypePieceId : undefined
|
||||
|
||||
const rawTypePieceLabel = typeof piece?.typePieceLabel === 'string'
|
||||
? piece.typePieceLabel.trim()
|
||||
: typeof piece?.typePiece?.name === 'string'
|
||||
? piece.typePiece.name.trim()
|
||||
: ''
|
||||
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
|
||||
|
||||
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
|
||||
? piece.reference.trim()
|
||||
: undefined
|
||||
|
||||
const rawFamilyCode = typeof piece?.familyCode === 'string'
|
||||
? piece.familyCode.trim()
|
||||
: typeof piece?.typePiece?.code === 'string'
|
||||
? piece.typePiece.code.trim()
|
||||
: ''
|
||||
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
|
||||
|
||||
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
|
||||
const role = rawRole.length > 0 ? rawRole : undefined
|
||||
|
||||
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result: ComponentModelPiece = {}
|
||||
if (role) {
|
||||
result.role = role
|
||||
}
|
||||
if (familyCode) {
|
||||
result.familyCode = familyCode
|
||||
}
|
||||
if (reference !== undefined) {
|
||||
result.reference = reference
|
||||
}
|
||||
if (typePieceId) {
|
||||
result.typePieceId = typePieceId
|
||||
}
|
||||
if (typePieceLabel) {
|
||||
result.typePieceLabel = typePieceLabel
|
||||
}
|
||||
return result
|
||||
})
|
||||
.filter((piece): piece is ComponentModelPiece => !!piece)
|
||||
}
|
||||
|
||||
export const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
|
||||
if (!Array.isArray(products)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return products
|
||||
.map((product) => {
|
||||
const rawTypeProductId = typeof product?.typeProductId === 'string'
|
||||
? product.typeProductId.trim()
|
||||
: typeof product?.typeProduct?.id === 'string'
|
||||
? product.typeProduct.id.trim()
|
||||
: ''
|
||||
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
|
||||
|
||||
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
|
||||
? product.typeProductLabel.trim()
|
||||
: typeof product?.typeProduct?.name === 'string'
|
||||
? product.typeProduct.name.trim()
|
||||
: ''
|
||||
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
|
||||
|
||||
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
|
||||
? product.reference.trim()
|
||||
: undefined
|
||||
|
||||
const rawFamilyCode = typeof product?.familyCode === 'string'
|
||||
? product.familyCode.trim()
|
||||
: typeof product?.typeProduct?.code === 'string'
|
||||
? product.typeProduct.code.trim()
|
||||
: ''
|
||||
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
|
||||
|
||||
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
|
||||
const role = rawRole.length > 0 ? rawRole : undefined
|
||||
|
||||
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result: ComponentModelProduct = {}
|
||||
if (role) {
|
||||
result.role = role
|
||||
}
|
||||
if (familyCode) {
|
||||
result.familyCode = familyCode
|
||||
}
|
||||
if (reference !== undefined) {
|
||||
result.reference = reference
|
||||
}
|
||||
if (typeProductId) {
|
||||
result.typeProductId = typeProductId
|
||||
}
|
||||
if (typeProductLabel) {
|
||||
result.typeProductLabel = typeProductLabel
|
||||
}
|
||||
return result
|
||||
})
|
||||
.filter((product): product is ComponentModelProduct => !!product)
|
||||
}
|
||||
|
||||
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return components
|
||||
.map((component) => {
|
||||
const rawTypeComposantId = typeof component?.typeComposantId === 'string'
|
||||
? component.typeComposantId.trim()
|
||||
: typeof component?.typeComposant?.id === 'string'
|
||||
? component.typeComposant.id.trim()
|
||||
: ''
|
||||
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
|
||||
|
||||
const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
|
||||
? component.modelId.trim()
|
||||
: undefined
|
||||
|
||||
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
|
||||
? component.familyCode.trim()
|
||||
: undefined
|
||||
|
||||
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
|
||||
? component.alias.trim()
|
||||
: undefined
|
||||
|
||||
if (!typeComposantId && !modelId && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result: ComponentModelStructureNode = {
|
||||
subcomponents: sanitizeSubcomponents(
|
||||
Array.isArray(component?.subcomponents)
|
||||
? component.subcomponents
|
||||
: component?.subComponents,
|
||||
),
|
||||
}
|
||||
|
||||
if (typeComposantId) {
|
||||
result.typeComposantId = typeComposantId
|
||||
}
|
||||
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
|
||||
? component.typeComposantLabel.trim()
|
||||
: typeof component?.typeComposant?.name === 'string'
|
||||
? component.typeComposant.name.trim()
|
||||
: ''
|
||||
if (typeComposantLabel) {
|
||||
result.typeComposantLabel = typeComposantLabel
|
||||
}
|
||||
if (modelId) {
|
||||
result.modelId = modelId
|
||||
}
|
||||
if (familyCode) {
|
||||
result.familyCode = familyCode
|
||||
}
|
||||
if (alias) {
|
||||
result.alias = alias
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
.filter((component): component is ComponentModelStructureNode => !!component)
|
||||
}
|
||||
|
||||
const hydrateCustomFields = (fields: any[]): any[] => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields.map((field, index) => {
|
||||
const valueObject = extractFieldValueObject(field)
|
||||
const name = typeof field?.name === 'string'
|
||||
? field.name
|
||||
: typeof field?.key === 'string'
|
||||
? field.key
|
||||
: ''
|
||||
|
||||
const candidateType =
|
||||
typeof field?.type === 'string' && field.type
|
||||
? field.type
|
||||
: typeof valueObject?.type === 'string'
|
||||
? valueObject.type
|
||||
: ''
|
||||
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
|
||||
? (candidateType as ComponentModelCustomFieldType)
|
||||
: 'text'
|
||||
|
||||
const required =
|
||||
typeof field?.required === 'boolean'
|
||||
? field.required
|
||||
: typeof valueObject?.required === 'boolean'
|
||||
? valueObject.required
|
||||
: false
|
||||
|
||||
const options =
|
||||
toStringArray(field?.options) ||
|
||||
toStringArray(valueObject?.options) ||
|
||||
toStringArray((valueObject as any)?.choices) ||
|
||||
[]
|
||||
|
||||
const optionsText = typeof field?.optionsText === 'string'
|
||||
? field.optionsText
|
||||
: options.length
|
||||
? options.join('\n')
|
||||
: ''
|
||||
|
||||
const defaultCandidate =
|
||||
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
|
||||
const resolvedDefault = (() => {
|
||||
if (defaultCandidate === undefined || defaultCandidate === null) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof defaultCandidate === 'object') {
|
||||
if (defaultCandidate === null) {
|
||||
return undefined
|
||||
}
|
||||
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
|
||||
return (defaultCandidate as Record<string, any>).defaultValue
|
||||
}
|
||||
if ('value' in (defaultCandidate as Record<string, any>)) {
|
||||
return (defaultCandidate as Record<string, any>).value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return defaultCandidate
|
||||
})()
|
||||
const defaultValue =
|
||||
resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== ''
|
||||
? String(resolvedDefault)
|
||||
: ''
|
||||
|
||||
const id = typeof field?.id === 'string' ? field.id : undefined
|
||||
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
|
||||
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
options,
|
||||
optionsText,
|
||||
defaultValue,
|
||||
id,
|
||||
customFieldId,
|
||||
orderIndex,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pieces.map((piece) => ({
|
||||
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
|
||||
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
|
||||
reference: piece?.reference ?? '',
|
||||
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
|
||||
role: piece?.role ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
export const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
|
||||
if (!Array.isArray(products)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return products.map((product) => ({
|
||||
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
|
||||
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
|
||||
reference: product?.reference ?? '',
|
||||
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
|
||||
role: product?.role ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return components.map((component) => ({
|
||||
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
|
||||
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
|
||||
modelId: component?.modelId ?? '',
|
||||
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
|
||||
alias: component?.alias ?? component?.name ?? '',
|
||||
subcomponents: hydrateSubcomponents(
|
||||
Array.isArray(component?.subcomponents)
|
||||
? component.subcomponents
|
||||
: component?.subComponents,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
||||
const source = cloneStructure(input)
|
||||
|
||||
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
||||
const customFields = sanitizedCustomFields.map((field) => {
|
||||
const options = Array.isArray(field.options) ? [...field.options] : []
|
||||
const optionsText = options.length ? options.join('\n') : ''
|
||||
const defaultValue =
|
||||
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
|
||||
? String(field.defaultValue)
|
||||
: null
|
||||
const copy: ComponentModelCustomField = {
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options,
|
||||
defaultValue,
|
||||
optionsText,
|
||||
id: field.id,
|
||||
customFieldId: field.customFieldId,
|
||||
}
|
||||
return copy
|
||||
})
|
||||
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: customFields as ComponentModelCustomField[],
|
||||
pieces: sanitizePieces(source.pieces),
|
||||
products: sanitizeProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||
}
|
||||
|
||||
if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) {
|
||||
result.typeComposantId = source.typeComposantId
|
||||
}
|
||||
if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) {
|
||||
result.typeComposantLabel = source.typeComposantLabel
|
||||
}
|
||||
if (typeof source.modelId === 'string' && source.modelId.length > 0) {
|
||||
result.modelId = source.modelId
|
||||
}
|
||||
if (typeof source.familyCode === 'string' && source.familyCode.length > 0) {
|
||||
result.familyCode = source.familyCode
|
||||
}
|
||||
if (typeof source.alias === 'string' && source.alias.length > 0) {
|
||||
result.alias = source.alias
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const normalizeStructureForSave = (input: any): any => {
|
||||
const source = cloneStructure(input)
|
||||
|
||||
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
||||
const backendCustomFields = sanitizedCustomFields.map((field) => {
|
||||
const value: Record<string, any> = {
|
||||
type: field.type,
|
||||
required: !!field.required,
|
||||
}
|
||||
if (field.options && field.options.length) {
|
||||
value.options = field.options
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
|
||||
value.defaultValue = field.defaultValue
|
||||
}
|
||||
const payload: Record<string, any> = {
|
||||
key: field.name,
|
||||
value,
|
||||
}
|
||||
if (field.id) {
|
||||
payload.id = field.id
|
||||
}
|
||||
if (field.customFieldId) {
|
||||
payload.customFieldId = field.customFieldId
|
||||
}
|
||||
return payload
|
||||
}) as any
|
||||
|
||||
const backendPieces = sanitizePieces(source.pieces).map((piece) => {
|
||||
const payload: Record<string, any> = {}
|
||||
if ((piece as any).familyCode) {
|
||||
payload.familyCode = (piece as any).familyCode
|
||||
}
|
||||
if (piece.typePieceId) {
|
||||
payload.typePieceId = piece.typePieceId
|
||||
}
|
||||
if (piece.typePieceLabel) {
|
||||
payload.typePieceLabel = piece.typePieceLabel
|
||||
}
|
||||
if (piece.reference) {
|
||||
payload.reference = piece.reference
|
||||
}
|
||||
return payload
|
||||
}) as any
|
||||
|
||||
const backendProducts = sanitizeProducts(source.products).map((product) => {
|
||||
const payload: Record<string, any> = {}
|
||||
if ((product as any).familyCode) {
|
||||
payload.familyCode = (product as any).familyCode
|
||||
}
|
||||
if (product.typeProductId) {
|
||||
payload.typeProductId = product.typeProductId
|
||||
}
|
||||
if (product.role) {
|
||||
payload.role = product.role
|
||||
}
|
||||
return payload
|
||||
}) as any
|
||||
|
||||
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
|
||||
const payload: Record<string, any> = {}
|
||||
if (subcomponent.typeComposantId) {
|
||||
payload.typeComposantId = subcomponent.typeComposantId
|
||||
}
|
||||
if (subcomponent.modelId) {
|
||||
payload.modelId = subcomponent.modelId
|
||||
}
|
||||
if (subcomponent.familyCode) {
|
||||
payload.familyCode = subcomponent.familyCode
|
||||
}
|
||||
if (subcomponent.alias) {
|
||||
payload.alias = subcomponent.alias
|
||||
}
|
||||
if (Array.isArray(subcomponent.subcomponents) && subcomponent.subcomponents.length) {
|
||||
payload.subcomponents = subcomponent.subcomponents.map(mapSubcomponentForSave)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const backendSubcomponents = sanitizeSubcomponents(source.subcomponents).map(mapSubcomponentForSave) as any
|
||||
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: backendCustomFields,
|
||||
pieces: backendPieces,
|
||||
products: backendProducts,
|
||||
subcomponents: backendSubcomponents,
|
||||
}
|
||||
|
||||
if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) {
|
||||
(result as any).typeComposantId = source.typeComposantId
|
||||
}
|
||||
if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) {
|
||||
(result as any).typeComposantLabel = source.typeComposantLabel
|
||||
}
|
||||
if (typeof source.modelId === 'string' && source.modelId.length > 0) {
|
||||
(result as any).modelId = source.modelId
|
||||
}
|
||||
if (typeof source.familyCode === 'string' && source.familyCode.length > 0) {
|
||||
(result as any).familyCode = source.familyCode
|
||||
}
|
||||
if (typeof source.alias === 'string' && source.alias.length > 0) {
|
||||
(result as any).alias = source.alias
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const hydrateStructureForEditor = (input: any): ComponentModelStructure => {
|
||||
const source = cloneStructure(input)
|
||||
return {
|
||||
customFields: hydrateCustomFields(source.customFields),
|
||||
pieces: hydratePieces(source.pieces),
|
||||
products: hydrateProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(
|
||||
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
|
||||
),
|
||||
typeComposantId: source.typeComposantId ?? '',
|
||||
typeComposantLabel: source.typeComposantLabel ?? '',
|
||||
modelId: source.modelId ?? '',
|
||||
familyCode: source.familyCode ?? '',
|
||||
alias: source.alias ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const mapComponentCustomFields = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
}
|
||||
return hydrateCustomFields(fields).map((field, index) => {
|
||||
const defaultValue =
|
||||
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
|
||||
? field.defaultValue
|
||||
: null
|
||||
return {
|
||||
name: typeof field?.name === 'string' ? field.name : '',
|
||||
type: field?.type ?? 'text',
|
||||
required: !!field?.required,
|
||||
options: Array.isArray(field?.options) ? field.options : [],
|
||||
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
|
||||
defaultValue,
|
||||
id: typeof (field as any)?.id === 'string' ? (field as any).id : undefined,
|
||||
customFieldId:
|
||||
typeof (field as any)?.customFieldId === 'string'
|
||||
? (field as any).customFieldId
|
||||
: undefined,
|
||||
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return []
|
||||
}
|
||||
return pieces.map((piece) => ({
|
||||
reference: piece?.reference ?? '',
|
||||
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
|
||||
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
|
||||
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
|
||||
role: piece?.role ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
|
||||
if (!Array.isArray(products)) {
|
||||
return []
|
||||
}
|
||||
return products.map((product) => ({
|
||||
reference: product?.reference ?? '',
|
||||
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
|
||||
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
|
||||
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
|
||||
role: product?.role ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
}
|
||||
return components.map((component) => ({
|
||||
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
|
||||
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
|
||||
modelId: component?.modelId ?? '',
|
||||
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
|
||||
alias: component?.alias ?? component?.name ?? '',
|
||||
subcomponents: mapSubcomponents(
|
||||
Array.isArray(component?.subcomponents)
|
||||
? component.subcomponents
|
||||
: component?.subComponents,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export const extractStructureFromComponent = (component: any) => {
|
||||
if (!component) {
|
||||
return defaultStructure()
|
||||
}
|
||||
|
||||
const raw = {
|
||||
customFields: mapComponentCustomFields(component.customFields),
|
||||
pieces: mapComponentPieces(component.pieces),
|
||||
products: mapComponentProducts(component.products),
|
||||
subcomponents: mapSubcomponents(
|
||||
Array.isArray(component?.subcomponents)
|
||||
? component.subcomponents
|
||||
: component?.subComponents,
|
||||
),
|
||||
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
|
||||
typeComposantLabel: component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
|
||||
modelId: component?.modelId ?? '',
|
||||
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
|
||||
alias: component?.alias ?? component?.name ?? '',
|
||||
}
|
||||
|
||||
return normalizeStructureForEditor(raw)
|
||||
}
|
||||
|
||||
export const computeStructureStats = (structure: any): ModelStructurePreview => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
|
||||
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
|
||||
products: Array.isArray(structure.products) ? structure.products.length : 0,
|
||||
subcomponents: Array.isArray(structure.subcomponents)
|
||||
? structure.subcomponents.length
|
||||
: Array.isArray(structure.subComponents)
|
||||
? structure.subComponents.length
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatStructurePreview = (structure: any) => {
|
||||
const stats = computeStructureStats(structure)
|
||||
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
|
||||
return 'Structure vide'
|
||||
}
|
||||
|
||||
const segments: string[] = []
|
||||
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
|
||||
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
|
||||
if (stats.products) segments.push(`${stats.products} produit(s)`)
|
||||
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
|
||||
return segments.join(' • ')
|
||||
}
|
||||
49
app/shared/model/definitionOverrides.ts
Normal file
49
app/shared/model/definitionOverrides.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { uniqueConstructeurIds } from '../constructeurUtils'
|
||||
|
||||
export interface DefinitionOverridePayload {
|
||||
name?: string
|
||||
reference?: string
|
||||
constructeurIds?: string[]
|
||||
prix?: number
|
||||
}
|
||||
|
||||
export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverridePayload | null => {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: DefinitionOverridePayload = {}
|
||||
|
||||
if (typeof definition.name === 'string') {
|
||||
const name = definition.name.trim()
|
||||
if (name.length > 0) {
|
||||
payload.name = name
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof definition.reference === 'string') {
|
||||
const reference = definition.reference.trim()
|
||||
if (reference.length > 0) {
|
||||
payload.reference = reference
|
||||
}
|
||||
}
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
definition.constructeurIds,
|
||||
definition.constructeurId,
|
||||
definition.constructeur,
|
||||
definition.constructeurs,
|
||||
)
|
||||
if (constructeurIds.length) {
|
||||
payload.constructeurIds = constructeurIds
|
||||
}
|
||||
|
||||
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
|
||||
const parsed = Number(definition.prix)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(payload).length ? payload : null
|
||||
}
|
||||
177
app/shared/model/pieceProductStructure.ts
Normal file
177
app/shared/model/pieceProductStructure.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
createEmptyPieceModelStructure,
|
||||
createEmptyProductModelStructure,
|
||||
type PieceModelCustomField,
|
||||
type PieceModelProduct,
|
||||
type PieceModelStructure,
|
||||
type PieceModelStructureEditorField,
|
||||
type PieceModelStructureForEditor,
|
||||
type ProductModelStructure,
|
||||
} from '../types/inventory'
|
||||
import { isPlainObject, sanitizeProducts, hydrateProducts } from './componentStructure'
|
||||
|
||||
export const defaultPieceStructure = (): PieceModelStructure => ({
|
||||
...createEmptyPieceModelStructure(),
|
||||
})
|
||||
|
||||
export const defaultProductStructure = (): ProductModelStructure => ({
|
||||
...createEmptyProductModelStructure(),
|
||||
})
|
||||
|
||||
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
||||
const base = createEmptyPieceModelStructure()
|
||||
if (!isPlainObject(input)) {
|
||||
return base
|
||||
}
|
||||
|
||||
const clone: PieceModelStructure = {
|
||||
...base,
|
||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (key === 'customFields' || key === 'products') {
|
||||
continue
|
||||
}
|
||||
clone[key] = value
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
export const clonePieceStructure = (input: any): PieceModelStructure => {
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(input ?? defaultPieceStructure()))
|
||||
return ensurePieceStructureShape(cloned)
|
||||
} catch (_error) {
|
||||
return defaultPieceStructure()
|
||||
}
|
||||
}
|
||||
|
||||
export const cloneProductStructure = (input: any): ProductModelStructure => {
|
||||
return clonePieceStructure(input)
|
||||
}
|
||||
|
||||
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields
|
||||
.map((field, index) => {
|
||||
const name = typeof field?.name === 'string' ? field.name.trim() : ''
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = typeof field?.type === 'string' && field.type ? field.type : 'text'
|
||||
const required = !!field?.required
|
||||
|
||||
let options: string[] | undefined
|
||||
if (type === 'select') {
|
||||
const rawOptions = typeof field?.optionsText === 'string'
|
||||
? field.optionsText
|
||||
: Array.isArray(field?.options)
|
||||
? field.options.join('\n')
|
||||
: ''
|
||||
const parsed = rawOptions
|
||||
.split(/\r?\n/)
|
||||
.map((option: string) => option.trim())
|
||||
.filter((option: string) => option.length > 0)
|
||||
options = parsed.length > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const result: PieceModelCustomField = { name, type, required }
|
||||
if (options) {
|
||||
result.options = options
|
||||
}
|
||||
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||
result.orderIndex = orderIndex
|
||||
return result
|
||||
})
|
||||
.filter((field): field is PieceModelCustomField => !!field)
|
||||
}
|
||||
|
||||
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
|
||||
return sanitizeProducts(products) as PieceModelProduct[]
|
||||
}
|
||||
|
||||
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
|
||||
const source = clonePieceStructure(input)
|
||||
const restEntries = Object.entries(source).filter(
|
||||
([key]) => key !== 'customFields' && key !== 'products',
|
||||
)
|
||||
return {
|
||||
...Object.fromEntries(restEntries),
|
||||
products: sanitizePieceProducts(source.products || []),
|
||||
customFields: sanitizePieceCustomFields(source.customFields),
|
||||
}
|
||||
}
|
||||
|
||||
const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField[] => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields.map((field, index) => ({
|
||||
name: field?.name ?? '',
|
||||
type: field?.type ?? 'text',
|
||||
required: !!field?.required,
|
||||
options: Array.isArray(field?.options) ? field.options : undefined,
|
||||
optionsText: typeof field?.optionsText === 'string'
|
||||
? field.optionsText
|
||||
: Array.isArray(field?.options)
|
||||
? field.options.join('\n')
|
||||
: '',
|
||||
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||
}))
|
||||
}
|
||||
|
||||
export const hydratePieceStructureForEditor = (input: any): PieceModelStructureForEditor => {
|
||||
const source = clonePieceStructure(input)
|
||||
const payload: PieceModelStructureForEditor = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
|
||||
),
|
||||
products: hydrateProducts(source.products || []) as PieceModelProduct[],
|
||||
customFields: hydratePieceCustomFields(source.customFields),
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export const formatPieceStructurePreview = (structure: any) => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return 'Aucun champ personnalisé'
|
||||
}
|
||||
|
||||
const customFields = Array.isArray((structure as any).customFields)
|
||||
? (structure as any).customFields.length
|
||||
: 0
|
||||
const products = Array.isArray((structure as any).products)
|
||||
? (structure as any).products.length
|
||||
: 0
|
||||
|
||||
if (!customFields && !products) {
|
||||
return 'Aucun produit ni champ personnalisé'
|
||||
}
|
||||
|
||||
const segments: string[] = []
|
||||
if (products) {
|
||||
segments.push(`${products} produit(s)`)
|
||||
}
|
||||
if (customFields) {
|
||||
segments.push(`${customFields} champ(s) personnalisé(s)`)
|
||||
}
|
||||
|
||||
return segments.join(' · ')
|
||||
}
|
||||
|
||||
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
|
||||
normalizePieceStructureForSave(input)
|
||||
|
||||
export const hydrateProductStructureForEditor = (input: any) =>
|
||||
hydratePieceStructureForEditor(input)
|
||||
|
||||
export const formatProductStructurePreview = (structure: any) =>
|
||||
formatPieceStructurePreview(structure)
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user