Compare commits
50 Commits
2f3d4c5260
...
2fffe4a368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f | ||
|
|
79d2df8bc6 | ||
|
|
23da4ba4c7 | ||
|
|
635b8f0461 | ||
|
|
bf74a50f57 | ||
|
|
7c44778f25 | ||
|
|
9f7dd12b34 | ||
|
|
67af3c9c46 | ||
|
|
634184c2be | ||
|
|
6152848957 | ||
|
|
046f464378 | ||
|
|
8700c253cd | ||
|
|
519fa3a8f4 | ||
|
|
e1594cab76 | ||
|
|
daaa1c4cb9 | ||
|
|
786b1d91f6 | ||
|
|
3436cd0b90 | ||
|
|
efe1fd2a73 | ||
|
|
a6664ce9a2 | ||
|
|
399ec1f7b4 | ||
|
|
86bb8af32d | ||
|
|
78718b85ae | ||
|
|
9ee348fff0 | ||
|
|
1fbd1d1b2e | ||
|
|
1f2d6c78e8 | ||
|
|
649f8ca9cc | ||
| 3705b8daed | |||
|
|
202b964b24 | ||
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
94239031d6 | ||
|
|
b27662d2bc | ||
|
|
55739fe50f | ||
|
|
1f5f1509a9 | ||
|
|
a8cb4d1ac0 | ||
| 8af8374282 | |||
| 9cc7ac10f0 | |||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 |
859
app/app.vue
859
app/app.vue
@@ -1,859 +1,52 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
Paramètres d'affichage
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-mobile')"
|
||||
:aria-expanded="openDropdown === 'pieces-mobile'"
|
||||
>
|
||||
<span>Pièces</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('products-mobile')"
|
||||
:aria-expanded="openDropdown === 'products-mobile'"
|
||||
>
|
||||
<span>Produits</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') || isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('component-mobile')"
|
||||
:aria-expanded="openDropdown === 'component-mobile'"
|
||||
>
|
||||
<span>Composant</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-mobile')"
|
||||
:aria-expanded="openDropdown === 'resources-mobile'"
|
||||
>
|
||||
<span>Ressources liées</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar placeholder">
|
||||
<div
|
||||
class="bg-primary text-primary-content rounded-lg w-10 grid place-items-center"
|
||||
>
|
||||
<IconLucideBoxes class="w-6 h-6" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventaire Pro
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('pieces-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('pieces-desktop')"
|
||||
@focusin="setDropdown('pieces-desktop')"
|
||||
@focusout="scheduleDropdownClose('pieces-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
|
||||
:aria-expanded="openDropdown === 'pieces-desktop'"
|
||||
>
|
||||
Pièces
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('products-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('products-desktop')"
|
||||
@focusin="setDropdown('products-desktop')"
|
||||
@focusout="scheduleDropdownClose('products-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('products-desktop')"
|
||||
:aria-expanded="openDropdown === 'products-desktop'"
|
||||
>
|
||||
Produits
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('component-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('component-desktop')"
|
||||
@focusin="setDropdown('component-desktop')"
|
||||
@focusout="scheduleDropdownClose('component-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') ||
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('component-desktop')"
|
||||
:aria-expanded="openDropdown === 'component-desktop'"
|
||||
>
|
||||
Composant
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('resources-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('resources-desktop')"
|
||||
@focusin="setDropdown('resources-desktop')"
|
||||
@focusout="scheduleDropdownClose('resources-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-desktop')"
|
||||
:aria-expanded="openDropdown === 'resources-desktop'"
|
||||
>
|
||||
Ressources liées
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Bouton paramètres d'affichage -->
|
||||
<button
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<LayoutAppNavbar
|
||||
@open-settings="displaySettingsOpen = true"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="activeProfile" class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
>
|
||||
<div
|
||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||
>
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
||||
>
|
||||
{{ activeProfileInitials }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
|
||||
>
|
||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||
Connecté en tant que<br />
|
||||
<span class="font-semibold text-base-content">{{
|
||||
activeProfileLabel
|
||||
}}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
||||
Gestion des profils
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="text-error justify-between"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<NuxtPage />
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Paramètres d'affichage -->
|
||||
<CommonConfirmModal />
|
||||
|
||||
<DisplaySettings
|
||||
:is-open="displaySettingsOpen"
|
||||
@close="closeDisplaySettings"
|
||||
@close="displaySettingsOpen = false"
|
||||
@update-settings="handleSettingsUpdate"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||
<div class="items-center grid-flow-col">
|
||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
||||
<p>
|
||||
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, navigateTo, useRuntimeConfig } from "#imports";
|
||||
import { useProfileSession } from "~/composables/useProfileSession";
|
||||
import IconLucideMenu from "~icons/lucide/menu";
|
||||
import IconLucideSettings from "~icons/lucide/settings";
|
||||
import IconLucideBoxes from "~icons/lucide/boxes";
|
||||
import IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucideCpu from "~icons/lucide/cpu";
|
||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { navigateTo, useRuntimeConfig } from '#imports'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
|
||||
// État du modal des paramètres d'affichage
|
||||
const displaySettingsOpen = ref(false);
|
||||
const { activeProfile, ensureSession, logout } = useProfileSession();
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const appVersion = computed(() => runtimeConfig.public?.appVersion ?? "0.1.0");
|
||||
const displaySettingsOpen = ref(false)
|
||||
const { ensureSession, logout } = useProfileSession()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
|
||||
|
||||
// Route active pour souligner l'onglet sélectionné dans la navbar
|
||||
const route = useRoute();
|
||||
const isActive = (path) => {
|
||||
if (path === "/") {
|
||||
return route.path === "/";
|
||||
}
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
|
||||
// Ouvrir les paramètres d'affichage
|
||||
const openDisplaySettings = () => {
|
||||
displaySettingsOpen.value = true;
|
||||
};
|
||||
|
||||
// Fermer les paramètres d'affichage
|
||||
const closeDisplaySettings = () => {
|
||||
displaySettingsOpen.value = false;
|
||||
};
|
||||
|
||||
// Gérer les mises à jour des paramètres
|
||||
const handleSettingsUpdate = (settings) => {
|
||||
console.log("Paramètres d'affichage mis à jour:", settings);
|
||||
};
|
||||
const handleSettingsUpdate = (_settings: unknown) => {
|
||||
// Placeholder for future persistence
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
await navigateTo("/profiles");
|
||||
};
|
||||
|
||||
const openDropdown = ref(null);
|
||||
let dropdownCloseTimer = null;
|
||||
|
||||
const setDropdown = (name) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
if (openDropdown.value !== name) {
|
||||
openDropdown.value = name;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDropdownClose = (name) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
}
|
||||
dropdownCloseTimer = setTimeout(() => {
|
||||
if (openDropdown.value === name) {
|
||||
openDropdown.value = null;
|
||||
}
|
||||
dropdownCloseTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const closeDropdownNow = () => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
openDropdown.value = null;
|
||||
};
|
||||
|
||||
const toggleDropdown = (name) => {
|
||||
if (openDropdown.value === name) {
|
||||
closeDropdownNow();
|
||||
return;
|
||||
}
|
||||
setDropdown(name);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeDropdownNow();
|
||||
},
|
||||
);
|
||||
|
||||
const activeProfileLabel = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return "Profil inconnu";
|
||||
}
|
||||
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`;
|
||||
});
|
||||
|
||||
const activeProfileInitials = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return "??";
|
||||
}
|
||||
const { firstName = "", lastName = "" } = activeProfile.value;
|
||||
return (
|
||||
`${firstName.charAt(0) || ""}${lastName.charAt(0) || ""}`.toUpperCase() ||
|
||||
"??"
|
||||
);
|
||||
});
|
||||
await logout()
|
||||
await navigateTo('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSession();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
});
|
||||
await ensureSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-dropdown-desktop-enter-active,
|
||||
.nav-dropdown-desktop-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.nav-dropdown-desktop-enter-from,
|
||||
.nav-dropdown-desktop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
.nav-dropdown-desktop-enter-to,
|
||||
.nav-dropdown-desktop-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.nav-dropdown-mobile-enter-active,
|
||||
.nav-dropdown-mobile-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-from,
|
||||
.nav-dropdown-mobile-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-to,
|
||||
.nav-dropdown-mobile-leave-from {
|
||||
max-height: 12rem;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -6,26 +6,31 @@
|
||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||
color-scheme: light; /* color of browser-provided UI */
|
||||
|
||||
--color-base-100: oklch(98% 0.02 240);
|
||||
--color-base-200: oklch(95% 0.03 240);
|
||||
--color-base-300: oklch(92% 0.04 240);
|
||||
--color-base-content: oklch(20% 0.05 240);
|
||||
--color-primary: oklch(55% 0.3 240);
|
||||
--color-primary-content: oklch(98% 0.01 240);
|
||||
--color-secondary: oklch(70% 0.25 200);
|
||||
--color-secondary-content: oklch(98% 0.01 200);
|
||||
--color-accent: oklch(65% 0.25 160);
|
||||
--color-accent-content: oklch(98% 0.01 160);
|
||||
--color-neutral: oklch(50% 0.05 240);
|
||||
--color-neutral-content: oklch(98% 0.01 240);
|
||||
--color-info: oklch(70% 0.2 220);
|
||||
--color-info-content: oklch(98% 0.01 220);
|
||||
--color-success: oklch(65% 0.25 140);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
--color-warning: oklch(80% 0.25 80);
|
||||
--color-warning-content: oklch(20% 0.05 80);
|
||||
--color-error: oklch(65% 0.3 30);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
/* #FBFAFA — gris clair */
|
||||
--color-base-100: oklch(98% 0.003 0);
|
||||
--color-base-200: oklch(94% 0.01 262);
|
||||
--color-base-300: oklch(90% 0.02 262);
|
||||
--color-base-content: oklch(20% 0.03 262);
|
||||
/* #304998 — bleu Malio */
|
||||
--color-primary: oklch(37% 0.15 262);
|
||||
--color-primary-content: oklch(98% 0.005 262);
|
||||
/* #A5ACD0 — lavande */
|
||||
--color-secondary: oklch(75% 0.055 270);
|
||||
--color-secondary-content: oklch(20% 0.03 270);
|
||||
/* #ED8521 — orange */
|
||||
--color-accent: oklch(71% 0.17 58);
|
||||
--color-accent-content: oklch(98% 0.005 58);
|
||||
/* neutral dérivé du bleu Malio */
|
||||
--color-neutral: oklch(37% 0.08 262);
|
||||
--color-neutral-content: oklch(98% 0.005 262);
|
||||
--color-info: oklch(55% 0.12 262);
|
||||
--color-info-content: oklch(98% 0.005 262);
|
||||
--color-success: oklch(65% 0.2 145);
|
||||
--color-success-content: oklch(98% 0.005 145);
|
||||
--color-warning: oklch(78% 0.15 70);
|
||||
--color-warning-content: oklch(20% 0.05 70);
|
||||
--color-error: oklch(60% 0.25 25);
|
||||
--color-error-content: oklch(98% 0.005 25);
|
||||
|
||||
/* border radius */
|
||||
--radius-selector: 1rem;
|
||||
@@ -114,7 +119,7 @@
|
||||
|
||||
/* Focus visible pour l'accessibilité */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline: 2px solid #304998;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
||||
|
||||
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@@ -437,200 +437,98 @@ import { ref, watch, computed } from 'vue'
|
||||
import PieceItem from './PieceItem.vue'
|
||||
import DocumentUpload from './DocumentUpload.vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
collapseAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
toggleToken: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
component: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'edit-piece',
|
||||
'custom-field-update'
|
||||
])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
documents: componentDocuments,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
} = useEntityProductDisplay({ entity: () => props.component })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
const selectedFiles = ref([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length))
|
||||
const componentDocuments = computed(() => props.component.documents || [])
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
const shouldInlinePdf = (document) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const appendPdfViewerParams = (src) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src || ''
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
const documentPreviewSrc = (document) => {
|
||||
if (!document?.path) {
|
||||
return ''
|
||||
}
|
||||
if (isPdfDocument(document)) {
|
||||
return appendPdfViewerParams(document.path)
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
const documentThumbnailClass = (document) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
}
|
||||
return 'h-16 w-16'
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
}
|
||||
|
||||
// --- Child components ---
|
||||
const childComponents = computed(() => {
|
||||
const list = props.component.subcomponents || props.component.subComponents || []
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const buildProductDisplay = (product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? product.constructeurs
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name) => typeof name === 'string' && name.trim().length > 0)
|
||||
.join(', ')
|
||||
: product.supplierLabel || null
|
||||
|
||||
const priceValue =
|
||||
product.supplierPrice ??
|
||||
product.price ??
|
||||
product.priceLabel ??
|
||||
product.priceDisplay ??
|
||||
null
|
||||
|
||||
let price = null
|
||||
if (priceValue !== null && priceValue !== undefined) {
|
||||
const parsed = Number(priceValue)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = currencyFormatter.format(parsed)
|
||||
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
|
||||
price = priceValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name:
|
||||
product.name ||
|
||||
product.label ||
|
||||
product.reference ||
|
||||
product.productName ||
|
||||
null,
|
||||
reference: product.reference || null,
|
||||
category: product.typeProduct?.name || product.category || null,
|
||||
suppliers,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
const displayProduct = computed(() => {
|
||||
const explicit = props.component.product || null
|
||||
const normalized = buildProductDisplay(explicit)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
const fallback = props.component.__productDisplay
|
||||
if (fallback) {
|
||||
return {
|
||||
name: fallback.name || null,
|
||||
reference: fallback.reference || null,
|
||||
category: fallback.category || null,
|
||||
suppliers: fallback.suppliers || null,
|
||||
price: fallback.price || null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (displayProduct.value?.name) {
|
||||
return displayProduct.value.name
|
||||
}
|
||||
return (
|
||||
props.component.product?.name ||
|
||||
props.component.productName ||
|
||||
props.component.productLabel ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const displayProductCategory = computed(() => displayProduct.value?.category || null)
|
||||
const displayProductReference = computed(() => displayProduct.value?.reference || null)
|
||||
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
|
||||
const displayProductPrice = computed(() => displayProduct.value?.price || null)
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return []
|
||||
}
|
||||
const rows = []
|
||||
if (displayProductReference.value) {
|
||||
rows.push({ label: 'Référence', value: displayProductReference.value })
|
||||
}
|
||||
if (displayProductPrice.value) {
|
||||
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
|
||||
}
|
||||
if (displayProductSuppliers.value) {
|
||||
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
|
||||
}
|
||||
if (displayProductCategory.value) {
|
||||
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product = props.component.product
|
||||
return Array.isArray(product?.documents) ? product.documents : []
|
||||
})
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.component,
|
||||
@@ -651,332 +549,8 @@ const componentConstructeursDisplay = computed(() =>
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
const extractStructureCustomFields = (structure) => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const customFields = structure.customFields
|
||||
return Array.isArray(customFields) ? customFields : []
|
||||
}
|
||||
|
||||
function fieldKeyFromNameAndType(name, type) {
|
||||
const normalizedName =
|
||||
typeof name === 'string' ? name.trim().toLowerCase() : ''
|
||||
const normalizedType =
|
||||
typeof type === 'string' ? type.trim().toLowerCase() : ''
|
||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
||||
}
|
||||
|
||||
function resolveOrderIndex(field) {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return 0
|
||||
}
|
||||
if (typeof field.orderIndex === 'number') {
|
||||
return field.orderIndex
|
||||
}
|
||||
if (
|
||||
field.customField &&
|
||||
typeof field.customField.orderIndex === 'number'
|
||||
) {
|
||||
return field.customField.orderIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function deduplicateFieldDefinitions(definitions) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
|
||||
const orderedDefinitions = (Array.isArray(definitions)
|
||||
? definitions.slice()
|
||||
: []
|
||||
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedDefinitions.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const id =
|
||||
field.id ??
|
||||
field.customFieldId ??
|
||||
field.customField?.id ??
|
||||
null
|
||||
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
|
||||
if (!id && !nameKey) {
|
||||
return
|
||||
}
|
||||
const key = id || nameKey
|
||||
if (key && seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (key) {
|
||||
seen.add(key)
|
||||
}
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function mergeFieldDefinitionsWithValues(definitions, values) {
|
||||
const definitionList = Array.isArray(definitions) ? definitions : []
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const valueMap = new Map()
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
if (fieldId) {
|
||||
valueMap.set(fieldId, entry)
|
||||
}
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
if (nameKey) {
|
||||
valueMap.set(nameKey, entry)
|
||||
}
|
||||
})
|
||||
|
||||
const merged = definitionList.map((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return field
|
||||
}
|
||||
|
||||
const fieldId = ensureCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
||||
|
||||
const matchedValue =
|
||||
(fieldId ? valueMap.get(fieldId) : undefined) ??
|
||||
(nameKey ? valueMap.get(nameKey) : undefined)
|
||||
|
||||
if (!matchedValue) {
|
||||
return {
|
||||
...field,
|
||||
value: field?.value ?? '',
|
||||
orderIndex: resolveOrderIndex(field),
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedOrder = Math.min(
|
||||
resolveOrderIndex(field),
|
||||
resolveOrderIndex(matchedValue.customField),
|
||||
)
|
||||
|
||||
return {
|
||||
...field,
|
||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||
customFieldId:
|
||||
matchedValue.customField?.id ??
|
||||
matchedValue.customFieldId ??
|
||||
fieldId ??
|
||||
null,
|
||||
customField: matchedValue.customField ?? field.customField ?? null,
|
||||
value: matchedValue.value ?? field.value ?? '',
|
||||
orderIndex: resolvedOrder,
|
||||
}
|
||||
})
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
|
||||
const exists = merged.some((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
|
||||
return true
|
||||
}
|
||||
const existingId = ensureCustomFieldId(field)
|
||||
if (fieldId && existingId && existingId === fieldId) {
|
||||
return true
|
||||
}
|
||||
if (!fieldId && nameKey) {
|
||||
return (
|
||||
fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
merged.push({
|
||||
customFieldValueId: entry.id ?? null,
|
||||
customFieldId: fieldId,
|
||||
name: entry.customField?.name ?? '',
|
||||
type: entry.customField?.type ?? 'text',
|
||||
required: entry.customField?.required ?? false,
|
||||
options: entry.customField?.options ?? [],
|
||||
value: entry.value ?? '',
|
||||
customField: entry.customField ?? null,
|
||||
orderIndex: resolveOrderIndex(entry.customField),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
function dedupeMergedFields(fields) {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields)
|
||||
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
: []
|
||||
}
|
||||
|
||||
const seen = new Map()
|
||||
const result = []
|
||||
|
||||
const orderedFields = fields
|
||||
.slice()
|
||||
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedFields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const rawName = resolveFieldName(field)
|
||||
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
if (!normalizedName) {
|
||||
return
|
||||
}
|
||||
field.name = normalizedName
|
||||
field.type = resolveFieldType(field)
|
||||
|
||||
const fieldId = ensureCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
|
||||
const key = fieldId || nameKey
|
||||
|
||||
if (!key) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = seen.get(key)
|
||||
if (!existing) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
seen.set(key, field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existingHasValue =
|
||||
existing.value !== undefined &&
|
||||
existing.value !== null &&
|
||||
String(existing.value).trim().length > 0
|
||||
|
||||
const incomingHasValue =
|
||||
field.value !== undefined &&
|
||||
field.value !== null &&
|
||||
String(field.value).trim().length > 0
|
||||
|
||||
if (!existingHasValue && incomingHasValue) {
|
||||
Object.assign(existing, field)
|
||||
existing.orderIndex = Math.min(
|
||||
resolveOrderIndex(existing),
|
||||
resolveOrderIndex(field),
|
||||
)
|
||||
seen.set(key, existing)
|
||||
}
|
||||
})
|
||||
|
||||
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
const componentDefinitionSources = computed(() => {
|
||||
const requirement = props.component.typeMachineComponentRequirement || {}
|
||||
const type = requirement.typeComposant || props.component.typeComposant || {}
|
||||
|
||||
const definitions = []
|
||||
const pushFields = (collection) => {
|
||||
if (Array.isArray(collection)) {
|
||||
definitions.push(...collection)
|
||||
}
|
||||
}
|
||||
|
||||
pushFields(props.component.customFields)
|
||||
pushFields(props.component.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
pushFields(requirement.customFields)
|
||||
pushFields(requirement.definition?.customFields)
|
||||
|
||||
;[
|
||||
props.component.definition?.structure,
|
||||
type.structure,
|
||||
type.componentSkeleton,
|
||||
requirement.structure,
|
||||
requirement.componentSkeleton,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) {
|
||||
definitions.push(...fields)
|
||||
}
|
||||
})
|
||||
|
||||
return deduplicateFieldDefinitions(definitions)
|
||||
})
|
||||
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
componentDefinitionSources.value,
|
||||
props.component.customFieldValues,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() => {
|
||||
const map = new Map()
|
||||
const register = (collection) => {
|
||||
if (!Array.isArray(collection)) {
|
||||
return
|
||||
}
|
||||
collection.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return
|
||||
}
|
||||
const id = item.id || item.customFieldId
|
||||
const name = typeof item.name === 'string' ? item.name : null
|
||||
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
||||
if (!key || map.has(key)) {
|
||||
return
|
||||
}
|
||||
map.set(key, item)
|
||||
})
|
||||
}
|
||||
|
||||
register(props.component.customFieldValues?.map((value) => value?.customField))
|
||||
register(componentDefinitionSources.value)
|
||||
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const handleConstructeurChange = async (value) => {
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
|
||||
props.component.constructeurIds = [...ids]
|
||||
props.component.constructeurId = null
|
||||
props.component.constructeur = null
|
||||
@@ -985,42 +559,10 @@ const handleConstructeurChange = async (value) => {
|
||||
constructeurs.value,
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
)
|
||||
|
||||
await updateComponent()
|
||||
}
|
||||
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
||||
const {
|
||||
updateCustomFieldValue: updateComponentCustomFieldValueApi,
|
||||
upsertCustomFieldValue: upsertComponentCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) {
|
||||
ensureDocumentsLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.component.documents,
|
||||
(docs) => {
|
||||
documentsLoaded.value = !!(docs && docs.length)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) {
|
||||
ensureDocumentsLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update / Event forwarding ---
|
||||
const updateComponent = () => {
|
||||
emit('update', {
|
||||
...props.component,
|
||||
@@ -1028,216 +570,6 @@ const updateComponent = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function resolveFieldKey(field, index) {
|
||||
return field?.id
|
||||
?? field?.customFieldValueId
|
||||
?? field?.customFieldId
|
||||
?? field?.name
|
||||
?? `field-${index}`
|
||||
}
|
||||
|
||||
function resolveFieldId(field) {
|
||||
return field?.customFieldValueId ?? null
|
||||
}
|
||||
|
||||
function resolveFieldName(field) {
|
||||
return field?.name ?? 'Champ'
|
||||
}
|
||||
|
||||
function resolveFieldType(field) {
|
||||
return field?.type ?? 'text'
|
||||
}
|
||||
|
||||
function resolveFieldOptions(field) {
|
||||
return field?.options ?? []
|
||||
}
|
||||
|
||||
function resolveFieldRequired(field) {
|
||||
return !!field?.required
|
||||
}
|
||||
|
||||
function resolveFieldReadOnly(field) {
|
||||
return !!field?.readOnly
|
||||
}
|
||||
|
||||
function buildCustomFieldMetadata(field) {
|
||||
return {
|
||||
customFieldName: resolveFieldName(field),
|
||||
customFieldType: resolveFieldType(field),
|
||||
customFieldRequired: resolveFieldRequired(field),
|
||||
customFieldOptions: resolveFieldOptions(field)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCustomFieldId(field) {
|
||||
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
||||
}
|
||||
|
||||
function ensureCustomFieldId(field) {
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (existingId) {
|
||||
return existingId
|
||||
}
|
||||
|
||||
const name = resolveFieldName(field)
|
||||
if (!name || name === 'Champ') {
|
||||
return null
|
||||
}
|
||||
|
||||
const matches = candidateCustomFields.value.filter((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidateId = candidate.id || candidate.customFieldId
|
||||
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
|
||||
return true
|
||||
}
|
||||
return typeof candidate.name === 'string' && candidate.name === name
|
||||
})
|
||||
|
||||
if (matches.length) {
|
||||
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
|
||||
const id = withId?.id || withId?.customFieldId || null
|
||||
if (id) {
|
||||
field.customFieldId = id
|
||||
}
|
||||
if (!field.customField && typeof withId === 'object') {
|
||||
field.customField = withId
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
const formatFieldDisplayValue = (field) => {
|
||||
const type = resolveFieldType(field)
|
||||
const rawValue = field?.value ?? ''
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true') return 'Oui'
|
||||
if (normalized === 'false') return 'Non'
|
||||
}
|
||||
return rawValue || 'Non défini'
|
||||
}
|
||||
|
||||
const updateComponentCustomField = async (field) => {
|
||||
if (!field || resolveFieldReadOnly(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (fieldValueId) {
|
||||
const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
|
||||
if (existingValue?.customField?.id) {
|
||||
field.customFieldId = existingValue.customField.id
|
||||
field.customField = existingValue.customField
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const customFieldId = ensureCustomFieldId(field)
|
||||
const fieldName = resolveFieldName(field)
|
||||
if (!props.component?.id) {
|
||||
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||
return
|
||||
}
|
||||
|
||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||
|
||||
const result = await upsertComponentCustomFieldValue(
|
||||
customFieldId,
|
||||
'composant',
|
||||
props.component.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
field.customField = newValue.customField
|
||||
}
|
||||
|
||||
if (Array.isArray(props.component.customFieldValues)) {
|
||||
const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
|
||||
if (index !== -1) {
|
||||
props.component.customFieldValues.splice(index, 1, newValue)
|
||||
} else {
|
||||
props.component.customFieldValues.push(newValue)
|
||||
}
|
||||
} else {
|
||||
props.component.customFieldValues = [newValue]
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||
|
||||
const definitions = Array.isArray(props.component.customFields)
|
||||
? [...props.component.customFields]
|
||||
: []
|
||||
const fieldIdentifier = ensureCustomFieldId(field)
|
||||
const existingIndex = definitions.findIndex((definition) => {
|
||||
const definitionId = ensureCustomFieldId(definition)
|
||||
if (fieldIdentifier && definitionId) {
|
||||
return definitionId === fieldIdentifier
|
||||
}
|
||||
return definition?.name === resolveFieldName(field)
|
||||
})
|
||||
|
||||
const updatedDefinition = {
|
||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||
customFieldValueId: field.customFieldValueId,
|
||||
customFieldId: fieldIdentifier,
|
||||
name: resolveFieldName(field),
|
||||
type: resolveFieldType(field),
|
||||
required: resolveFieldRequired(field),
|
||||
options: resolveFieldOptions(field),
|
||||
value: field.value ?? '',
|
||||
customField: field.customField ?? null,
|
||||
}
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||
} else {
|
||||
definitions.push(updatedDefinition)
|
||||
}
|
||||
|
||||
props.component.customFields = definitions
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePiece = (updatedPiece) => {
|
||||
emit('edit-piece', updatedPiece)
|
||||
}
|
||||
@@ -1250,87 +582,4 @@ const updatePieceCustomField = (fieldUpdate) => {
|
||||
emit('custom-field-update', fieldUpdate)
|
||||
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !props.component?.id) { return }
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(props.component.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
props.component.documents = result.data || []
|
||||
documentsLoaded.value = true
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files) => {
|
||||
if (!files.length || !props.component?.id) { return }
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { composantId: props.component.id }
|
||||
},
|
||||
{ updateStore: false }
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newDocs = result.data || []
|
||||
props.component.documents = [...newDocs, ...(props.component.documents || [])]
|
||||
documentsLoaded.value = true
|
||||
selectedFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId) => {
|
||||
if (!documentId) { return }
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadDocument = (doc) => {
|
||||
if (!doc?.path) { return }
|
||||
|
||||
if (doc.path.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = doc.path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
if (size === 0) { return '0 B' }
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:locked-type-label="displayedRootTypeLabel"
|
||||
:allow-subcomponents="allowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
is-root
|
||||
/>
|
||||
</div>
|
||||
@@ -55,6 +56,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: Infinity,
|
||||
},
|
||||
restrictedMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -110,7 +115,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
if (Array.isArray(node.customFields)) {
|
||||
node.customFields = node.customFields.map((field: any) => {
|
||||
node.customFields = node.customFields.map((field: Record<string, any>) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return field
|
||||
}
|
||||
@@ -140,7 +145,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
if (Array.isArray(node.subcomponents)) {
|
||||
node.subcomponents = node.subcomponents.map((sub: any) => {
|
||||
node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
|
||||
if (!sub || typeof sub !== 'object') {
|
||||
return sub
|
||||
}
|
||||
@@ -241,7 +246,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const loaders: Promise<any>[] = []
|
||||
const loaders: Promise<unknown>[] = []
|
||||
if (!availablePieceTypes.value.length) {
|
||||
loaders.push(loadPieceTypes())
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
<SearchSelect
|
||||
:model-value="assignment.selectedComponentId || ''"
|
||||
:options="componentOptions"
|
||||
:loading="componentsLoading"
|
||||
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant..."
|
||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||
:option-label="componentOptionLabel"
|
||||
:option-description="componentOptionDescription"
|
||||
@search="fetchComponentOptions"
|
||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,22 +46,23 @@
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describePieceRequirement(pieceAssignment.definition) }}
|
||||
{{ describePieceRequirement(pieceAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error">
|
||||
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="pieceAssignment.selectedPieceId || ''"
|
||||
:options="getPieceOptions(pieceAssignment.definition)"
|
||||
:loading="piecesLoading"
|
||||
:options="getPieceOptions(pieceAssignment)"
|
||||
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher une pièce..."
|
||||
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,22 +85,23 @@
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describeProductRequirement(productAssignment.definition) }}
|
||||
{{ describeProductRequirement(productAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
|
||||
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
|
||||
Aucun produit disponible pour cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="productAssignment.selectedProductId || ''"
|
||||
:options="getProductOptions(productAssignment.definition)"
|
||||
:loading="productsLoading"
|
||||
:options="getProductOptions(productAssignment)"
|
||||
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher un produit..."
|
||||
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:option-label="productOptionLabel"
|
||||
:option-description="productOptionDescription"
|
||||
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -131,8 +134,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
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,
|
||||
@@ -206,6 +211,9 @@ const props = withDefaults(
|
||||
componentsLoading?: boolean;
|
||||
piecesLoading?: boolean;
|
||||
productsLoading?: boolean;
|
||||
pieceTypeLabelMap?: Record<string, string>;
|
||||
productTypeLabelMap?: Record<string, string>;
|
||||
componentTypeLabelMap?: Record<string, string>;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
@@ -215,6 +223,9 @@ const props = withDefaults(
|
||||
componentsLoading: false,
|
||||
piecesLoading: false,
|
||||
productsLoading: false,
|
||||
pieceTypeLabelMap: () => ({}),
|
||||
productTypeLabelMap: () => ({}),
|
||||
componentTypeLabelMap: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -225,10 +236,26 @@ const wrapperClass = computed(() =>
|
||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||
);
|
||||
|
||||
const { get } = useApi();
|
||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
||||
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value;
|
||||
};
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (isRoot.value) {
|
||||
return [];
|
||||
}
|
||||
const cached = componentOptionsByPath.value[props.assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null;
|
||||
@@ -274,6 +301,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const typeIri = (id: string) => `/api/model_types/${id}`;
|
||||
const primedPiecePaths = new Set<string>();
|
||||
const primedProductPaths = new Set<string>();
|
||||
const primedComponentPaths = new Set<string>();
|
||||
|
||||
const fetchComponentOptions = async (term = '') => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (componentLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(componentLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/composants?${params.toString()}`);
|
||||
if (result.success) {
|
||||
componentOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(componentLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (pieceLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || definition.typePiece?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(pieceLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`);
|
||||
if (result.success) {
|
||||
pieceOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(pieceLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (productLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || definition.typeProduct?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(productLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/products?${params.toString()}`);
|
||||
if (result.success) {
|
||||
productOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(productLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
@@ -290,7 +415,8 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
@@ -299,20 +425,21 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
}
|
||||
};
|
||||
|
||||
const options = getPieceOptions(definition);
|
||||
const options = getPieceOptions(assignment);
|
||||
const fallbackPiece = options[0] || null;
|
||||
const fallbackType = fallbackPiece?.typePiece || null;
|
||||
|
||||
addPart(definition.role);
|
||||
addPart(
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel ||
|
||||
(definition as any).typePiece?.name ||
|
||||
fallbackType?.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) {
|
||||
@@ -333,10 +460,15 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||
};
|
||||
|
||||
const getProductOptions = (definition: ComponentModelProduct) => {
|
||||
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const cached = productOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typeProductId ||
|
||||
(definition as any).typeProduct?.id ||
|
||||
definition.typeProduct?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -347,7 +479,7 @@ const getProductOptions = (definition: ComponentModelProduct) => {
|
||||
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
|
||||
@@ -386,7 +518,8 @@ const productOptionDescription = (product?: ProductOption | null) => {
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const describeProductRequirement = (definition: ComponentModelProduct) => {
|
||||
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
@@ -395,20 +528,21 @@ const describeProductRequirement = (definition: ComponentModelProduct) => {
|
||||
}
|
||||
};
|
||||
|
||||
const options = getProductOptions(definition);
|
||||
const options = getProductOptions(assignment);
|
||||
const fallbackProduct = options[0] || null;
|
||||
const fallbackType = fallbackProduct?.typeProduct || null;
|
||||
|
||||
addPart(definition.role);
|
||||
addPart(
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel ||
|
||||
(definition as any).typeProduct?.name ||
|
||||
fallbackType?.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) {
|
||||
@@ -435,6 +569,9 @@ const requirementLabel = computed(() => {
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
|
||||
return props.componentTypeLabelMap[definition.typeComposantId];
|
||||
}
|
||||
if (definition.typeComposant?.name) {
|
||||
return definition.typeComposant.name;
|
||||
}
|
||||
@@ -448,6 +585,7 @@ const requirementDescription = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const family =
|
||||
definition.typeComposantLabel ||
|
||||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
||||
definition.typeComposant?.name ||
|
||||
definition.familyCode;
|
||||
if (family) {
|
||||
@@ -456,10 +594,15 @@ const requirementDescription = computed(() => {
|
||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||
});
|
||||
|
||||
const getPieceOptions = (definition: ComponentModelPiece) => {
|
||||
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const cached = pieceOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
(definition as any).typePiece?.id ||
|
||||
definition.typePiece?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -470,7 +613,7 @@ const getPieceOptions = (definition: ComponentModelPiece) => {
|
||||
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
|
||||
@@ -526,13 +669,17 @@ watch(
|
||||
() => [props.pieces, props.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of props.assignment.pieces) {
|
||||
const options = getPieceOptions(pieceAssignment.definition);
|
||||
const options = getPieceOptions(pieceAssignment);
|
||||
if (
|
||||
pieceAssignment.selectedPieceId &&
|
||||
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = '';
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path);
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
@@ -542,15 +689,34 @@ watch(
|
||||
() => [props.products, props.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of props.assignment.products) {
|
||||
const options = getProductOptions(productAssignment.definition);
|
||||
const options = getProductOptions(productAssignment);
|
||||
if (
|
||||
productAssignment.selectedProductId &&
|
||||
!options.some((product) => product.id === productAssignment.selectedProductId)
|
||||
) {
|
||||
productAssignment.selectedProductId = '';
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path);
|
||||
fetchProductOptions(productAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.assignment.definition,
|
||||
() => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||
primedComponentPaths.add(key);
|
||||
fetchComponentOptions().catch(() => {});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="canRenderPdf"
|
||||
:src="previewSrc"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="icon.component"
|
||||
@@ -54,8 +48,6 @@ const props = defineProps<{
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const normalizedDocument = computed(() => props.document ?? null);
|
||||
|
||||
const canRenderImage = computed(() => {
|
||||
@@ -64,14 +56,9 @@ const canRenderImage = computed(() => {
|
||||
});
|
||||
|
||||
const canRenderPdf = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
if (!doc || !isPdfDocument(doc) || !doc.path) {
|
||||
return false;
|
||||
}
|
||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// Rendering many PDF iframes in a list is very heavy for the browser.
|
||||
// We intentionally disable inline PDF previews and fall back to an icon.
|
||||
return false;
|
||||
});
|
||||
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,10 @@
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -35,6 +35,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(product)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -51,12 +52,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isProductLocked(product)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeProduct(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -108,7 +119,7 @@
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
@@ -128,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
@@ -137,16 +148,27 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isFieldLocked(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isFieldLocked(field)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -181,6 +203,7 @@ type EditorProduct = {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
restrictedMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -330,6 +353,19 @@ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
||||
|
||||
const isFieldLocked = (field: EditorField): boolean => {
|
||||
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
|
||||
}
|
||||
|
||||
const isProductLocked = (product: EditorProduct): boolean => {
|
||||
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
|
||||
}
|
||||
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
@@ -438,6 +474,8 @@ watch(
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -472,6 +510,10 @@ const reorderFields = (from: number, to: number) => {
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(to, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<SearchSelect
|
||||
:model-value="modelValue"
|
||||
:model-value="modelValue ?? undefined"
|
||||
:options="productOptions"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<select
|
||||
v-model="node.typeComposantId"
|
||||
class="select select-bordered select-sm w-full"
|
||||
:disabled="isLocked"
|
||||
@change="handleComponentTypeSelect(node)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -42,6 +43,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Alias du sous-composant"
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,13 +54,18 @@
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isRoot"
|
||||
v-if="!isRoot && !isLocked"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 space-y-5">
|
||||
@@ -108,7 +115,7 @@
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste</option>
|
||||
@@ -117,7 +124,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<textarea
|
||||
@@ -125,15 +132,26 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCustomFieldLocked(index)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeCustomField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +162,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -179,6 +197,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(index)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -194,9 +213,18 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +235,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -243,6 +271,7 @@
|
||||
<select
|
||||
v-model="piece.typePieceId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isPieceLocked(index)"
|
||||
@change="handlePieceTypeSelect(piece)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -262,9 +291,14 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,7 +308,7 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<button
|
||||
v-if="canManageSubcomponents"
|
||||
v-if="canManageSubcomponents && !restrictedMode"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
@@ -317,6 +351,8 @@
|
||||
:product-types="productTypes"
|
||||
:allow-subcomponents="childAllowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
:is-locked="isSubcomponentLocked(index)"
|
||||
@remove="removeSubComponent(index)"
|
||||
/>
|
||||
</div>
|
||||
@@ -359,6 +395,8 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel?: string
|
||||
allowSubcomponents?: boolean
|
||||
maxSubcomponentDepth?: number
|
||||
restrictedMode?: boolean
|
||||
isLocked?: boolean
|
||||
}>(), {
|
||||
depth: 0,
|
||||
componentTypes: () => [],
|
||||
@@ -369,10 +407,52 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel: '',
|
||||
allowSubcomponents: true,
|
||||
maxSubcomponentDepth: Infinity,
|
||||
restrictedMode: false,
|
||||
isLocked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
||||
const initialProductIndices = ref<Set<number>>(new Set())
|
||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
||||
|
||||
const initializeLockedIndices = () => {
|
||||
if (props.restrictedMode) {
|
||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
||||
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
|
||||
|
||||
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
|
||||
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
|
||||
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
|
||||
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
|
||||
}
|
||||
}
|
||||
|
||||
initializeLockedIndices()
|
||||
|
||||
const isCustomFieldLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isPieceLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialPieceIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isProductLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialProductIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isSubcomponentLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
@@ -460,7 +540,7 @@ const getPieceTypeLabel = (id?: string) => {
|
||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const getProductTypeLabel = (id?: string) => {
|
||||
const _getProductTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(productTypeMap.value.get(id))
|
||||
}
|
||||
@@ -744,10 +824,9 @@ const customFieldReorderClass = (index: number) => {
|
||||
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
const nextIndex = Array.isArray(props.node.customFields)
|
||||
? props.node.customFields.length
|
||||
: 0
|
||||
props.node.customFields.push({
|
||||
const fields = props.node.customFields!
|
||||
const nextIndex = fields.length
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
@@ -766,7 +845,7 @@ const removeCustomField = (index: number) => {
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces.push({
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
@@ -782,7 +861,7 @@ const removePiece = (index: number) => {
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products.push({
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
@@ -830,6 +909,7 @@ const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
||||
}
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
@@ -62,19 +62,30 @@ const deepClone = value => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const normalizeCustomField = (field = {}, index = 0) => {
|
||||
const clone = deepClone(field)
|
||||
if (clone.type === 'select') {
|
||||
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
||||
if (Array.isArray(clone.options)) {
|
||||
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
||||
} else {
|
||||
clone.optionsText = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
const withNormalizedOrder = (items = []) => {
|
||||
if (!Array.isArray(items)) { return [] }
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const clone = deepClone(item)
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
})
|
||||
.map((item, index) => normalizeCustomField(item, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((item, index) => ({ ...item, orderIndex: index }))
|
||||
}
|
||||
|
||||
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>
|
||||
128
app/components/common/Pagination.vue
Normal file
128
app/components/common/Pagination.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<IconLucideChevronFirst class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<IconLucideChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<IconLucideChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<IconLucideChevronLast class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
|
||||
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxVisiblePages: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage'])
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = props.totalPages
|
||||
const current = props.currentPage
|
||||
const maxVisible = props.maxVisiblePages
|
||||
|
||||
if (total <= maxVisible + 2) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(2, current - half)
|
||||
let end = Math.min(total - 1, current + half)
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= half + 1) {
|
||||
end = maxVisible
|
||||
}
|
||||
// Adjust if near end
|
||||
if (current >= total - half) {
|
||||
start = total - maxVisible + 1
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis-start')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total - 1) {
|
||||
pages.push('ellipsis-end')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
}
|
||||
}
|
||||
</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()
|
||||
|
||||
@@ -122,7 +122,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
@@ -184,11 +184,12 @@ watch(
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value
|
||||
(_newOptions) => {
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(openDropdown, (isOpen) => {
|
||||
@@ -265,6 +266,7 @@ function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
|
||||
369
app/components/layout/AppNavbar.vue
Normal file
369
app/components/layout/AppNavbar.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<!-- Mobile hamburger menu -->
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary"
|
||||
@click="$emit('open-settings')"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
Paramètres d'affichage
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Mobile: simple links -->
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<!-- Mobile: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
:key="group.id + '-mobile'"
|
||||
class="mt-1 border-t border-base-200 pt-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="groupClass(group)"
|
||||
:aria-expanded="openDropdown === group.id + '-mobile'"
|
||||
@click="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
>
|
||||
<span>{{ group.label }}</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === group.id + '-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li v-for="child in group.children" :key="child.to">
|
||||
<NuxtLink
|
||||
:to="child.to"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar">
|
||||
<div class="w-14">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventory
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navbar -->
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<!-- Desktop: simple links -->
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<!-- Desktop: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
:key="group.id + '-desktop'"
|
||||
class="relative"
|
||||
@mouseenter="setDropdown(group.id + '-desktop')"
|
||||
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
|
||||
@focusin="setDropdown(group.id + '-desktop')"
|
||||
@focusout="scheduleDropdownClose(group.id + '-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="groupClass(group)"
|
||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||
@click="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
>
|
||||
{{ group.label }}
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === group.id + '-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li v-for="child in group.children" :key="child.to">
|
||||
<NuxtLink
|
||||
:to="child.to"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Navbar end -->
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
@click="$emit('open-settings')"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="activeProfile" class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
>
|
||||
<div
|
||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||
>
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
||||
>
|
||||
{{ activeProfileInitials }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
|
||||
>
|
||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||
Connecté en tant que<br />
|
||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
||||
Gestion des profils
|
||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="text-error justify-between"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
Déconnexion
|
||||
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
import IconLucideMenu from '~icons/lucide/menu'
|
||||
import IconLucideSettings from '~icons/lucide/settings'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open-settings'): void
|
||||
(e: 'logout'): void
|
||||
}>()
|
||||
|
||||
interface NavLink {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
id: string
|
||||
label: string
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble' },
|
||||
{ to: '/machines', label: 'Parc Machines' },
|
||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'pieces',
|
||||
label: 'Pièces',
|
||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||
children: [
|
||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||
{ to: '/piece-category', label: 'Catégorie de pièce' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Produits',
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composant',
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||
const { activeProfile } = useProfileSession()
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') {
|
||||
return route.path === '/'
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const isGroupActive = (group: NavGroup) => {
|
||||
return group.activePaths.some((path) => isActive(path))
|
||||
}
|
||||
|
||||
const linkClass = (link: NavLink) => {
|
||||
return isActive(link.to)
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
}
|
||||
|
||||
const groupClass = (group: NavGroup) => {
|
||||
return isGroupActive(group)
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
}
|
||||
|
||||
const childLinkClass = (child: NavLink) => {
|
||||
return isActive(child.to)
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
}
|
||||
|
||||
const activeProfileLabel = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return 'Profil inconnu'
|
||||
}
|
||||
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
|
||||
})
|
||||
|
||||
const activeProfileInitials = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return '??'
|
||||
}
|
||||
const { firstName = '', lastName = '' } = activeProfile.value
|
||||
return (
|
||||
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-dropdown-desktop-enter-active,
|
||||
.nav-dropdown-desktop-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.nav-dropdown-desktop-enter-from,
|
||||
.nav-dropdown-desktop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
.nav-dropdown-desktop-enter-to,
|
||||
.nav-dropdown-desktop-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.nav-dropdown-mobile-enter-active,
|
||||
.nav-dropdown-mobile-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-from,
|
||||
.nav-dropdown-mobile-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-to,
|
||||
.nav-dropdown-mobile-leave-from {
|
||||
max-height: 12rem;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
Convertir la catégorie
|
||||
</h3>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Vérification de la conversion…
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||
{{ checkError }}
|
||||
</div>
|
||||
|
||||
<!-- Blocked state -->
|
||||
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||
<p class="mt-3 text-sm text-base-content/70">
|
||||
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||
</p>
|
||||
<ul class="mt-3 space-y-1">
|
||||
<li
|
||||
v-for="(blocker, i) in checkResult.blockers"
|
||||
:key="i"
|
||||
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||
>
|
||||
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{{ blocker }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Eligible state -->
|
||||
<template v-else-if="checkResult && checkResult.canConvert">
|
||||
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||
<p class="text-sm font-medium text-warning">
|
||||
{{ directionLabel }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="checkResult.names.length > 0"
|
||||
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||
>
|
||||
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||
Éléments concernés :
|
||||
</p>
|
||||
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||
<li
|
||||
v-for="(name, i) in checkResult.names"
|
||||
:key="i"
|
||||
class="py-1.5 text-sm text-base-content"
|
||||
>
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||
{{ convertError }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="converting"
|
||||
@click="emit('close')"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
v-if="checkResult?.canConvert"
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
:disabled="converting"
|
||||
@click="doConvert"
|
||||
>
|
||||
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Convertir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||
import {
|
||||
checkConversion,
|
||||
convertCategory,
|
||||
type ConversionCheck,
|
||||
type ModelType,
|
||||
} from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
modelType: ModelType | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'converted'): void;
|
||||
}>();
|
||||
|
||||
const checking = ref(false);
|
||||
const checkError = ref<string | null>(null);
|
||||
const checkResult = ref<ConversionCheck | null>(null);
|
||||
const converting = ref(false);
|
||||
const convertError = ref<string | null>(null);
|
||||
|
||||
const directionLabel = computed(() => {
|
||||
if (!checkResult.value) return '';
|
||||
return checkResult.value.direction === 'piece_to_component'
|
||||
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen || !props.modelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
checking.value = true;
|
||||
checkError.value = null;
|
||||
checkResult.value = null;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
checkResult.value = await checkConversion(props.modelType.id);
|
||||
} catch (err: any) {
|
||||
checkError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const doConvert = async () => {
|
||||
if (!props.modelType) return;
|
||||
|
||||
converting.value = true;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
const result = await convertCategory(props.modelType.id);
|
||||
|
||||
if (!result.success) {
|
||||
convertError.value = result.error || 'La conversion a échoué.';
|
||||
return;
|
||||
}
|
||||
|
||||
emit('converted');
|
||||
} catch (err: any) {
|
||||
convertError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -29,18 +29,86 @@
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@convert="openConversionModal"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
|
||||
<ModelTypesConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ relatedModalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ relatedModalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
||||
{{ relatedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="relatedItems.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in relatedItems"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="openRelatedEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeRelatedModal">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useUrlState } from "~/composables/useUrlState";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
@@ -49,6 +117,7 @@ import {
|
||||
type ModelTypeListResponse,
|
||||
} from "~/services/modelTypes";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
||||
@@ -67,11 +136,28 @@ const props = withDefaults(
|
||||
|
||||
const selectedCategory = ref<ModelCategory>(props.category);
|
||||
const searchInput = ref("");
|
||||
const searchTerm = ref("");
|
||||
const sort = ref<"name" | "createdAt">("name");
|
||||
const dir = ref<"asc" | "desc">("asc");
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const urlState = useUrlState({
|
||||
q: { default: '' },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
limit: { default: 20, type: 'number' },
|
||||
offset: { default: 0, type: 'number' },
|
||||
}, {
|
||||
onRestore: () => {
|
||||
searchInput.value = urlState.q.value;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
const searchTerm = urlState.q;
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
||||
const limit = urlState.limit;
|
||||
const offset = urlState.offset;
|
||||
|
||||
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
||||
searchInput.value = searchTerm.value;
|
||||
|
||||
const items = ref<ModelType[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -82,6 +168,7 @@ let activeController: AbortController | null = null;
|
||||
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
@@ -96,7 +183,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;
|
||||
};
|
||||
@@ -151,8 +238,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));
|
||||
@@ -235,16 +322,19 @@ const openEditPage = (item: ModelType) => {
|
||||
});
|
||||
};
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = window.confirm(
|
||||
"Supprimer ce type ? Cette action est irréversible."
|
||||
);
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id);
|
||||
invalidateEntityTypeCache(item.category);
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
@@ -257,6 +347,173 @@ const confirmDelete = async (item: ModelType) => {
|
||||
}
|
||||
};
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
reference?: string | null;
|
||||
};
|
||||
|
||||
const relatedModalOpen = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedError = ref<string | null>(null);
|
||||
const relatedItems = ref<RelatedEntry[]>([]);
|
||||
const relatedType = ref<ModelType | null>(null);
|
||||
|
||||
const relatedCategoryLabels: Record<
|
||||
ModelCategory,
|
||||
{ plural: string; singular: string }
|
||||
> = {
|
||||
COMPONENT: { plural: "composants", singular: "composant" },
|
||||
PIECE: { plural: "pièces", singular: "pièce" },
|
||||
PRODUCT: { plural: "produits", singular: "produit" },
|
||||
};
|
||||
|
||||
const relatedModalTitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "Éléments liés";
|
||||
}
|
||||
return `Éléments liés à « ${current.name} »`;
|
||||
});
|
||||
|
||||
const relatedModalSubtitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "";
|
||||
}
|
||||
const labels =
|
||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||
const count = relatedItems.value.length;
|
||||
if (relatedLoading.value) {
|
||||
return `Chargement des ${labels.plural}…`;
|
||||
}
|
||||
if (count === 0) {
|
||||
return `Aucun ${labels.singular} lié.`;
|
||||
}
|
||||
if (count === 1) {
|
||||
return `1 ${labels.singular} lié.`;
|
||||
}
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||
}
|
||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||
};
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/pieces";
|
||||
}
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof record.reference === "string" && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === "string" && record.code.trim()
|
||||
? record.code
|
||||
: null;
|
||||
return {
|
||||
id: record.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRelatedItems = async (item: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||
const params = new URLSearchParams();
|
||||
params.set("itemsPerPage", "200");
|
||||
params.set(filterKey, buildModelTypeIri(item.id));
|
||||
params.set("order[name]", "asc");
|
||||
|
||||
relatedLoading.value = true;
|
||||
relatedError.value = null;
|
||||
relatedItems.value = [];
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`);
|
||||
if (!result.success) {
|
||||
relatedError.value =
|
||||
result.error ?? "Impossible de charger les éléments liés.";
|
||||
return;
|
||||
}
|
||||
const collection = extractCollection(result.data);
|
||||
relatedItems.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||
} catch (error) {
|
||||
relatedError.value = extractErrorMessage(error);
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item;
|
||||
relatedModalOpen.value = true;
|
||||
void loadRelatedItems(item);
|
||||
};
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const basePath = resolveRelatedEditBasePath(current.category);
|
||||
relatedModalOpen.value = false;
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.");
|
||||
});
|
||||
};
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
|
||||
const conversionModalOpen = ref(false);
|
||||
const conversionTarget = ref<ModelType | null>(null);
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item;
|
||||
conversionModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false;
|
||||
};
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false;
|
||||
invalidateEntityTypeCache("PIECE");
|
||||
invalidateEntityTypeCache("COMPONENT");
|
||||
showSuccess("Catégorie convertie avec succès.");
|
||||
refresh();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
minlength="2"
|
||||
maxlength="120"
|
||||
required
|
||||
:disabled="restrictedMode"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||
</div>
|
||||
@@ -47,6 +48,7 @@
|
||||
rows="4"
|
||||
name="notes"
|
||||
maxlength="2000"
|
||||
:disabled="restrictedMode"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||
</div>
|
||||
@@ -81,6 +83,7 @@
|
||||
v-model="componentStructure"
|
||||
:allow-subcomponents="allowComponentSubcomponents"
|
||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -103,11 +106,30 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="restrictedMode && restrictedModeMessage"
|
||||
class="alert alert-info"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>{{ restrictedModeMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="disableSubmit"
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span>{{ disableSubmitMessage }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
@@ -150,6 +172,10 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading?: boolean
|
||||
allowComponentSubcomponents?: boolean
|
||||
componentSubcomponentMaxDepth?: number
|
||||
disableSubmit?: boolean
|
||||
disableSubmitMessage?: string
|
||||
restrictedMode?: boolean
|
||||
restrictedModeMessage?: string
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
@@ -157,6 +183,10 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading: false,
|
||||
allowComponentSubcomponents: true,
|
||||
componentSubcomponentMaxDepth: 1,
|
||||
disableSubmit: false,
|
||||
disableSubmitMessage: '',
|
||||
restrictedMode: false,
|
||||
restrictedModeMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -173,6 +203,18 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
? props.componentSubcomponentMaxDepth
|
||||
: 1,
|
||||
)
|
||||
const disableSubmit = computed(() => props.disableSubmit === true)
|
||||
const disableSubmitMessage = computed(() =>
|
||||
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
|
||||
? props.disableSubmitMessage
|
||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||
)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
const restrictedModeMessage = computed(() =>
|
||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||
? props.restrictedModeMessage
|
||||
: '',
|
||||
)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
name: '',
|
||||
@@ -236,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
|
||||
@@ -248,7 +291,7 @@ const resetForm = () => {
|
||||
}
|
||||
|
||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
||||
|
||||
const validate = () => {
|
||||
errors.name = undefined
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<tr class="text-base-content/70">
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col" class="w-32 text-right">Actions</th>
|
||||
<th scope="col" class="w-48 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,6 +45,18 @@
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -72,6 +84,18 @@
|
||||
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -112,6 +136,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import IconLucideInbox from '~icons/lucide/inbox';
|
||||
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -120,14 +145,21 @@ const props = defineProps<{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: ModelCategory;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'convert', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
}>();
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||
);
|
||||
|
||||
const categoryDictionary: Record<ModelCategory, string> = {
|
||||
COMPONENT: 'Composants',
|
||||
PIECE: 'Pièces',
|
||||
|
||||
70
app/composables/useActivityLog.ts
Normal file
70
app/composables/useActivityLog.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ActivityLogActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ActivityLogEntry = {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName: string | null
|
||||
entityRef: string | null
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ActivityLogActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface LoadActivityLogOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
entityType?: string
|
||||
action?: string
|
||||
}
|
||||
|
||||
export function useActivityLog() {
|
||||
const { get } = useApi()
|
||||
|
||||
const entries = ref<ActivityLogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(options.page ?? 1))
|
||||
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
if (options.action) params.set('action', options.action)
|
||||
|
||||
const result = await get(`/activity-logs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
|
||||
entries.value = []
|
||||
return result
|
||||
}
|
||||
|
||||
const data = result.data as any
|
||||
entries.value = Array.isArray(data?.items) ? data.items : []
|
||||
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
|
||||
|
||||
return { success: true, data: entries.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
entries.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, total, loading, error, loadActivityLog }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
114
app/composables/useCategoryEditGuard.ts
Normal file
114
app/composables/useCategoryEditGuard.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
type GuardLabels = {
|
||||
singular: string
|
||||
plural: string
|
||||
verifying: string
|
||||
}
|
||||
|
||||
type GuardConfig = {
|
||||
endpoint: string
|
||||
filterKey: string
|
||||
labels: GuardLabels
|
||||
}
|
||||
|
||||
const extractTotal = (payload: any, fallbackLength: number) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member.length
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member'].length
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useCategoryEditGuard (config: GuardConfig) {
|
||||
const { get } = useApi()
|
||||
const { showInfo } = useToast()
|
||||
|
||||
const linkedCount = ref(0)
|
||||
const linkedLoading = ref(false)
|
||||
|
||||
const loadLinkedCount = async (modelTypeId: string) => {
|
||||
linkedLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '1')
|
||||
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
|
||||
|
||||
const result = await get(`${config.endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
linkedCount.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackLength = Array.isArray(result.data?.member)
|
||||
? result.data.member.length
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member'].length
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (_error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isRestrictedMode = computed(
|
||||
() => !linkedLoading.value && linkedCount.value > 0,
|
||||
)
|
||||
|
||||
const isSubmitBlocked = computed(
|
||||
() => linkedLoading.value,
|
||||
)
|
||||
|
||||
const restrictedModeMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
if (linkedCount.value <= 0) {
|
||||
return ''
|
||||
}
|
||||
if (linkedCount.value === 1) {
|
||||
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
||||
}
|
||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
||||
})
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const guardSubmitOrNotify = () => {
|
||||
if (!isSubmitBlocked.value) {
|
||||
return false
|
||||
}
|
||||
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
linkedCount,
|
||||
linkedLoading,
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
}
|
||||
}
|
||||
|
||||
12
app/composables/useComponentHistory.ts
Normal file
12
app/composables/useComponentHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ComponentHistoryActor = EntityHistoryActor
|
||||
export type ComponentHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useComponentHistory() {
|
||||
return useEntityHistory('composant')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
|
||||
const composants = 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 function useComposants () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (composant) => {
|
||||
if (!composant || typeof composant !== 'object') {
|
||||
return composant
|
||||
}
|
||||
if (!composant.typeComposantId) {
|
||||
const typeComposantId = extractRelationId(composant.typeComposant)
|
||||
if (typeComposantId) {
|
||||
composant.typeComposantId = typeComposantId
|
||||
}
|
||||
}
|
||||
if (!composant.productId) {
|
||||
const productId = extractRelationId(composant.product)
|
||||
if (productId) {
|
||||
composant.productId = productId
|
||||
}
|
||||
}
|
||||
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')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
if (resolved.length) {
|
||||
composant.constructeurs = resolved
|
||||
composant.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return composant
|
||||
}
|
||||
|
||||
const loadComposants = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/composants')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
composants.value = enrichedItems
|
||||
showInfo(`Chargement de ${composants.value.length} composant(s) réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComposant = async (composantData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||
const result = await post('/composants', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
composants.value.push(enriched)
|
||||
const displayName = result.data?.name
|
||||
|| composantData?.definition?.name
|
||||
|| composantData?.name
|
||||
|| 'Composant'
|
||||
showSuccess(`Composant "${displayName}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateComposantData = async (id, composantData) => {
|
||||
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 (index !== -1) {
|
||||
composants.value[index] = updated
|
||||
}
|
||||
showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComposant = async (id) => {
|
||||
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)
|
||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du composant:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getComposants = () => composants.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
composants,
|
||||
loading,
|
||||
loadComposants,
|
||||
createComposant,
|
||||
updateComposant: updateComposantData,
|
||||
deleteComposant,
|
||||
getComposants,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
257
app/composables/useComposants.ts
Normal file
257
app/composables/useComposants.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
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'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const composants = ref<Composant[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useComposants() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (composant: Composant): Promise<Composant> => {
|
||||
if (!composant || typeof composant !== 'object') {
|
||||
return composant
|
||||
}
|
||||
if (!composant.typeComposantId) {
|
||||
const typeComposantId = extractRelationId(composant.typeComposant)
|
||||
if (typeComposantId) {
|
||||
composant.typeComposantId = typeComposantId
|
||||
}
|
||||
}
|
||||
if (!composant.productId) {
|
||||
const productId = extractRelationId(composant.product)
|
||||
if (productId) {
|
||||
composant.productId = productId
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
composant.constructeurIds,
|
||||
composant.constructeurs,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
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)
|
||||
if (resolved.length) {
|
||||
composant.constructeurs = resolved
|
||||
composant.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return composant
|
||||
}
|
||||
|
||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
composants.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result as ComposantListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
||||
composants.value.unshift(enriched)
|
||||
total.value += 1
|
||||
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 { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 && 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 { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getComposants = () => composants.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearComposantsCache = () => {
|
||||
composants.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
composants,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadComposants,
|
||||
createComposant,
|
||||
updateComposant: updateComposantData,
|
||||
deleteComposant,
|
||||
getComposants,
|
||||
isLoading,
|
||||
clearComposantsCache,
|
||||
}
|
||||
}
|
||||
73
app/composables/useConfirm.ts
Normal file
73
app/composables/useConfirm.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Promise-based confirmation dialog composable.
|
||||
*
|
||||
* Usage:
|
||||
* const { confirm, confirmState } = useConfirm()
|
||||
* const ok = await confirm({ message: 'Supprimer ?' })
|
||||
* if (ok) { ... }
|
||||
*
|
||||
* The ConfirmModal component reads `confirmState` to render the dialog.
|
||||
*/
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
dangerous?: boolean
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
dangerous: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
const state = reactive<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: 'Supprimer',
|
||||
cancelText: 'Annuler',
|
||||
dangerous: true,
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
function confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.title = options.title ?? 'Confirmation'
|
||||
state.message = options.message
|
||||
state.confirmText = options.confirmText ?? 'Supprimer'
|
||||
state.cancelText = options.cancelText ?? 'Annuler'
|
||||
state.dangerous = options.dangerous ?? true
|
||||
state.resolve = resolve
|
||||
state.open = true
|
||||
})
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
state.resolve?.(true)
|
||||
state.open = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
state.resolve?.(false)
|
||||
state.open = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
return {
|
||||
confirm,
|
||||
confirmState: state,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const constructeurs = ref([])
|
||||
export interface Constructeur {
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
success: boolean
|
||||
data?: Constructeur | Constructeur[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const uniqueConstructeurs = (items = []) => {
|
||||
const map = new Map()
|
||||
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
|
||||
const map = new Map<string, Constructeur>()
|
||||
items.forEach((item) => {
|
||||
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||
map.set(item.id, item)
|
||||
@@ -15,7 +30,7 @@ const uniqueConstructeurs = (items = []) => {
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const normalizeIds = (ids = []) => {
|
||||
const normalizeIds = (ids: unknown[] = []): string[] => {
|
||||
if (!Array.isArray(ids)) {
|
||||
return []
|
||||
}
|
||||
@@ -28,7 +43,7 @@ const normalizeIds = (ids = []) => {
|
||||
)
|
||||
}
|
||||
|
||||
const upsertConstructeurs = (items = []) => {
|
||||
const upsertConstructeurs = (items: Constructeur[] = []) => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
return
|
||||
}
|
||||
@@ -36,32 +51,19 @@ const upsertConstructeurs = (items = []) => {
|
||||
constructeurs.value = merged
|
||||
}
|
||||
|
||||
const getIndexedConstructeur = (id) =>
|
||||
const getIndexedConstructeur = (id: string): Constructeur | null =>
|
||||
constructeurs.value.find((item) => item && item.id === id) || null
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
const pendingFetches = new Map<string, Promise<Constructeur | null>>()
|
||||
|
||||
const pendingFetches = new Map()
|
||||
|
||||
export function useConstructeurs () {
|
||||
export function useConstructeurs() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const loadConstructeurs = async (search = '') => {
|
||||
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
|
||||
if (!search && !options.force && loaded.value) {
|
||||
return { success: true, data: constructeurs.value }
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
@@ -69,48 +71,51 @@ export function useConstructeurs () {
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
if (!search) loaded.value = true
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des fournisseurs:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchConstructeurs = async (search = '') => {
|
||||
const searchConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data) => {
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data])
|
||||
showSuccess(`Fournisseur "${result.data.name}" créé`)
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" créé`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la création du fournisseur:', error)
|
||||
showError('Impossible de créer le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureConstructeurs = async (ids = []) => {
|
||||
const ensureConstructeurs = async (ids: unknown[] = []): Promise<Constructeur[]> => {
|
||||
const normalizedIds = normalizeIds(ids)
|
||||
if (!normalizedIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const collected = []
|
||||
const missing = []
|
||||
const collected: Constructeur[] = []
|
||||
const missing: string[] = []
|
||||
normalizedIds.forEach((id) => {
|
||||
const existing = getIndexedConstructeur(id)
|
||||
if (existing) {
|
||||
@@ -129,7 +134,7 @@ export function useConstructeurs () {
|
||||
const task = get(`/constructeurs/${id}`)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
return result.data
|
||||
return result.data as Constructeur
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -145,7 +150,7 @@ export function useConstructeurs () {
|
||||
})
|
||||
|
||||
const fetched = await Promise.all(fetchTasks)
|
||||
const validFetched = fetched.filter((item) => item && item.id)
|
||||
const validFetched = fetched.filter((item): item is Constructeur => item !== null && item.id !== undefined)
|
||||
if (validFetched.length) {
|
||||
upsertConstructeurs(validFetched)
|
||||
}
|
||||
@@ -153,50 +158,52 @@ export function useConstructeurs () {
|
||||
|
||||
return normalizedIds
|
||||
.map((id) => getIndexedConstructeur(id))
|
||||
.filter((item) => Boolean(item))
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id, data) => {
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data])
|
||||
showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" mis à jour`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la mise à jour du fournisseur:', error)
|
||||
showError('Impossible de mettre à jour le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConstructeur = async (id) => {
|
||||
const deleteConstructeur = async (id: string): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/constructeurs/${id}`)
|
||||
if (result.success) {
|
||||
constructeurs.value = constructeurs.value.filter(item => item.id !== id)
|
||||
constructeurs.value = constructeurs.value.filter((item) => item.id !== id)
|
||||
showSuccess('Fournisseur supprimé')
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la suppression du fournisseur:', error)
|
||||
showError('Impossible de supprimer le fournisseur')
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getConstructeurById = (id) => getIndexedConstructeur(id)
|
||||
const getConstructeurById = (id: string) => getIndexedConstructeur(id)
|
||||
|
||||
return {
|
||||
constructeurs,
|
||||
@@ -1,66 +1,75 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
|
||||
export function useCustomFields () {
|
||||
export interface CustomFieldValue {
|
||||
id: string
|
||||
customFieldId: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
value: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useCustomFields() {
|
||||
const { apiCall } = useApi()
|
||||
const customFieldValues = ref([])
|
||||
const customFieldValues = ref<CustomFieldValue[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Créer une valeur de champ personnalisé
|
||||
const createCustomFieldValue = async (customFieldValueData) => {
|
||||
const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(customFieldValueData)
|
||||
body: JSON.stringify(customFieldValueData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les valeurs de champs personnalisés pour une entité
|
||||
const getCustomFieldValuesByEntity = async (entityType, entityId) => {
|
||||
const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
|
||||
method: 'GET'
|
||||
method: 'GET',
|
||||
})
|
||||
if (result.success) {
|
||||
customFieldValues.value = result.data
|
||||
customFieldValues.value = result.data as CustomFieldValue[]
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une valeur de champ personnalisé
|
||||
const updateCustomFieldValue = async (id, updateData) => {
|
||||
const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData)
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||
const upsertCustomFieldValue = async (
|
||||
customFieldId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
metadata = {},
|
||||
) => {
|
||||
customFieldId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values/upsert', {
|
||||
method: 'POST',
|
||||
@@ -69,26 +78,26 @@ export function useCustomFields () {
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
...metadata
|
||||
})
|
||||
...metadata,
|
||||
}),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une valeur de champ personnalisé
|
||||
const deleteCustomFieldValue = async (id) => {
|
||||
const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +108,6 @@ export function useCustomFields () {
|
||||
getCustomFieldValuesByEntity,
|
||||
updateCustomFieldValue,
|
||||
upsertCustomFieldValue,
|
||||
deleteCustomFieldValue
|
||||
deleteCustomFieldValue,
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||
|
||||
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 []
|
||||
}
|
||||
|
||||
const fileToBase64 = file =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
export function useDocuments () {
|
||||
const { get, post, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (endpoint, { updateStore = false } = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get(endpoint)
|
||||
if (result.success) {
|
||||
const data = extractCollection(result.data)
|
||||
if (updateStore) {
|
||||
documents.value = data
|
||||
}
|
||||
return { success: true, data }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du chargement des documents (${endpoint}):`, error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (options = {}) => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (siteId, options = {}) => {
|
||||
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' } }
|
||||
return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByComponent = async (componentId, options = {}) => {
|
||||
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é' } }
|
||||
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' } }
|
||||
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é' } }
|
||||
|
||||
loading.value = true
|
||||
const created = []
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const dataUrl = await fileToBase64(file)
|
||||
|
||||
const payload = normalizeRelationIds({
|
||||
name: file.name,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
path: dataUrl,
|
||||
...context
|
||||
})
|
||||
|
||||
const result = await post('/documents', payload)
|
||||
if (result.success) {
|
||||
created.push(result.data)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
} else if (result.error) {
|
||||
showError(`Erreur sur ${file.name} : ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length) {
|
||||
if (updateStore) {
|
||||
documents.value = [...created, ...documents.value]
|
||||
}
|
||||
return { success: true, data: created }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Aucun document ajouté' }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'upload des documents:', error)
|
||||
showError("Échec de l'ajout des documents")
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDocument = async (id, { updateStore = false } = {}) => {
|
||||
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)
|
||||
}
|
||||
showSuccess('Document supprimé')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du document:', error)
|
||||
showError('Impossible de supprimer le document')
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
loading,
|
||||
loadDocuments,
|
||||
loadDocumentsBySite,
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByComponent,
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
deleteDocument
|
||||
}
|
||||
}
|
||||
226
app/composables/useDocuments.ts
Normal file
226
app/composables/useDocuments.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 as string)
|
||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, post, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
endpoint: string,
|
||||
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||
const result = await get(url)
|
||||
if (result.success) {
|
||||
const data = extractCollection(result.data)
|
||||
if (updateStore) {
|
||||
documents.value = data
|
||||
}
|
||||
return { success: true, data }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error(`Erreur lors du chargement des documents (${endpoint}):`, error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||
}
|
||||
|
||||
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: 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: 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: 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: 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 = {} }: { 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: Document[] = []
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const dataUrl = await fileToBase64(file)
|
||||
|
||||
const payload = normalizeRelationIds({
|
||||
name: file.name,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
path: dataUrl,
|
||||
...context,
|
||||
})
|
||||
|
||||
const result = await post('/documents', payload)
|
||||
if (result.success) {
|
||||
created.push(result.data as Document)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
} else if (result.error) {
|
||||
showError(`Erreur sur ${file.name} : ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length) {
|
||||
if (updateStore) {
|
||||
documents.value = [...created, ...documents.value]
|
||||
}
|
||||
return { success: true, data: created }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Aucun document ajouté' }
|
||||
} catch (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: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
showSuccess('Document supprimé')
|
||||
}
|
||||
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: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
loading,
|
||||
loadDocuments,
|
||||
loadDocumentsBySite,
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByComponent,
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
deleteDocument,
|
||||
}
|
||||
}
|
||||
181
app/composables/useEntityCustomFields.ts
Normal file
181
app/composables/useEntityCustomFields.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Reactive custom field management for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
|
||||
* watchers, and API calls for updating/upserting custom field values.
|
||||
*/
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
buildDefinitionSources,
|
||||
buildCandidateCustomFields,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
ensureCustomFieldId,
|
||||
resolveFieldId,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
buildCustomFieldMetadata,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
export interface EntityCustomFieldsDeps {
|
||||
entity: () => any
|
||||
entityType: 'composant' | 'piece'
|
||||
}
|
||||
|
||||
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const {
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const definitionSources = computed(() =>
|
||||
buildDefinitionSources(entity(), entityType),
|
||||
)
|
||||
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() =>
|
||||
buildCandidateCustomFields(entity(), definitionSources.value),
|
||||
)
|
||||
|
||||
// Watchers to ensure field IDs are resolved
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(displayedCustomFields.value || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(fields || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const updateCustomField = async (field: any) => {
|
||||
if (!field || resolveFieldReadOnly(field)) return
|
||||
|
||||
const e = entity()
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
|
||||
// Update existing field value
|
||||
if (fieldValueId) {
|
||||
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
|
||||
if (existingValue?.customField?.id) {
|
||||
field.customFieldId = existingValue.customField.id
|
||||
field.customField = existingValue.customField
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create new field value
|
||||
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const fieldName = resolveFieldName(field)
|
||||
if (!e?.id) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
entityType,
|
||||
e.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
field.customField = newValue.customField
|
||||
}
|
||||
|
||||
if (Array.isArray(e.customFieldValues)) {
|
||||
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
|
||||
if (index !== -1) {
|
||||
e.customFieldValues.splice(index, 1, newValue)
|
||||
} else {
|
||||
e.customFieldValues.push(newValue)
|
||||
}
|
||||
} else {
|
||||
e.customFieldValues = [newValue]
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||
|
||||
// Update definitions list
|
||||
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
|
||||
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const existingIndex = definitions.findIndex((definition: any) => {
|
||||
const definitionId = resolveCustomFieldId(definition)
|
||||
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
|
||||
return definition?.name === resolveFieldName(field)
|
||||
})
|
||||
|
||||
const updatedDefinition = {
|
||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||
customFieldValueId: field.customFieldValueId,
|
||||
customFieldId: fieldIdentifier,
|
||||
name: resolveFieldName(field),
|
||||
type: resolveFieldType(field),
|
||||
required: field.required ?? false,
|
||||
options: field.options ?? [],
|
||||
value: field.value ?? '',
|
||||
customField: field.customField ?? null,
|
||||
}
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||
} else {
|
||||
definitions.push(updatedDefinition)
|
||||
}
|
||||
e.customFields = definitions
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayedCustomFields,
|
||||
candidateCustomFields,
|
||||
updateCustomField,
|
||||
}
|
||||
}
|
||||
122
app/composables/useEntityDocuments.ts
Normal file
122
app/composables/useEntityDocuments.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Reactive document management for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Handles document CRUD operations, preview modal state, and lazy loading.
|
||||
* Display helpers (formatSize, shouldInlinePdf, etc.) are imported from
|
||||
* shared/utils/documentDisplayUtils.ts.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
export interface EntityDocumentsDeps {
|
||||
entity: () => any
|
||||
entityType: 'composant' | 'piece'
|
||||
}
|
||||
|
||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const { uploadDocuments, deleteDocument } = useDocuments()
|
||||
|
||||
const loadDocumentsFn = entityType === 'composant'
|
||||
? useDocuments().loadDocumentsByComponent
|
||||
: useDocuments().loadDocumentsByPiece
|
||||
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const documentsLoaded = ref(!!(entity().documents && entity().documents.length))
|
||||
|
||||
const documents = computed(() => entity().documents || [])
|
||||
|
||||
// Preview modal state
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
// Document watchers
|
||||
watch(
|
||||
() => entity().documents,
|
||||
(docs: any) => {
|
||||
documentsLoaded.value = !!(docs && docs.length)
|
||||
},
|
||||
)
|
||||
|
||||
// CRUD operations
|
||||
const refreshDocuments = async () => {
|
||||
const e = entity()
|
||||
if (!e?.id) return
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
e.documents = result.data || []
|
||||
documentsLoaded.value = true
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !entity()?.id) return
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
const e = entity()
|
||||
if (!files.length || !e?.id) return
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const contextKey = entityType === 'composant' ? 'composantId' : 'pieceId'
|
||||
const result: any = await uploadDocuments(
|
||||
{ files, context: { [contextKey]: e.id } } as any,
|
||||
{ updateStore: false } as any,
|
||||
)
|
||||
if (result.success) {
|
||||
const newDocs = result.data || []
|
||||
e.documents = [...newDocs, ...(e.documents || [])]
|
||||
documentsLoaded.value = true
|
||||
selectedFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string) => {
|
||||
if (!documentId) return
|
||||
const result: any = await deleteDocument(documentId, { updateStore: false } as any)
|
||||
if (result.success) {
|
||||
const e = entity()
|
||||
e.documents = (e.documents || []).filter((doc: any) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
documentsLoaded,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
refreshDocuments,
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
}
|
||||
}
|
||||
69
app/composables/useEntityHistory.ts
Normal file
69
app/composables/useEntityHistory.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Generic entity history composable.
|
||||
*
|
||||
* Replaces useComponentHistory, usePieceHistory, useProductHistory which were
|
||||
* 99% identical (only the API endpoint differed).
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type EntityHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type EntityHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: EntityHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||
composant: '/composants',
|
||||
piece: '/pieces',
|
||||
product: '/products',
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): EntityHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) return payload.items
|
||||
if (Array.isArray(payload?.member)) return payload.member
|
||||
if (Array.isArray(payload?.['hydra:member'])) return payload['hydra:member']
|
||||
return []
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') {
|
||||
const { get } = useApi()
|
||||
const basePath = ENTITY_ENDPOINTS[entityType]
|
||||
|
||||
const history = ref<EntityHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (entityId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`${basePath}/${entityId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l\'historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data)
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { history, loading, error, loadHistory }
|
||||
}
|
||||
103
app/composables/useEntityProductDisplay.ts
Normal file
103
app/composables/useEntityProductDisplay.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Reactive product display for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Resolves product information from entity.product, entity.__productDisplay,
|
||||
* or a selectedProduct ref, and produces display-ready computed properties.
|
||||
*/
|
||||
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
})
|
||||
|
||||
function buildProductDisplay(product: any) {
|
||||
if (!product || typeof product !== 'object') return null
|
||||
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? product.constructeurs
|
||||
.map((c: any) => c?.name)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim().length > 0)
|
||||
.join(', ')
|
||||
: product.supplierLabel || null
|
||||
|
||||
const priceValue = product.supplierPrice ?? product.price ?? product.priceLabel ?? product.priceDisplay ?? null
|
||||
let price: string | null = null
|
||||
if (priceValue !== null && priceValue !== undefined) {
|
||||
const parsed = Number(priceValue)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = currencyFormatter.format(parsed)
|
||||
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
|
||||
price = priceValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: product.name || product.label || product.reference || product.productName || null,
|
||||
reference: product.reference || null,
|
||||
category: product.typeProduct?.name || product.category || null,
|
||||
suppliers,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
export interface EntityProductDisplayDeps {
|
||||
entity: () => any
|
||||
selectedProduct?: Ref<any>
|
||||
}
|
||||
|
||||
export function useEntityProductDisplay(deps: EntityProductDisplayDeps) {
|
||||
const { entity, selectedProduct } = deps
|
||||
|
||||
const displayProduct = computed(() => {
|
||||
// Priority: selectedProduct (for PieceItem) → entity.product → entity.__productDisplay
|
||||
if (selectedProduct?.value) {
|
||||
const normalized = buildProductDisplay(selectedProduct.value)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
const explicit = entity().product || null
|
||||
const normalized = buildProductDisplay(explicit)
|
||||
if (normalized) return normalized
|
||||
const fallback = entity().__productDisplay
|
||||
if (fallback) {
|
||||
return {
|
||||
name: fallback.name || null,
|
||||
reference: fallback.reference || null,
|
||||
category: fallback.category || null,
|
||||
suppliers: fallback.suppliers || null,
|
||||
price: fallback.price || null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (displayProduct.value?.name) return displayProduct.value.name
|
||||
const e = entity()
|
||||
return e.product?.name || e.productName || e.productLabel || null
|
||||
})
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) return []
|
||||
const rows: { label: string; value: string }[] = []
|
||||
if (displayProduct.value.reference) rows.push({ label: 'Référence', value: displayProduct.value.reference })
|
||||
if (displayProduct.value.price) rows.push({ label: 'Prix indicatif', value: displayProduct.value.price })
|
||||
if (displayProduct.value.suppliers) rows.push({ label: 'Fournisseur(s)', value: displayProduct.value.suppliers })
|
||||
if (displayProduct.value.category) rows.push({ label: 'Catégorie', value: displayProduct.value.category })
|
||||
return rows
|
||||
})
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product = selectedProduct?.value || entity().product || null
|
||||
return Array.isArray(product?.documents) ? product.documents : []
|
||||
})
|
||||
|
||||
return {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
}
|
||||
}
|
||||
188
app/composables/useEntityTypes.ts
Normal file
188
app/composables/useEntityTypes.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Generic entity types composable.
|
||||
*
|
||||
* Replaces useComponentTypes, usePieceTypes, useProductTypes which were
|
||||
* 95%+ identical (only the category string and labels differed).
|
||||
*/
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import {
|
||||
listModelTypes,
|
||||
createModelType,
|
||||
updateModelType,
|
||||
deleteModelType,
|
||||
type ModelType,
|
||||
type ModelCategory,
|
||||
} from '~/services/modelTypes'
|
||||
|
||||
export interface EntityType extends ModelType {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface EntityTypePayload {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
structure?: any
|
||||
}
|
||||
|
||||
interface EntityTypeResult {
|
||||
success: boolean
|
||||
data?: EntityType | EntityType[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface EntityTypeConfig {
|
||||
category: ModelCategory
|
||||
label: string // e.g. 'composant', 'pièce', 'produit'
|
||||
}
|
||||
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
// Shared state per category (module-level singletons)
|
||||
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
|
||||
|
||||
function getOrCreateState(category: ModelCategory) {
|
||||
if (!stateByCategory[category]) {
|
||||
stateByCategory[category] = {
|
||||
types: ref<EntityType[]>([]),
|
||||
loading: ref(false),
|
||||
loaded: ref(false),
|
||||
}
|
||||
}
|
||||
return stateByCategory[category]
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the cache for a given category as stale.
|
||||
* Next call to `loadTypes()` (without `force`) will refetch from the API.
|
||||
* Safe to call from event handlers (no setup context required).
|
||||
*/
|
||||
export function invalidateEntityTypeCache(category: ModelCategory) {
|
||||
const state = stateByCategory[category]
|
||||
if (state) {
|
||||
state.loaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const { category, label } = config
|
||||
const { showSuccess, showError } = useToast()
|
||||
const state = getOrCreateState(category)
|
||||
|
||||
const normalizeItem = (item: ModelType): EntityType => ({
|
||||
...item,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
})
|
||||
|
||||
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
|
||||
if (!options.force && state.loaded.value) {
|
||||
return { success: true, data: state.types.value }
|
||||
}
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category,
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
state.types.value = data.items.map(normalizeItem)
|
||||
state.loaded.value = true
|
||||
return { success: true, data: state.types.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createType = async (payload: EntityTypePayload): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category,
|
||||
notes: payload.description ?? payload.notes ?? undefined,
|
||||
description: payload.description ?? undefined,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
state.types.value.push(normalized)
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateType = async (id: string, payload: EntityTypePayload): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description ?? undefined,
|
||||
notes: payload.notes ?? undefined,
|
||||
code: payload.code,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
const index = state.types.value.findIndex((t) => t.id === id)
|
||||
if (index !== -1) state.types.value[index] = normalized
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteType = async (id: string): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
state.types.value = state.types.value.filter((t) => t.id !== id)
|
||||
showSuccess(`Type de ${label} supprimé`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
types: state.types,
|
||||
loading: state.loading,
|
||||
loadTypes,
|
||||
createType,
|
||||
updateType,
|
||||
deleteType,
|
||||
}
|
||||
}
|
||||
458
app/composables/useMachineCreatePage.ts
Normal file
458
app/composables/useMachineCreatePage.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Machine creation page – orchestration composable.
|
||||
*
|
||||
* Consolidates entity lookup maps, option filters, label helpers,
|
||||
* template wrappers, and the finalization logic that were previously
|
||||
* inlined in pages/machines/new.vue.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||
import {
|
||||
useMachineCreatePreview,
|
||||
validateRequirementSelections as _validateRequirementSelections,
|
||||
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
|
||||
} from '~/composables/useMachineCreatePreview'
|
||||
import {
|
||||
getComponentMachineAssignments,
|
||||
getPieceMachineAssignments,
|
||||
getPieceComponentAssignments,
|
||||
formatAssignmentList,
|
||||
} from '~/shared/utils/assignmentUtils'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||
const { get } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
typeMachineId: '',
|
||||
reference: '',
|
||||
})
|
||||
|
||||
const selectedMachineType = computed(() => {
|
||||
if (!newMachine.typeMachineId) return null
|
||||
return (machineTypes as any).value.find((type: any) => type.id === newMachine.typeMachineId) || null
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentById = computed(() => {
|
||||
const map = new Map()
|
||||
;((composants as any).value || []).forEach((component: any) => {
|
||||
if (component?.id) map.set(component.id, component)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceById = computed(() => {
|
||||
const map = new Map()
|
||||
;((pieces as any).value || []).forEach((piece: any) => {
|
||||
if (piece?.id) map.set(piece.id, piece)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentInventory = computed(() => (composants as any).value || [])
|
||||
const pieceInventory = computed(() => (pieces as any).value || [])
|
||||
const productInventory = computed(() => (products as any).value || [])
|
||||
|
||||
const productById = computed(() => {
|
||||
const map = new Map()
|
||||
;(productInventory.value || []).forEach((product: any) => {
|
||||
if (product?.id) map.set(product.id, product)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity finders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findComponentById = (id: string) => {
|
||||
if (!id) return null
|
||||
return componentById.value.get(id) || null
|
||||
}
|
||||
|
||||
const findPieceById = (id: string): any => {
|
||||
if (!id) return null
|
||||
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
||||
}
|
||||
|
||||
const findProductById = (id: string) => {
|
||||
if (!id) return null
|
||||
return productById.value.get(id) || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection state (from composable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
pieceOptionsByKey,
|
||||
pieceLoadingByKey,
|
||||
selectedPieceIds,
|
||||
getPieceKey,
|
||||
findPieceInCachedOptions,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct: _setProductRequirementProduct,
|
||||
clearRequirementSelections,
|
||||
initializeRequirementSelections,
|
||||
} = useMachineCreateSelections({
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
pieces: pieces as any,
|
||||
get: get as any,
|
||||
toast,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview / validation (from composable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({
|
||||
newMachine,
|
||||
sites: sites as any,
|
||||
selectedMachineType,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveComponentRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||
_resolveComponentRequirementTypeLabel(requirement, entry, findComponentById)
|
||||
|
||||
const resolvePieceRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||
_resolvePieceRequirementTypeLabel(requirement, entry, findPieceById)
|
||||
|
||||
const setProductRequirementProduct = (requirement: any, index: number, productId: string) =>
|
||||
_setProductRequirementProduct(requirement, index, productId, findProductById)
|
||||
|
||||
const validateRequirementSelections = (type: any) =>
|
||||
_validateRequirementSelections(type, {
|
||||
newMachine,
|
||||
sites: sites as any,
|
||||
selectedMachineType,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine type helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineTypeLabel = (type: any) => {
|
||||
if (!type) return ''
|
||||
return type.name || 'Type de machine'
|
||||
}
|
||||
|
||||
const machineTypeDescription = (type: any) => {
|
||||
if (!type) return ''
|
||||
const parts: string[] = []
|
||||
if (type.category) parts.push(`Catégorie : ${type.category}`)
|
||||
const componentCount = type.componentRequirements?.length ?? 0
|
||||
const pieceCount = type.pieceRequirements?.length ?? 0
|
||||
const productCount = type.productRequirements?.length ?? 0
|
||||
parts.push(
|
||||
`${componentCount} composant(s)`,
|
||||
`${pieceCount} pièce(s)`,
|
||||
`${productCount} produit(s)`,
|
||||
)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getComponentOptions = (requirement: any, currentEntry: any) => {
|
||||
const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null
|
||||
return componentInventory.value.filter((component: any) => {
|
||||
if (!component?.id) return false
|
||||
if (requirementTypeId && component.typeComposantId !== requirementTypeId) {
|
||||
return currentEntry?.composantId === component.id
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const getPieceOptions = (requirement: any, currentEntry: any, entryIndex: number) => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
const cached = pieceOptionsByKey.value[key]
|
||||
if (cached) return cached
|
||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||
const usedIds = new Set(
|
||||
selectedPieceIds.value.filter((id: any) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
||||
)
|
||||
return pieceInventory.value.filter((piece: any) => {
|
||||
if (requirementTypeId && piece.typePieceId !== requirementTypeId) return false
|
||||
if (!piece.id) return false
|
||||
if (currentEntry?.pieceId === piece.id) return true
|
||||
return !usedIds.has(piece.id)
|
||||
})
|
||||
}
|
||||
|
||||
const getProductOptions = (requirement: any) => {
|
||||
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
|
||||
return productInventory.value.filter((product: any) => {
|
||||
if (!product?.id) return false
|
||||
if (!requirementTypeId) return true
|
||||
const productTypeId = product.typeProductId || product.typeProduct?.id || null
|
||||
return productTypeId === requirementTypeId
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option label / description helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentOptionLabel = (component: any) => component?.name || 'Composant'
|
||||
|
||||
const componentOptionDescription = (component: any) => {
|
||||
if (!component) return ''
|
||||
const parts: string[] = []
|
||||
if (component.reference) parts.push(`Réf. ${component.reference}`)
|
||||
const constructeurName = component.constructeur?.name || component.constructeurName
|
||||
if (constructeurName) parts.push(constructeurName)
|
||||
const machineAssignments = getComponentMachineAssignments(component)
|
||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||
const productTypeName = component.product?.typeProduct?.name
|
||||
const productLabel = component.product?.name || component.product?.reference
|
||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const pieceOptionLabel = (piece: any) => piece?.name || 'Pièce'
|
||||
|
||||
const pieceOptionDescription = (piece: any) => {
|
||||
if (!piece) return ''
|
||||
const parts: string[] = []
|
||||
if (piece.reference) parts.push(`Réf. ${piece.reference}`)
|
||||
const constructeurName = piece.constructeur?.name || piece.constructeurName
|
||||
if (constructeurName) parts.push(constructeurName)
|
||||
const machineAssignments = getPieceMachineAssignments(piece)
|
||||
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||
const componentAssignments = getPieceComponentAssignments(piece)
|
||||
if (componentAssignments.length) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
||||
const productTypeName = piece.product?.typeProduct?.name
|
||||
const productLabel = piece.product?.name || piece.product?.reference
|
||||
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
const type = selectedMachineType.value
|
||||
if (!type) {
|
||||
toast.showError('Merci de sélectionner un type de machine')
|
||||
return
|
||||
}
|
||||
if (!canCreateMachine.value) {
|
||||
toast.showError('Compléter les informations obligatoires avant de créer la machine')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const baseMachineData = {
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId,
|
||||
reference: newMachine.reference,
|
||||
typeMachineId: type.id,
|
||||
}
|
||||
|
||||
const hasRequirements =
|
||||
(type.componentRequirements?.length || 0) > 0 ||
|
||||
(type.pieceRequirements?.length || 0) > 0 ||
|
||||
(type.productRequirements?.length || 0) > 0
|
||||
|
||||
let componentLinks: any[] = []
|
||||
let pieceLinks: any[] = []
|
||||
let productLinks: any[] = []
|
||||
|
||||
if (hasRequirements) {
|
||||
const validationResult = validateRequirementSelections(type)
|
||||
if (!validationResult.valid) {
|
||||
toast.showError(validationResult.error as string)
|
||||
return
|
||||
}
|
||||
componentLinks = validationResult.componentLinks as any[]
|
||||
pieceLinks = validationResult.pieceLinks as any[]
|
||||
productLinks = validationResult.productLinks as any[]
|
||||
}
|
||||
|
||||
const result: any = hasRequirements
|
||||
? await createMachine(baseMachineData as any)
|
||||
: await createMachineFromType(baseMachineData as any, type)
|
||||
|
||||
if (result.success) {
|
||||
if (hasRequirements && result.data?.id) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
} as any)
|
||||
if (!skeletonResult.success) {
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||
return
|
||||
}
|
||||
}
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.typeMachineId = ''
|
||||
newMachine.reference = ''
|
||||
clearRequirementSelections()
|
||||
await navigateTo('/machines')
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchers & lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
watch(
|
||||
() => newMachine.typeMachineId,
|
||||
(typeId) => {
|
||||
clearRequirementSelections()
|
||||
if (!typeId) return
|
||||
const type = (machineTypes as any).value.find((item: any) => item.id === typeId)
|
||||
if (!type) return
|
||||
initializeRequirementSelections(type)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachineTypes(),
|
||||
loadComposants(),
|
||||
loadPieces(),
|
||||
loadProducts(),
|
||||
])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// State
|
||||
submitting,
|
||||
newMachine,
|
||||
sites,
|
||||
machineTypes,
|
||||
machineTypesLoading,
|
||||
composantsLoading,
|
||||
piecesLoading,
|
||||
productsLoading,
|
||||
selectedMachineType,
|
||||
|
||||
// Selection state
|
||||
pieceLoadingByKey,
|
||||
getPieceKey,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct,
|
||||
|
||||
// Preview
|
||||
machinePreview,
|
||||
blockingPreviewIssues,
|
||||
canCreateMachine,
|
||||
|
||||
// Entity finders
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
findProductById,
|
||||
|
||||
// Options
|
||||
getComponentOptions,
|
||||
getPieceOptions,
|
||||
getProductOptions,
|
||||
|
||||
// Label helpers
|
||||
machineTypeLabel,
|
||||
machineTypeDescription,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
|
||||
// Type label resolvers
|
||||
resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
}
|
||||
}
|
||||
572
app/composables/useMachineCreatePreview.ts
Normal file
572
app/composables/useMachineCreatePreview.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Machine creation – preview computation and validation.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Builds the live preview model
|
||||
* and validates requirement selections before machine creation.
|
||||
*/
|
||||
|
||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
getComponentMachineAssignments,
|
||||
getPieceMachineAssignments,
|
||||
getPieceComponentAssignments,
|
||||
formatAssignmentList,
|
||||
} from '~/shared/utils/assignmentUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreatePreviewDeps {
|
||||
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
|
||||
sites: Ref<AnyRecord[]>
|
||||
selectedMachineType: ComputedRef<AnyRecord | null>
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
findProductById: (id: string) => AnyRecord | null
|
||||
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product type ID extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
|
||||
if (!component || typeof component !== 'object') return null
|
||||
return (
|
||||
(component.product as AnyRecord)?.typeProductId ||
|
||||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
component.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
|
||||
if (!piece || typeof piece !== 'object') return null
|
||||
return (
|
||||
(piece.product as AnyRecord)?.typeProductId ||
|
||||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
|
||||
piece.productTypeId ||
|
||||
null
|
||||
) as string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStatusBadgeClass = (status: string): string => {
|
||||
if (status === 'ready') return 'badge-success'
|
||||
if (status === 'warning') return 'badge-warning'
|
||||
return 'badge-error'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll / issue click helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
|
||||
|
||||
export const scrollToAnchor = (anchor: string): void => {
|
||||
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
const target = document.getElementById(anchor)
|
||||
if (!target) return
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
highlightClasses.forEach((cls) => target.classList.add(cls))
|
||||
window.setTimeout(() => {
|
||||
highlightClasses.forEach((cls) => target.classList.remove(cls))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
export const handleIssueClick = (issue: AnyRecord): void => {
|
||||
if (!issue?.anchor) return
|
||||
scrollToAnchor(issue.anchor as string)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveComponentRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findComponentById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.composantId) {
|
||||
const component = findComponentById(entry.composantId as string)
|
||||
if ((component?.typeComposant as AnyRecord)?.name) {
|
||||
return (component!.typeComposant as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
export const resolvePieceRequirementTypeLabel = (
|
||||
requirement: AnyRecord,
|
||||
entry: AnyRecord,
|
||||
findPieceById: (id: string) => AnyRecord | null,
|
||||
): string => {
|
||||
if (entry?.pieceId) {
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
if ((piece?.typePiece as AnyRecord)?.name) {
|
||||
return (piece!.typePiece as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product requirement stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeProductUsageFromSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
increment(getProductTypeIdFromComponent(component))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
increment(getProductTypeIdFromPiece(piece))
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
entries.forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
const typeProductId = (
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
) as string | null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const buildProductRequirementStats = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): { stats: AnyRecord[]; usage: Map<string, number> } => {
|
||||
const usage = computeProductUsageFromSelections(type, deps)
|
||||
|
||||
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const typeProductId = (
|
||||
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
|
||||
) as string | null
|
||||
|
||||
const label = (
|
||||
(requirement.label as string)?.trim() ||
|
||||
(requirement.typeProduct as AnyRecord)?.name ||
|
||||
(requirement.typeProduct as AnyRecord)?.code ||
|
||||
'Produit requis'
|
||||
) as string
|
||||
|
||||
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
|
||||
|
||||
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = rawEntries.map((entry, index) => {
|
||||
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
|
||||
const subtitleParts: string[] = []
|
||||
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
|
||||
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
|
||||
const price = Number(product.supplierPrice)
|
||||
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
|
||||
const cLabel = (product!.constructeurs as AnyRecord[])
|
||||
.map((c) => c?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
|
||||
}
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: product ? 'complete' : 'pending',
|
||||
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
|
||||
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
|
||||
}
|
||||
})
|
||||
|
||||
const issues: AnyRecord[] = []
|
||||
if (count < min) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
issues.push({
|
||||
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
|
||||
issues.push({
|
||||
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
|
||||
kind: 'error',
|
||||
anchor: `product-group-${requirement.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const total = normalizedEntries.length
|
||||
const status = issues.some((i) => i.kind === 'error')
|
||||
? 'error'
|
||||
: issues.some((i) => i.kind === 'warning')
|
||||
? 'warning'
|
||||
: 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
requirement,
|
||||
label,
|
||||
typeName,
|
||||
count,
|
||||
min,
|
||||
max,
|
||||
completed,
|
||||
total,
|
||||
entries: normalizedEntries,
|
||||
issues,
|
||||
allowNewModels: requirement.allowNewModels ?? true,
|
||||
status,
|
||||
}
|
||||
})
|
||||
|
||||
return { stats, usage }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const validateRequirementSelections = (
|
||||
type: AnyRecord,
|
||||
deps: MachineCreatePreviewDeps,
|
||||
): AnyRecord => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.composantId) {
|
||||
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
|
||||
return
|
||||
}
|
||||
const component = deps.findComponentById(entry.composantId as string)
|
||||
if (!component) {
|
||||
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
|
||||
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.pieceId) {
|
||||
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
|
||||
return
|
||||
}
|
||||
const piece = deps.findPieceById(entry.pieceId as string)
|
||||
if (!piece) {
|
||||
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
|
||||
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
|
||||
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const { stats: productStats } = buildProductRequirementStats(type, deps)
|
||||
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
|
||||
const entries = deps.getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = deps.findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
|
||||
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
|
||||
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
productStats.forEach((stat) => {
|
||||
((stat.issues || []) as AnyRecord[])
|
||||
.filter((issue) => issue.kind === 'error')
|
||||
.forEach((issue) => errors.push(issue.message as string))
|
||||
})
|
||||
|
||||
if (errors.length > 0) return { valid: false, error: errors[0] }
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main preview composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
|
||||
const machinePreview = computed(() => {
|
||||
const type = deps.selectedMachineType.value
|
||||
if (!type) return null
|
||||
|
||||
const trimmedName = (deps.newMachine.name || '').trim()
|
||||
const currentSite = deps.newMachine.siteId
|
||||
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
|
||||
: null
|
||||
const trimmedReference = (deps.newMachine.reference || '').trim()
|
||||
|
||||
const baseFields = [
|
||||
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
|
||||
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
|
||||
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
|
||||
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
|
||||
]
|
||||
|
||||
const baseIssues: AnyRecord[] = []
|
||||
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
|
||||
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
|
||||
|
||||
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
|
||||
|
||||
// Component groups
|
||||
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getComponentRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
|
||||
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
|
||||
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
|
||||
const assignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.composantId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
assignmentLabel,
|
||||
assignments: machineAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
|
||||
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Piece groups
|
||||
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
|
||||
const entries = deps.getPieceRequirementEntries(requirement.id as string)
|
||||
const normalizedEntries = entries.map((entry, index) => {
|
||||
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
|
||||
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
|
||||
const subtitleParts: string[] = []
|
||||
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
|
||||
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
|
||||
if (constructeurName) subtitleParts.push(constructeurName as string)
|
||||
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
|
||||
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
|
||||
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
|
||||
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
|
||||
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
|
||||
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
|
||||
return {
|
||||
key: `${requirement.id}-${index}`,
|
||||
status: entry.pieceId ? 'complete' : 'pending',
|
||||
title: displayName,
|
||||
subtitle: subtitleParts.join(' • ') || null,
|
||||
machineAssignmentLabel, componentAssignmentLabel,
|
||||
machineAssignments, componentAssignments,
|
||||
}
|
||||
})
|
||||
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
|
||||
const issues: AnyRecord[] = []
|
||||
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||
|
||||
const hasErrors = issues.some((i) => i.kind === 'error')
|
||||
const hasWarnings = completed < entries.length
|
||||
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
id: requirement.id,
|
||||
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
|
||||
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
|
||||
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
|
||||
}
|
||||
})
|
||||
|
||||
// Product groups
|
||||
const { stats: productGroups } = buildProductRequirementStats(type, deps)
|
||||
|
||||
// Aggregate
|
||||
const aggregatedIssues = [
|
||||
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
|
||||
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
|
||||
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
|
||||
]
|
||||
|
||||
const statuses = [
|
||||
baseStatus,
|
||||
...componentGroups.map((g) => g.status),
|
||||
...pieceGroups.map((g) => g.status),
|
||||
...productGroups.map((g: AnyRecord) => g.status as string),
|
||||
]
|
||||
|
||||
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
|
||||
|
||||
return {
|
||||
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
|
||||
componentGroups,
|
||||
pieceGroups,
|
||||
productGroups,
|
||||
type: {
|
||||
name: type.name,
|
||||
category: type.category || null,
|
||||
hasStructuredDefinition:
|
||||
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
|
||||
((type.productRequirements as unknown[])?.length || 0) > 0,
|
||||
},
|
||||
status: overallStatus,
|
||||
ready: overallStatus === 'ready',
|
||||
issues: aggregatedIssues,
|
||||
}
|
||||
})
|
||||
|
||||
const blockingPreviewIssues = computed(() => {
|
||||
if (!machinePreview.value) return []
|
||||
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
|
||||
})
|
||||
|
||||
const canCreateMachine = computed(() => {
|
||||
if (!machinePreview.value) return false
|
||||
return blockingPreviewIssues.value.length === 0
|
||||
})
|
||||
|
||||
return {
|
||||
machinePreview,
|
||||
blockingPreviewIssues,
|
||||
canCreateMachine,
|
||||
}
|
||||
}
|
||||
365
app/composables/useMachineCreateSelections.ts
Normal file
365
app/composables/useMachineCreateSelections.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Machine creation – requirement selection state management.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue. Manages the reactive selection state
|
||||
* for component / piece / product requirements when creating a new machine.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineCreateSelectionsDeps {
|
||||
findComponentById: (id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
pieces: { value: AnyRecord[] }
|
||||
get: (url: string) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactive state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
|
||||
const pieceLoadingByKey = ref<Record<string, boolean>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Piece option caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
|
||||
`${requirement?.id || 'req'}:${entryIndex}`
|
||||
|
||||
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
|
||||
if (!id) return null
|
||||
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||
for (const bucket of buckets) {
|
||||
if (!Array.isArray(bucket)) continue
|
||||
const found = bucket.find((piece) => piece?.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const cachePieceIfMissing = (piece: AnyRecord): void => {
|
||||
if (!piece?.id) return
|
||||
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
|
||||
pieces.value = [...current, piece]
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (
|
||||
requirement: AnyRecord,
|
||||
entryIndex: number,
|
||||
term = '',
|
||||
): Promise<void> => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
if (pieceLoadingByKey.value[key]) return
|
||||
|
||||
const requirementTypeId =
|
||||
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term && term.trim()) params.set('name', term.trim())
|
||||
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByKey.value = {
|
||||
...pieceOptionsByKey.value,
|
||||
[key]: extractCollection(result.data) as AnyRecord[],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry getters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
composantId: source?.composantId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
pieceId: source?.pieceId || null,
|
||||
definition: {},
|
||||
})
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||
typeProductId:
|
||||
source?.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null,
|
||||
productId: source?.productId || null,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selected piece IDs (for dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const selectedPieceIds = computed(() => {
|
||||
const ids: string[] = []
|
||||
Object.values(pieceRequirementSelections).forEach((entries) => {
|
||||
;(entries || []).forEach((entry) => {
|
||||
if (entry?.pieceId) ids.push(entry.pieceId as string)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount ?? null) as number | null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection setters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const setComponentRequirementComponent = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
componentId: string,
|
||||
): void => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.composantId = componentId || null
|
||||
if (componentId) {
|
||||
const component = findComponentById(componentId)
|
||||
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
|
||||
} else {
|
||||
entry.typeComposantId = requirement?.typeComposantId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setPieceRequirementPiece = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
pieceId: string,
|
||||
): void => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
entry.pieceId = pieceId || null
|
||||
if (pieceId) {
|
||||
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||
if (piece) cachePieceIfMissing(piece as AnyRecord)
|
||||
} else {
|
||||
entry.typePieceId = requirement?.typePieceId || null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (
|
||||
requirement: AnyRecord,
|
||||
index: number,
|
||||
productId: string,
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
): void => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
product?.typeProductId ||
|
||||
(product?.typeProduct as AnyRecord)?.id ||
|
||||
entry.typeProductId ||
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
} else {
|
||||
entry.typeProductId =
|
||||
requirement?.typeProductId ||
|
||||
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clearRequirementSelections = (): void => {
|
||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||
delete componentRequirementSelections[key]
|
||||
})
|
||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||
delete pieceRequirementSelections[key]
|
||||
})
|
||||
Object.keys(productRequirementSelections).forEach((key) => {
|
||||
delete productRequirementSelections[key]
|
||||
})
|
||||
}
|
||||
|
||||
const initializeRequirementSelections = (type: AnyRecord): void => {
|
||||
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
|
||||
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
|
||||
const productRequirements = (type.productRequirements || []) as AnyRecord[]
|
||||
|
||||
componentRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
componentRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createComponentSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
componentRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
pieceRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
const entries = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createPieceSelectionEntry(requirement),
|
||||
)
|
||||
pieceRequirementSelections[requirement.id as string] = entries
|
||||
entries.forEach((_: unknown, index: number) => {
|
||||
fetchPieceOptions(requirement, index).catch(() => {})
|
||||
})
|
||||
} else {
|
||||
pieceRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
|
||||
productRequirements.forEach((requirement) => {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
productRequirementSelections[requirement.id as string] = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createProductSelectionEntry(requirement),
|
||||
)
|
||||
} else {
|
||||
productRequirementSelections[requirement.id as string] = []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
pieceOptionsByKey,
|
||||
pieceLoadingByKey,
|
||||
selectedPieceIds,
|
||||
getPieceKey,
|
||||
findPieceInCachedOptions,
|
||||
fetchPieceOptions,
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setComponentRequirementComponent,
|
||||
setPieceRequirementPiece,
|
||||
setProductRequirementProduct,
|
||||
clearRequirementSelections,
|
||||
initializeRequirementSelections,
|
||||
}
|
||||
}
|
||||
1410
app/composables/useMachineDetailData.ts
Normal file
1410
app/composables/useMachineDetailData.ts
Normal file
File diff suppressed because it is too large
Load Diff
298
app/composables/useMachineHierarchy.ts
Normal file
298
app/composables/useMachineHierarchy.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Builds a component/piece hierarchy tree from flat machine link arrays.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
|
||||
*/
|
||||
|
||||
import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
|
||||
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const collectConstructeurs = (
|
||||
allConstructeurs: AnyRecord[],
|
||||
...sources: unknown[]
|
||||
): AnyRecord[] => {
|
||||
const ids = uniqueConstructeurIds(...sources)
|
||||
if (!ids.length) return []
|
||||
|
||||
const pools = sources
|
||||
.flatMap((source) => {
|
||||
if (Array.isArray(source)) return [source]
|
||||
if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]]
|
||||
return []
|
||||
})
|
||||
.filter(Boolean) as AnyRecord[][]
|
||||
|
||||
// ConstructeurSummary and AnyRecord are structurally compatible at runtime
|
||||
const allPools = [...pools, allConstructeurs] as unknown as Array<ConstructeurSummary[]>
|
||||
return resolveConstructeurs(ids, ...allPools) as unknown as AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link array resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => {
|
||||
if (!source || typeof source !== 'object') return null
|
||||
for (const key of keys) {
|
||||
const value = (source as AnyRecord)[key]
|
||||
if (Array.isArray(value)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge trees (for incremental updates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
if (!updates.length) {
|
||||
return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]),
|
||||
)
|
||||
const merged = existing.map((piece) => {
|
||||
const update = updateMap.get(piece.id)
|
||||
if (!update) return piece
|
||||
return { ...piece, ...update, customFields: update.customFields ?? piece.customFields }
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((piece) => piece.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((component) => ({
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
}))
|
||||
}
|
||||
if (!updates.length) return existing
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((component) => [
|
||||
component.id,
|
||||
{
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
},
|
||||
]),
|
||||
)
|
||||
const merged: AnyRecord[] = existing.map((component) => {
|
||||
const update = updateMap.get(component.id)
|
||||
if (!update) {
|
||||
return {
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []),
|
||||
subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...component,
|
||||
...update,
|
||||
customFields: update.customFields ?? component.customFields,
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]),
|
||||
subComponents: mergeComponentTrees(
|
||||
(component.subComponents || []) as AnyRecord[],
|
||||
(update.subComponents || []) as AnyRecord[],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((component) => component.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build hierarchy from links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildMachineHierarchyFromLinks = (
|
||||
componentLinks: AnyRecord[] = [],
|
||||
pieceLinks: AnyRecord[] = [],
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
allConstructeurs: AnyRecord[] = [],
|
||||
): { components: AnyRecord[]; machinePieces: AnyRecord[] } => {
|
||||
const normalizeComponentLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId)
|
||||
|
||||
const normalizePieceLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId)
|
||||
|
||||
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
|
||||
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||
|
||||
const overrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const basePiece: AnyRecord = {
|
||||
...appliedPiece,
|
||||
id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`,
|
||||
pieceId,
|
||||
name: overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce',
|
||||
reference: overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null,
|
||||
prix: overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null,
|
||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
||||
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
|
||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||
typeMachinePieceRequirement: requirement,
|
||||
typeMachinePieceRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
overrides,
|
||||
originalPiece,
|
||||
machinePieceLink: link,
|
||||
machinePieceLinkId,
|
||||
linkId: machinePieceLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId),
|
||||
parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId),
|
||||
parentComponentName,
|
||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
skeletonOnly: !pieceId,
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId)
|
||||
|
||||
return {
|
||||
...basePiece,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || basePiece.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null,
|
||||
productId: resolvedProductId || appliedPiece.productId || null,
|
||||
product: resolvedProduct || appliedPiece.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
|
||||
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||
|
||||
const compOverrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
||||
|
||||
const pieces = Array.isArray(link.pieceLinks)
|
||||
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
const subComponents = Array.isArray(link.childLinks)
|
||||
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const baseComponent: AnyRecord = {
|
||||
...appliedComponent,
|
||||
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
|
||||
composantId,
|
||||
name: componentName,
|
||||
reference: compOverrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null,
|
||||
prix: compOverrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null,
|
||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
||||
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
|
||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||
typeMachineComponentRequirement: requirement,
|
||||
typeMachineComponentRequirementId: requirement?.id || null,
|
||||
requirementId: requirement?.id || null,
|
||||
overrides: compOverrides,
|
||||
machineComponentLinkOverrides: compOverrides,
|
||||
definitionOverrides: compOverrides,
|
||||
originalComposant: originalComponent,
|
||||
machineComponentLink: link,
|
||||
machineComponentLinkId,
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
sousComposants: subComponents,
|
||||
skeletonOnly: !composantId,
|
||||
}
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
||||
|
||||
return {
|
||||
...baseComponent,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || baseComponent.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null,
|
||||
productId: resolvedProductId || appliedComponent.productId || null,
|
||||
product: resolvedProduct || appliedComponent.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const rootComponents = (Array.isArray(componentLinks) ? componentLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map(createComponentNode)
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map((link) => createPieceNode(link, null))
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
return { components: rootComponents, machinePieces }
|
||||
}
|
||||
163
app/composables/useMachinePrint.ts
Normal file
163
app/composables/useMachinePrint.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Machine print selection and execution logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue.
|
||||
*/
|
||||
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface PrintSelection {
|
||||
machine: { info: boolean; customFields: boolean; documents: boolean }
|
||||
components: Record<string, boolean>
|
||||
pieces: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function useMachinePrint() {
|
||||
const printModalOpen = ref(false)
|
||||
const printSelection = reactive<PrintSelection>({
|
||||
machine: { info: true, customFields: true, documents: true },
|
||||
components: {},
|
||||
pieces: {},
|
||||
})
|
||||
|
||||
const ensurePrintSelectionEntries = (
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
printSelection.machine.info ??= true
|
||||
printSelection.machine.customFields ??= true
|
||||
printSelection.machine.documents ??= true
|
||||
|
||||
const ensureComponent = (component: AnyRecord) => {
|
||||
if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) {
|
||||
printSelection.components[component.id as string] = true
|
||||
}
|
||||
;((component.pieces || []) as AnyRecord[]).forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent)
|
||||
}
|
||||
|
||||
components.forEach(ensureComponent)
|
||||
machinePieces.forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setAllPrintSelection = (
|
||||
value: boolean,
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printSelection.machine.info = value
|
||||
printSelection.machine.customFields = value
|
||||
printSelection.machine.documents = value
|
||||
Object.keys(printSelection.components).forEach((key) => {
|
||||
printSelection.components[key] = value
|
||||
})
|
||||
Object.keys(printSelection.pieces).forEach((key) => {
|
||||
printSelection.pieces[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printModalOpen.value = true
|
||||
}
|
||||
|
||||
const closePrintModal = () => {
|
||||
printModalOpen.value = false
|
||||
}
|
||||
|
||||
const printMachine = (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
currentSelection: PrintSelection = printSelection,
|
||||
) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// machineReport.js has no type annotations; cast to avoid inferred never[] params
|
||||
const context = (buildMachinePrintContext as unknown as (config: Record<string, unknown>) => Record<string, unknown>)({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machinePieces,
|
||||
components,
|
||||
selection: currentSelection,
|
||||
})
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join('')
|
||||
|
||||
const htmlContent = buildMachinePrintHtml(context, styles)
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.position = 'fixed'
|
||||
iframe.style.right = '0'
|
||||
iframe.style.bottom = '0'
|
||||
iframe.style.width = '0'
|
||||
iframe.style.height = '0'
|
||||
iframe.style.border = '0'
|
||||
iframe.setAttribute('title', 'print-frame')
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const iframeWindow = iframe.contentWindow
|
||||
const iframeDocument = iframe.contentDocument || iframeWindow?.document
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
iframeDocument.open()
|
||||
iframeDocument.write(htmlContent)
|
||||
iframeDocument.close()
|
||||
|
||||
const triggerPrint = () => {
|
||||
iframeWindow.focus()
|
||||
iframeWindow.print()
|
||||
setTimeout(() => {
|
||||
iframe.remove()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (iframeDocument.readyState === 'complete') {
|
||||
setTimeout(triggerPrint, 50)
|
||||
} else {
|
||||
iframe.onload = () => setTimeout(triggerPrint, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrintConfirm = async (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
) => {
|
||||
closePrintModal()
|
||||
await nextTick()
|
||||
printMachine(machine, machineName, machineReference, machinePieces, components, printSelection)
|
||||
}
|
||||
|
||||
return {
|
||||
printModalOpen,
|
||||
printSelection,
|
||||
ensurePrintSelectionEntries,
|
||||
setAllPrintSelection,
|
||||
openPrintModal,
|
||||
closePrintModal,
|
||||
printMachine,
|
||||
handlePrintConfirm,
|
||||
}
|
||||
}
|
||||
838
app/composables/useMachineSkeletonEditor.ts
Normal file
838
app/composables/useMachineSkeletonEditor.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* Machine skeleton editor — selection state, validation & save logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue (F1.1).
|
||||
* Manages the reactive selection state for component / piece / product
|
||||
* skeleton requirements, validation, and reconfiguration API calls.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import {
|
||||
resolveIdentifier,
|
||||
extractParentLinkIdentifiers,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineSkeletonEditorDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
components: Ref<AnyRecord[]>
|
||||
pieces: Ref<AnyRecord[]>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
machineType: ComputedRef<AnyRecord | null>
|
||||
machineHasSkeletonRequirements: ComputedRef<boolean>
|
||||
componentRequirements: ComputedRef<AnyRecord[]>
|
||||
pieceRequirements: ComputedRef<AnyRecord[]>
|
||||
productRequirements: ComputedRef<AnyRecord[]>
|
||||
componentTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
pieceTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
productInventory: ComputedRef<AnyRecord[]>
|
||||
flattenedComponents: ComputedRef<AnyRecord[]>
|
||||
machinePieces: ComputedRef<AnyRecord[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
|
||||
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
|
||||
applyMachineLinks: (source: AnyRecord) => boolean
|
||||
collapseAllComponents: () => void
|
||||
initMachineFields: () => void
|
||||
collectPiecesForSkeleton: () => AnyRecord[]
|
||||
constructeurs: Ref<AnyRecord[]>
|
||||
loadProducts: () => Promise<void>
|
||||
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
|
||||
const {
|
||||
machine,
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
machineType,
|
||||
machineHasSkeletonRequirements,
|
||||
productRequirements,
|
||||
componentTypeLabelMap,
|
||||
pieceTypeLabelMap,
|
||||
productInventory,
|
||||
flattenedComponents,
|
||||
machineDocumentsLoaded,
|
||||
findProductById,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
applyMachineLinks,
|
||||
collapseAllComponents,
|
||||
initMachineFields,
|
||||
collectPiecesForSkeleton,
|
||||
loadProducts,
|
||||
reconfigureMachineSkeleton,
|
||||
toast,
|
||||
} = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeMachineView = ref<'details' | 'skeleton'>('details')
|
||||
const isDetailsView = computed(() => activeMachineView.value === 'details')
|
||||
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const skeletonEditor = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (value: unknown): boolean =>
|
||||
Object.prototype.toString.call(value) === '[object Object]'
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId =
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
if (typeId) {
|
||||
const typeMatch = productRequirements.value.find(
|
||||
(req: AnyRecord) =>
|
||||
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
|
||||
)
|
||||
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
|
||||
return (typeMatch.typeProduct as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
|
||||
}
|
||||
|
||||
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
|
||||
const requirementTypeId =
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return (productInventory.value as AnyRecord[]).filter((product) => {
|
||||
if (!product?.id) return false
|
||||
if (!requirementTypeId) return true
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return productTypeId === requirementTypeId
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineComponentLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
|
||||
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
typeComposantId:
|
||||
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
||||
source?.typeComposantId ||
|
||||
(source?.typeComposant as AnyRecord)?.id ||
|
||||
requirement?.typeComposantId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machinePieceLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
|
||||
pieceId: resolveIdentifier(source?.pieceId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
|
||||
typePieceId:
|
||||
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
||||
source?.typePieceId ||
|
||||
(source?.typePiece as AnyRecord)?.id ||
|
||||
requirement?.typePieceId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineProductLink as AnyRecord) || source || null
|
||||
|
||||
return {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
|
||||
productId: resolveIdentifier(source?.productId, link?.productId),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
|
||||
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resetSkeletonRequirementSelections = () => {
|
||||
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
|
||||
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
|
||||
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
|
||||
}
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeComposantId = value || null
|
||||
}
|
||||
|
||||
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typePieceId = value || null
|
||||
}
|
||||
|
||||
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
(product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeProductId = value || entry.typeProductId || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initializeSkeletonRequirementSelections = async () => {
|
||||
skeletonEditor.loading = true
|
||||
try {
|
||||
resetSkeletonRequirementSelections()
|
||||
const type = machineType.value as AnyRecord
|
||||
if (!type) return
|
||||
|
||||
try {
|
||||
await loadProducts()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des produits pour le squelette:', error)
|
||||
}
|
||||
|
||||
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = flattenedComponents.value.filter(
|
||||
(c) => c.typeMachineComponentRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
|
||||
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const allPieces = collectPiecesForSkeleton()
|
||||
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = allPieces.filter(
|
||||
(p) => p.typeMachinePieceRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
|
||||
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const existingProductLinks = Array.isArray(machineProductLinks.value)
|
||||
? machineProductLinks.value
|
||||
: Array.isArray(machine.value?.productLinks)
|
||||
? (machine.value.productLinks as AnyRecord[])
|
||||
: []
|
||||
|
||||
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const matches = existingProductLinks.filter((link) => {
|
||||
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
|
||||
return reqId === requirement.id
|
||||
})
|
||||
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
|
||||
if (entries.length) productRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
} finally {
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor open/close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openSkeletonEditor = async () => {
|
||||
if (skeletonEditor.open) return
|
||||
skeletonEditor.open = true
|
||||
await initializeSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const closeSkeletonEditor = () => {
|
||||
if (!skeletonEditor.open) return
|
||||
if (skeletonEditor.submitting) return
|
||||
skeletonEditor.open = false
|
||||
skeletonEditor.loading = false
|
||||
skeletonEditor.submitting = false
|
||||
resetSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const changeMachineView = async (view: 'details' | 'skeleton') => {
|
||||
if (view === activeMachineView.value) return
|
||||
|
||||
if (view === 'skeleton') {
|
||||
if (!machineHasSkeletonRequirements.value) {
|
||||
toast.showInfo('Aucun squelette configuré pour cette machine.')
|
||||
return
|
||||
}
|
||||
activeMachineView.value = 'skeleton'
|
||||
if (!skeletonEditor.open) {
|
||||
try {
|
||||
await openSkeletonEditor()
|
||||
} catch (error) {
|
||||
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
|
||||
toast.showError('Impossible de charger les éléments du squelette.')
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
closeSkeletonEditor()
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation & save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = findComponentById(components.value, entry.composantId as string)
|
||||
const typeProductId =
|
||||
((component?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
const typeProductId =
|
||||
((piece?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = findProductById(entry.productId as string)
|
||||
const typeProductId =
|
||||
((product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null)
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const validateSkeletonSelections = (type: AnyRecord) => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.pieceId) payload.pieceId = entry.pieceId
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const productUsage = computeSkeletonProductUsage(type)
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
|
||||
}
|
||||
|
||||
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
|
||||
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
|
||||
if (count < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { valid: false as const, error: errors[0] }
|
||||
return {
|
||||
valid: true as const,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply reconfiguration result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
|
||||
if (!data) return
|
||||
|
||||
const updatedMachine = (data.machine as AnyRecord) || data
|
||||
if (updatedMachine) {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...updatedMachine,
|
||||
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
|
||||
}
|
||||
initMachineFields()
|
||||
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
||||
}
|
||||
|
||||
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
|
||||
if (linksApplied) {
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
collapseAllComponents()
|
||||
return
|
||||
}
|
||||
|
||||
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newComponents)) {
|
||||
components.value = transformComponentCustomFields(newComponents)
|
||||
collapseAllComponents()
|
||||
}
|
||||
|
||||
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newPieces)) {
|
||||
pieces.value = transformCustomFields(newPieces)
|
||||
}
|
||||
|
||||
const prodLinks =
|
||||
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
|
||||
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
|
||||
if (Array.isArray(prodLinks)) {
|
||||
machineProductLinks.value = prodLinks as AnyRecord[]
|
||||
if (machine.value) machine.value.productLinks = prodLinks
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const saveSkeletonConfiguration = async () => {
|
||||
if (!machine.value?.id) return
|
||||
|
||||
const type = machineType.value as AnyRecord
|
||||
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
|
||||
|
||||
if (type && machineHasSkeletonRequirements.value) {
|
||||
const validation = validateSkeletonSelections(type)
|
||||
if (!validation.valid) {
|
||||
toast.showError((validation as AnyRecord).error as string)
|
||||
return
|
||||
}
|
||||
payload = {
|
||||
componentLinks: (validation as AnyRecord).componentLinks,
|
||||
pieceLinks: (validation as AnyRecord).pieceLinks,
|
||||
productLinks: (validation as AnyRecord).productLinks,
|
||||
}
|
||||
}
|
||||
|
||||
skeletonEditor.submitting = true
|
||||
try {
|
||||
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
|
||||
if ((result as AnyRecord).success) {
|
||||
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
|
||||
await changeMachineView('details')
|
||||
} else if ((result as AnyRecord).error) {
|
||||
toast.showError((result as AnyRecord).error as string)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
|
||||
} finally {
|
||||
skeletonEditor.submitting = false
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// View state
|
||||
activeMachineView,
|
||||
isDetailsView,
|
||||
isSkeletonView,
|
||||
|
||||
// Editor state
|
||||
skeletonEditor,
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
|
||||
// Entry getters
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
|
||||
// Label resolvers
|
||||
resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel,
|
||||
resolveProductRequirementTypeLabel,
|
||||
getProductOptionsForRequirement,
|
||||
|
||||
// Selection CRUD
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
setComponentRequirementType,
|
||||
setComponentRequirementConstructeur,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
setPieceRequirementType,
|
||||
setPieceRequirementConstructeur,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setProductRequirementProduct,
|
||||
setProductRequirementType,
|
||||
|
||||
// Editor lifecycle
|
||||
openSkeletonEditor,
|
||||
closeSkeletonEditor,
|
||||
changeMachineView,
|
||||
initializeSkeletonRequirementSelections,
|
||||
|
||||
// Validation & save
|
||||
validateSkeletonSelections,
|
||||
saveSkeletonConfiguration,
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
|
||||
const machineTypes = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value, relationKey) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationValue = normalized[relationKey.replace('Id', '')]
|
||||
const relationId = extractRelationId(relationValue)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMachineType = (type) => {
|
||||
if (!type || typeof type !== 'object') {
|
||||
return type
|
||||
}
|
||||
return {
|
||||
...type,
|
||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items.map(normalizeMachineType)
|
||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineType = async (typeData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/type_machines', typeData)
|
||||
if (result.success) {
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
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 }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineType = async (id, typeData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachineType = async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedType = machineTypes.value.find(type => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter(type => type.id !== id)
|
||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypeById = async (id) => {
|
||||
// D'abord chercher dans le cache local
|
||||
const localType = machineTypes.value.find(type => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
|
||||
// Si pas trouvé localement, récupérer depuis l'API
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
// Ajouter au cache local
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypes = () => machineTypes.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
machineTypes,
|
||||
loading,
|
||||
loadMachineTypes,
|
||||
createMachineType,
|
||||
updateMachineType,
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
186
app/composables/useMachineTypesApi.ts
Normal file
186
app/composables/useMachineTypesApi.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface MachineTypeRequirement {
|
||||
id?: string
|
||||
label?: string
|
||||
minCount?: number
|
||||
maxCount?: number
|
||||
required?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MachineType {
|
||||
id: string
|
||||
name: string
|
||||
componentRequirements: MachineTypeRequirement[]
|
||||
pieceRequirements: MachineTypeRequirement[]
|
||||
productRequirements: MachineTypeRequirement[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machineTypes = ref<MachineType[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry: Record<string, unknown>, _index: number) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
const relationField = relationKey.replace('Id', '')
|
||||
const relationValue = normalized[relationField]
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationId = extractRelationId(relationValue)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
return normalized as MachineTypeRequirement
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
|
||||
if (!type || typeof type !== 'object') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...type,
|
||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||
} as MachineType
|
||||
}
|
||||
|
||||
export function useMachineTypesApi() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/type_machines', typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
if (normalized) machineTypes.value.push(normalized)
|
||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await put(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedType = machineTypes.value.find((type) => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
|
||||
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
||||
if (!forceRefresh) {
|
||||
const localType = machineTypes.value.find((type) => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer depuis l'API
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
// Mettre à jour le cache local
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
} else if (normalized) {
|
||||
machineTypes.value.push(normalized)
|
||||
}
|
||||
return { success: true, data: normalized }
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du type de machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypes = (): MachineType[] => machineTypes.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machineTypes,
|
||||
loading,
|
||||
loadMachineTypes,
|
||||
createMachineType,
|
||||
updateMachineType,
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const machines = ref([])
|
||||
export interface Machine {
|
||||
id: string
|
||||
name?: string
|
||||
siteId?: string | null
|
||||
typeMachineId?: string | null
|
||||
componentLinks?: unknown[]
|
||||
pieceLinks?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machines = ref<Machine[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const resolveLinkCollection = (source, keys) => {
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
@@ -22,16 +34,17 @@ const resolveLinkCollection = (source, keys) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeMachineResponse = (payload) => {
|
||||
const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const container = payload.machine && typeof payload.machine === 'object'
|
||||
? payload.machine
|
||||
: payload
|
||||
const raw = payload as Record<string, unknown>
|
||||
const container = raw.machine && typeof raw.machine === 'object'
|
||||
? raw.machine as Record<string, unknown>
|
||||
: raw
|
||||
|
||||
const normalized = { ...container }
|
||||
const normalized: Record<string, unknown> = { ...container }
|
||||
|
||||
if (!normalized.siteId) {
|
||||
const siteId = extractRelationId(container.site)
|
||||
@@ -47,44 +60,35 @@ const normalizeMachineResponse = (payload) => {
|
||||
}
|
||||
}
|
||||
|
||||
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ??
|
||||
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||
[]
|
||||
const pieceLinks = resolveLinkCollection(payload, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
const pieceLinks = resolveLinkCollection(raw, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
[]
|
||||
|
||||
normalized.componentLinks = componentLinks
|
||||
normalized.pieceLinks = pieceLinks
|
||||
|
||||
return normalized
|
||||
return normalized as Machine
|
||||
}
|
||||
|
||||
export function useMachines () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachines = async () => {
|
||||
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/machines')
|
||||
if (result.success) {
|
||||
const machineList = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.data?.member)
|
||||
? result.data.member
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member']
|
||||
: Array.isArray(result.data?.machines)
|
||||
? result.data.machines
|
||||
: Array.isArray(result.data?.data)
|
||||
? result.data.data
|
||||
: []
|
||||
const machineList = extractCollection(result.data)
|
||||
const normalized = machineList
|
||||
.map((item) => normalizeMachineResponse(item))
|
||||
.filter(Boolean)
|
||||
.filter((item): item is Machine => item !== null)
|
||||
machines.value = normalized
|
||||
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des machines:', error)
|
||||
@@ -93,14 +97,14 @@ export function useMachines () {
|
||||
}
|
||||
}
|
||||
|
||||
const createMachine = async (machineData) => {
|
||||
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await post('/machines', normalizedPayload)
|
||||
if (result.success) {
|
||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (createdMachine) {
|
||||
machines.value.push(createdMachine)
|
||||
@@ -111,34 +115,31 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineFromType = async (machineData, typeMachine) => {
|
||||
// Créer la machine avec la structure héritée du type
|
||||
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
|
||||
const machineWithStructure = {
|
||||
...machineData,
|
||||
typeMachineId: typeMachine.id
|
||||
// La structure sera automatiquement héritée du type
|
||||
// Les composants et pièces seront créés automatiquement
|
||||
typeMachineId: typeMachine.id,
|
||||
}
|
||||
|
||||
return await createMachine(machineWithStructure)
|
||||
}
|
||||
|
||||
const updateMachineData = async (id, machineData) => {
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
const index = machines.value.findIndex(machine => machine.id === id)
|
||||
const index = machines.value.findIndex((machine) => machine.id === id)
|
||||
if (index !== -1 && updatedMachine) {
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
@@ -150,13 +151,13 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reconfigureSkeleton = async (machineId, payload) => {
|
||||
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Identifiant de machine manquant' }
|
||||
}
|
||||
@@ -165,28 +166,28 @@ export function useMachines () {
|
||||
try {
|
||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
||||
if (result.success) {
|
||||
const index = machines.value.findIndex(machine => machine.id === machineId)
|
||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||
if (index !== -1) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
machines.value[index]
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
...(updatedMachine || {}),
|
||||
}
|
||||
...updatedMachine,
|
||||
} as Machine
|
||||
}
|
||||
showSuccess('Structure de la machine mise à jour avec succès')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMissingCustomFields = async (machineId, { showToast: shouldShowToast = true } = {}) => {
|
||||
const addMissingCustomFields = async (machineId: string, { showToast: shouldShowToast = true } = {}): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
const error = 'Identifiant de machine manquant'
|
||||
if (shouldShowToast) {
|
||||
@@ -206,46 +207,46 @@ export function useMachines () {
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l’ajout des champs personnalisés manquants:', error)
|
||||
console.error('Erreur lors de l\'ajout des champs personnalisés manquants:', error)
|
||||
if (shouldShowToast) {
|
||||
showError('Erreur lors de la complétion des champs personnalisés')
|
||||
}
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachine = async (id) => {
|
||||
const deleteMachine = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedMachine = machines.value.find(machine => machine.id === id)
|
||||
machines.value = machines.value.filter(machine => machine.id !== id)
|
||||
const deletedMachine = machines.value.find((machine) => machine.id === id)
|
||||
machines.value = machines.value.filter((machine) => machine.id !== id)
|
||||
showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineById = (id) => {
|
||||
return machines.value.find(machine => machine.id === id)
|
||||
const getMachineById = (id: string): Machine | undefined => {
|
||||
return machines.value.find((machine) => machine.id === id)
|
||||
}
|
||||
|
||||
const getMachinesBySite = (siteId) => {
|
||||
return machines.value.filter(machine => machine.siteId === siteId)
|
||||
const getMachinesBySite = (siteId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||
}
|
||||
|
||||
const getMachinesByType = (typeMachineId) => {
|
||||
return machines.value.filter(machine => machine.typeMachineId === typeMachineId)
|
||||
const getMachinesByType = (typeMachineId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
|
||||
}
|
||||
|
||||
const getMachines = () => machines.value
|
||||
const isLoading = () => loading.value
|
||||
const getMachines = (): Machine[] => machines.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machines,
|
||||
@@ -261,6 +262,6 @@ export function useMachines () {
|
||||
getMachinesByType,
|
||||
getMachines,
|
||||
isLoading,
|
||||
addMissingCustomFields
|
||||
addMissingCustomFields,
|
||||
}
|
||||
}
|
||||
65
app/composables/useNavDropdown.ts
Normal file
65
app/composables/useNavDropdown.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
|
||||
export function useNavDropdown() {
|
||||
const openDropdown = ref<string | null>(null)
|
||||
let dropdownCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const route = useRoute()
|
||||
|
||||
const setDropdown = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
if (openDropdown.value !== name) {
|
||||
openDropdown.value = name
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDropdownClose = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
}
|
||||
dropdownCloseTimer = setTimeout(() => {
|
||||
if (openDropdown.value === name) {
|
||||
openDropdown.value = null
|
||||
}
|
||||
dropdownCloseTimer = null
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const closeDropdownNow = () => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
openDropdown.value = null
|
||||
}
|
||||
|
||||
const toggleDropdown = (name: string) => {
|
||||
if (openDropdown.value === name) {
|
||||
closeDropdownNow()
|
||||
return
|
||||
}
|
||||
setDropdown(name)
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
closeDropdownNow()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
openDropdown,
|
||||
setDropdown,
|
||||
scheduleDropdownClose,
|
||||
closeDropdownNow,
|
||||
toggleDropdown,
|
||||
}
|
||||
}
|
||||
12
app/composables/usePieceHistory.ts
Normal file
12
app/composables/usePieceHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type PieceHistoryActor = EntityHistoryActor
|
||||
export type PieceHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function usePieceHistory() {
|
||||
return useEntityHistory('piece')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
|
||||
const pieces = 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 function usePieces () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (piece) => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return piece
|
||||
}
|
||||
if (!piece.typePieceId) {
|
||||
const typePieceId = extractRelationId(piece.typePiece)
|
||||
if (typePieceId) {
|
||||
piece.typePieceId = typePieceId
|
||||
}
|
||||
}
|
||||
if (!piece.productId) {
|
||||
const productId = extractRelationId(piece.product)
|
||||
if (productId) {
|
||||
piece.productId = productId
|
||||
}
|
||||
}
|
||||
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')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
if (resolved.length) {
|
||||
piece.constructeurs = resolved
|
||||
piece.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return piece
|
||||
}
|
||||
|
||||
const loadPieces = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/pieces')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
pieces.value = enrichedItems
|
||||
showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createPiece = async (pieceData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||
const result = await post('/pieces', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
pieces.value.push(enriched)
|
||||
const displayName = result.data?.name
|
||||
|| pieceData?.definition?.name
|
||||
|| pieceData?.name
|
||||
|| 'Pièce'
|
||||
showSuccess(`Pièce "${displayName}" créée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceData = async (id, pieceData) => {
|
||||
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 (index !== -1) {
|
||||
pieces.value[index] = updated
|
||||
}
|
||||
showSuccess(`Pièce "${updated?.name || pieceData.name || ''}" mise à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePiece = async (id) => {
|
||||
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)
|
||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la pièce:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPieces = () => pieces.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
pieces,
|
||||
loading,
|
||||
loadPieces,
|
||||
createPiece,
|
||||
updatePiece: updatePieceData,
|
||||
deletePiece,
|
||||
getPieces,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
267
app/composables/usePieces.ts
Normal file
267
app/composables/usePieces.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
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'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const pieces = ref<Piece[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function usePieces() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (piece: Piece): Promise<Piece> => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return piece
|
||||
}
|
||||
if (!piece.typePieceId) {
|
||||
const typePieceId = extractRelationId(piece.typePiece)
|
||||
if (typePieceId) {
|
||||
piece.typePieceId = typePieceId
|
||||
}
|
||||
}
|
||||
if (!piece.productId) {
|
||||
const productId = extractRelationId(piece.product)
|
||||
if (productId) {
|
||||
piece.productId = productId
|
||||
}
|
||||
}
|
||||
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
|
||||
if (productIds.length === 0 && piece.productId) {
|
||||
piece.productIds = [piece.productId]
|
||||
} else if (productIds.length > 0) {
|
||||
piece.productIds = productIds.map((id) => String(id))
|
||||
if (!piece.productId) {
|
||||
piece.productId = piece.productIds[0] || null
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
piece.constructeurIds,
|
||||
piece.constructeurs,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
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)
|
||||
if (resolved.length) {
|
||||
piece.constructeurs = resolved
|
||||
piece.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return piece
|
||||
}
|
||||
|
||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
pieces.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result as PieceListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Piece)
|
||||
pieces.value.unshift(enriched)
|
||||
total.value += 1
|
||||
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 { 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 as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 && 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 { 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 as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
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 as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPieces = () => pieces.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearPiecesCache = () => {
|
||||
pieces.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
pieces,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadPieces,
|
||||
createPiece,
|
||||
updatePiece: updatePieceData,
|
||||
deletePiece,
|
||||
getPieces,
|
||||
isLoading,
|
||||
clearPiecesCache,
|
||||
}
|
||||
}
|
||||
12
app/composables/useProductHistory.ts
Normal file
12
app/composables/useProductHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ProductHistoryActor = EntityHistoryActor
|
||||
export type ProductHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useProductHistory() {
|
||||
return useEntityHistory('product')
|
||||
}
|
||||
@@ -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,28 +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 (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data
|
||||
}
|
||||
return []
|
||||
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
|
||||
}
|
||||
@@ -60,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)
|
||||
@@ -77,40 +108,67 @@ export function useProducts () {
|
||||
return product
|
||||
}
|
||||
|
||||
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 (!force && loaded.value && !search && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value },
|
||||
}
|
||||
}
|
||||
if (loaded.value && !options.force) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value },
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get('/products?itemsPerPage=100')
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
products.value = enrichedItems
|
||||
total.value = typeof result.data?.totalItems === 'number'
|
||||
? result.data.totalItems
|
||||
: items.length
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
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 }
|
||||
@@ -119,26 +177,27 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const createProduct = async (payload) => {
|
||||
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await post('/products', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
const added = replaceInCache(enriched)
|
||||
if (added) {
|
||||
total.value += 1
|
||||
}
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la création du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -147,23 +206,24 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const updateProduct = async (id, payload) => {
|
||||
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await patch(`/products/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -172,23 +232,23 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProduct = async (id) => {
|
||||
const deleteProduct = async (id: string): Promise<ProductSingleResult> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await del(`/products/${id}`)
|
||||
if (result.success) {
|
||||
const removed = products.value.find((product) => product.id === id)
|
||||
products.value = products.value.filter((product) => product.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
return { success: true }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la suppression du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
showError(message)
|
||||
return { success: false, error: message }
|
||||
@@ -197,7 +257,7 @@ export function useProducts () {
|
||||
}
|
||||
}
|
||||
|
||||
const getProduct = async (id, options = {}) => {
|
||||
const getProduct = async (id: string, options: { force?: boolean } = {}): Promise<ProductSingleResult> => {
|
||||
const shouldForce = !!options.force
|
||||
if (!shouldForce) {
|
||||
const cached = products.value.find((product) => product.id === id)
|
||||
@@ -209,14 +269,14 @@ export function useProducts () {
|
||||
try {
|
||||
const result = await get(`/products/${id}`)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return result
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement du produit:', err)
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
import type { Profile } from './useProfiles'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseUrl = process.server
|
||||
? (config.apiBaseUrl || config.public.apiBaseUrl || '')
|
||||
: (config.public.apiBaseUrl || '')
|
||||
const baseUrl = import.meta.server
|
||||
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
|
||||
: ((config.public.apiBaseUrl as string) || '')
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfileSession () {
|
||||
const activeProfile = useState('profileSession:active', () => null)
|
||||
const sessionLoaded = useState('profileSession:loaded', () => false)
|
||||
const loading = useState('profileSession:loading', () => false)
|
||||
export function useProfileSession() {
|
||||
const activeProfile = useState<Profile | null>('profileSession:active', () => null)
|
||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) { return undefined }
|
||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
||||
if (!import.meta.server) { return undefined }
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchCurrentProfile = async () => {
|
||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch(buildUrl('/session/profile'), {
|
||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
const err = error as { status?: number }
|
||||
if (err?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
@@ -42,29 +44,29 @@ export function useProfileSession () {
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = () => {
|
||||
const ensureSession = (): Promise<Profile | null> => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
const activateProfile = async (profileId) => {
|
||||
const activateProfile = async (profileId: string): Promise<void> => {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { profileId },
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
@@ -79,6 +81,6 @@ export function useProfileSession () {
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
logout
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || ''
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfiles () {
|
||||
const profiles = useState('profiles:list', () => [])
|
||||
const loadingProfiles = useState('profiles:loading', () => false)
|
||||
const profilesLoaded = useState('profiles:loaded', () => false)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) { return undefined }
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
loadingProfiles.value = true
|
||||
try {
|
||||
profiles.value = await $fetch(buildUrl('/session/profiles'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
profilesLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des profils', error)
|
||||
profiles.value = []
|
||||
profilesLoaded.value = false
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
return profiles.value
|
||||
}
|
||||
|
||||
const createProfile = async ({ firstName, lastName }) => {
|
||||
const profile = await $fetch(buildUrl('/session/profiles'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { firstName, lastName },
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
await fetchProfiles()
|
||||
return profile
|
||||
}
|
||||
|
||||
const deleteProfile = async (profileId) => {
|
||||
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
await fetchProfiles()
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loadingProfiles,
|
||||
profilesLoaded,
|
||||
fetchProfiles,
|
||||
createProfile,
|
||||
deleteProfile
|
||||
}
|
||||
}
|
||||
74
app/composables/useProfiles.ts
Normal file
74
app/composables/useProfiles.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = (config.public.apiBaseUrl as string)?.replace(/\/$/, '') || ''
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfiles() {
|
||||
const profiles = useState<Profile[]>('profiles:list', () => [])
|
||||
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
||||
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
||||
|
||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
||||
if (!import.meta.server) { return undefined }
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchProfiles = async (): Promise<Profile[]> => {
|
||||
loadingProfiles.value = true
|
||||
try {
|
||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
profilesLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des profils', error)
|
||||
profiles.value = []
|
||||
profilesLoaded.value = false
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
return profiles.value
|
||||
}
|
||||
|
||||
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
|
||||
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { firstName, lastName },
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchProfiles()
|
||||
return profile
|
||||
}
|
||||
|
||||
const deleteProfile = async (profileId: string): Promise<void> => {
|
||||
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchProfiles()
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loadingProfiles,
|
||||
profilesLoaded,
|
||||
fetchProfiles,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { navigateTo, useRoute } from '#imports'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
@@ -41,6 +42,7 @@ export function useSiteManagement() {
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites()
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments()
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const showAddSiteModal = ref(false)
|
||||
const showEditSiteModal = ref(false)
|
||||
@@ -132,7 +134,8 @@ export function useSiteManagement() {
|
||||
if (index !== -1) {
|
||||
sites.value[index] = {
|
||||
...sites.value[index],
|
||||
...updated
|
||||
...updated,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,8 +169,9 @@ export function useSiteManagement() {
|
||||
)
|
||||
uploadingDocuments.value = false
|
||||
|
||||
if (uploadResult.success) {
|
||||
uploadedDocuments = uploadResult.data || []
|
||||
if (uploadResult.success && uploadResult.data) {
|
||||
const data = uploadResult.data
|
||||
uploadedDocuments = (Array.isArray(data) ? data : [data]) as SiteDocument[]
|
||||
selectedFiles.value = []
|
||||
}
|
||||
}
|
||||
@@ -250,9 +254,9 @@ export function useSiteManagement() {
|
||||
|
||||
const confirmDeleteSite = async (site: SiteWithDocuments) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`
|
||||
)
|
||||
!await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
const sites = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
export function useSites () {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
console.log('sites api result', result)
|
||||
|
||||
if (result.success) {
|
||||
const collection = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.data?.member)
|
||||
? result.data.member
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member']
|
||||
: Array.isArray(result.data?.data)
|
||||
? result.data.data
|
||||
: []
|
||||
sites.value = collection
|
||||
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success) {
|
||||
sites.value.push(result.data)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id, siteData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success) {
|
||||
const index = sites.value.findIndex(site => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/sites/${id}`)
|
||||
if (result.success) {
|
||||
const deletedSite = sites.value.find(site => site.id === id)
|
||||
sites.value = sites.value.filter(site => site.id !== id)
|
||||
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id) => {
|
||||
return sites.value.find(site => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
loadSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading
|
||||
}
|
||||
}
|
||||
123
app/composables/useSites.ts
Normal file
123
app/composables/useSites.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
contactPostalCode?: string
|
||||
contactCity?: string
|
||||
machines?: unknown[]
|
||||
documents?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface SiteResult {
|
||||
success: boolean
|
||||
data?: Site
|
||||
error?: string
|
||||
}
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSites() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
if (result.success) {
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success && result.data) {
|
||||
sites.value.push(result.data as Site)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id: string, siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success && result.data) {
|
||||
const index = sites.value.findIndex((site) => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data as Site
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id: string): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/sites/${id}`)
|
||||
if (result.success) {
|
||||
const deletedSite = sites.value.find((site) => site.id === id)
|
||||
sites.value = sites.value.filter((site) => site.id !== id)
|
||||
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id: string): Site | undefined => {
|
||||
return sites.value.find((site) => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
loadSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const toasts = ref([])
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
const MAX_TOASTS = 3
|
||||
let nextId = 1
|
||||
|
||||
export function useToast () {
|
||||
const showToast = (message, type = 'info', duration = 3500) => {
|
||||
export function useToast() {
|
||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||
const id = nextId++
|
||||
const toast = {
|
||||
const toast: Toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true
|
||||
visible: true,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
@@ -28,25 +37,25 @@ export function useToast () {
|
||||
return id
|
||||
}
|
||||
|
||||
const showSuccess = (message, duration = 5000) => {
|
||||
const showSuccess = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message, duration = 5000) => {
|
||||
const showError = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
const showWarning = (message, duration = 6000) => {
|
||||
const showWarning = (message: string, duration = 6000): number => {
|
||||
return showToast(message, 'warning', duration)
|
||||
}
|
||||
|
||||
const showInfo = (message, duration = 5000) => {
|
||||
const showInfo = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'info', duration)
|
||||
}
|
||||
|
||||
const removeToast = (id) => {
|
||||
const index = toasts.value.findIndex(toast => toast.id === id)
|
||||
if (index !== -1) {
|
||||
const removeToast = (id: number): void => {
|
||||
const index = toasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index !== -1 && toasts.value[index]) {
|
||||
toasts.value[index].visible = false
|
||||
setTimeout(() => {
|
||||
toasts.value.splice(index, 1)
|
||||
@@ -54,7 +63,7 @@ export function useToast () {
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const clearAll = (): void => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
@@ -66,6 +75,6 @@ export function useToast () {
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeToast,
|
||||
clearAll
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
116
app/composables/useUrlState.ts
Normal file
116
app/composables/useUrlState.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ref, watch, nextTick, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
|
||||
interface ParamDef<T extends string | number = string | number> {
|
||||
default: T
|
||||
type?: 'string' | 'number'
|
||||
/** Debounce URL writes (ms). Default: 0 (immediate). */
|
||||
debounce?: number
|
||||
}
|
||||
|
||||
type ParamDefs = Record<string, ParamDef>
|
||||
|
||||
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
|
||||
|
||||
type StateRefs<T extends ParamDefs> = {
|
||||
[K in keyof T]: InferRef<T[K]>
|
||||
}
|
||||
|
||||
interface UseUrlStateOptions {
|
||||
/** Called when state is restored from URL (back/forward navigation). */
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function useUrlState<T extends ParamDefs>(
|
||||
params: T,
|
||||
options?: UseUrlStateOptions,
|
||||
): StateRefs<T> {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keys = Object.keys(params) as (keyof T & string)[]
|
||||
const refs: Record<string, Ref<string | number>> = {}
|
||||
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
|
||||
|
||||
for (const key of keys) {
|
||||
refs[key] = ref(parseValue(route.query[key], params[key]!))
|
||||
timers[key] = null
|
||||
}
|
||||
|
||||
let isProgrammatic = false
|
||||
|
||||
const buildQuery = (): Record<string, string> => {
|
||||
const q: Record<string, string> = {}
|
||||
for (const key of keys) {
|
||||
const val = refs[key]!.value
|
||||
if (val !== params[key]!.default) {
|
||||
q[key] = String(val)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
const pushToUrl = () => {
|
||||
if (isProgrammatic) return
|
||||
isProgrammatic = true
|
||||
const query = buildQuery()
|
||||
router
|
||||
.replace({ path: route.path, query })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
nextTick(() => {
|
||||
isProgrammatic = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const ms = params[key]!.debounce ?? 0
|
||||
watch(refs[key]!, () => {
|
||||
if (isProgrammatic) return
|
||||
if (ms > 0) {
|
||||
if (timers[key]) clearTimeout(timers[key]!)
|
||||
timers[key] = setTimeout(pushToUrl, ms)
|
||||
} else {
|
||||
pushToUrl()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({ ...route.query }),
|
||||
(newQuery) => {
|
||||
if (isProgrammatic) return
|
||||
isProgrammatic = true
|
||||
let changed = false
|
||||
for (const key of keys) {
|
||||
const parsed = parseValue(newQuery[key], params[key]!)
|
||||
if (refs[key]!.value !== parsed) {
|
||||
refs[key]!.value = parsed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
isProgrammatic = false
|
||||
if (changed && options?.onRestore) {
|
||||
options.onRestore()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return refs as StateRefs<T>
|
||||
}
|
||||
|
||||
function parseValue(
|
||||
raw: unknown,
|
||||
def: ParamDef,
|
||||
): string | number {
|
||||
const str = typeof raw === 'string' ? raw : null
|
||||
if (str === null) return def.default
|
||||
if (def.type === 'number' || typeof def.default === 'number') {
|
||||
const n = Number(str)
|
||||
return Number.isFinite(n) ? n : def.default
|
||||
}
|
||||
return str
|
||||
}
|
||||
274
app/pages/activity-log.vue
Normal file
274
app/pages/activity-log.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Historique des modifications sur l'ensemble des pièces, produits et composants.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-entity-type"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="activity-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="piece">Pièce</option>
|
||||
<option value="product">Produit</option>
|
||||
<option value="composant">Composant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-action"
|
||||
>
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
id="activity-action"
|
||||
v-model="actionFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
<option value="create">Création</option>
|
||||
<option value="update">Modification</option>
|
||||
<option value="delete">Suppression</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="activity-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!total" class="text-sm text-base-content/70">
|
||||
Aucune activité enregistrée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
||||
Aucune activité ne correspond à vos filtres.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Type</th>
|
||||
<th>Entité</th>
|
||||
<th>Auteur</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(entry.action)"
|
||||
>
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(entry.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="entry.action !== 'delete'"
|
||||
:to="entityEditLink(entry)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ entry.entityRef }})
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.actor?.label || '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="hasDiff(entry)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="expandedIds.has(entry.id)">
|
||||
<td colspan="6" class="bg-base-200/50 p-4">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useActivityLog } from '~/composables/useActivityLog'
|
||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
|
||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
||||
|
||||
const entityTypeFilter = ref('')
|
||||
const actionFilter = ref('')
|
||||
|
||||
const expandedIds = reactive(new Set<string>())
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
if (expandedIds.has(id)) expandedIds.delete(id)
|
||||
else expandedIds.add(id)
|
||||
}
|
||||
|
||||
const hasDiff = (entry: ActivityLogEntry) =>
|
||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
||||
|
||||
const fetchLog = () => {
|
||||
loadActivityLog({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
action: actionFilter.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
composant: 'Composant',
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||
|
||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
||||
piece: '/pieces',
|
||||
product: '/product',
|
||||
composant: '/component',
|
||||
}
|
||||
|
||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
||||
}
|
||||
|
||||
const actionBadgeClass = (action: string) => {
|
||||
if (action === 'create') return 'badge-success'
|
||||
if (action === 'delete') return 'badge-error'
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const globalFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
supplierPrice: 'Prix fournisseur',
|
||||
typePiece: 'Type de pièce',
|
||||
typeProduct: 'Type de produit',
|
||||
typeComposant: 'Type de composant',
|
||||
product: 'Produit',
|
||||
productIds: 'Produits',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
structure: 'Structure',
|
||||
}
|
||||
|
||||
onMounted(fetchLog)
|
||||
</script>
|
||||
173
app/pages/changelog.vue
Normal file
173
app/pages/changelog.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Historique des modifications et nouvelles fonctionnalités de l'application.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
v-for="release in releases"
|
||||
:key="release.version"
|
||||
class="card border border-base-200 bg-base-100 shadow-sm"
|
||||
>
|
||||
<div class="card-body space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-bold text-base-content">
|
||||
{{ release.version }}
|
||||
</h2>
|
||||
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(item, i) in release.changes"
|
||||
:key="i"
|
||||
class="flex items-start gap-2 text-sm text-base-content/80"
|
||||
>
|
||||
<span
|
||||
class="badge badge-sm mt-0.5 shrink-0"
|
||||
:class="badgeClass(item.type)"
|
||||
>
|
||||
{{ item.type }}
|
||||
</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '#imports'
|
||||
|
||||
useHead({ title: 'Changelog' })
|
||||
|
||||
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
|
||||
|
||||
interface Change {
|
||||
type: ChangeType
|
||||
text: string
|
||||
}
|
||||
|
||||
interface Release {
|
||||
version: string
|
||||
date: string
|
||||
changes: Change[]
|
||||
}
|
||||
|
||||
const badgeClass = (type: ChangeType) => {
|
||||
const map: Record<ChangeType, string> = {
|
||||
feat: 'badge-primary',
|
||||
fix: 'badge-error',
|
||||
perf: 'badge-warning',
|
||||
chore: 'badge-ghost',
|
||||
}
|
||||
return map[type] ?? 'badge-ghost'
|
||||
}
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
version: 'v1.6.0',
|
||||
date: '2026-02-12',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
|
||||
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
|
||||
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
|
||||
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.5.0',
|
||||
date: '2026-02-11',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
|
||||
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
|
||||
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
|
||||
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
|
||||
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
|
||||
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
|
||||
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
|
||||
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
|
||||
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
|
||||
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
|
||||
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
|
||||
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
|
||||
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
|
||||
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.4.0',
|
||||
date: '2026-02-04',
|
||||
changes: [
|
||||
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
|
||||
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.3.0',
|
||||
date: '2026-01-28',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
|
||||
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
|
||||
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
|
||||
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
|
||||
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
|
||||
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
|
||||
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
|
||||
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
|
||||
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
|
||||
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
|
||||
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.2.0',
|
||||
date: '2026-01-21',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
|
||||
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
|
||||
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
|
||||
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.1.1',
|
||||
date: '2026-01-14',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
|
||||
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.1.0',
|
||||
date: '2026-01-07',
|
||||
changes: [
|
||||
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
|
||||
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
|
||||
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.0.0',
|
||||
date: '2025-12-15',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
|
||||
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
|
||||
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
|
||||
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
|
||||
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
|
||||
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
@@ -35,6 +35,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -48,6 +49,7 @@
|
||||
id="component-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
@@ -64,14 +66,33 @@
|
||||
id="component-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }}
|
||||
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -83,54 +104,71 @@
|
||||
Aucun composant n'a encore été créé.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70">
|
||||
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
||||
Aucun composant ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in visibleComposants" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>{{ resolveComponentType(component) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in composantsList" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
v-if="component.typeComposant?.id"
|
||||
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
Modifier
|
||||
{{ resolveComponentType(component) }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -138,19 +176,54 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { showError } = useToast()
|
||||
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 20, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
}, {
|
||||
onRestore: () => fetchComposants(),
|
||||
})
|
||||
|
||||
const composantsTotal = computed(() => total.value)
|
||||
const composantsOnPage = computed(() => composants.value.length)
|
||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Enrichir les composants avec les types de composants complets
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
@@ -161,13 +234,32 @@ const composantsList = computed(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
const composantsTotal = computed(() => composantsList.value.length)
|
||||
|
||||
const searchTerm = ref('')
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'component-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
const fetchComposants = async () => {
|
||||
await loadComposants({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||
@@ -230,58 +322,6 @@ const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComparableName = (component: Record<string, any>) => {
|
||||
const toComparable = (value?: string | null) =>
|
||||
(value ?? '').toString().trim().toLowerCase()
|
||||
|
||||
return (
|
||||
toComparable(component?.name) ||
|
||||
toComparable(component?.reference) ||
|
||||
toComparable(component?.id)
|
||||
)
|
||||
}
|
||||
|
||||
const resolveComparableDate = (component: Record<string, any>) => {
|
||||
const raw = component?.createdAt ?? component?.created_at ?? null
|
||||
if (!raw) {
|
||||
return 0
|
||||
}
|
||||
const parsed = new Date(raw).getTime()
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
const visibleComposants = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const source = composantsList.value || []
|
||||
|
||||
const filtered = term
|
||||
? source.filter((component) => {
|
||||
const name = (component?.name || '').toLowerCase()
|
||||
const reference = (component?.reference || '').toLowerCase()
|
||||
return (
|
||||
name.includes(term) ||
|
||||
reference.includes(term)
|
||||
)
|
||||
})
|
||||
: [...source]
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortField.value === 'name') {
|
||||
return (
|
||||
resolveComparableName(a).localeCompare(
|
||||
resolveComparableName(b),
|
||||
'fr',
|
||||
{ sensitivity: 'base' }
|
||||
) * direction
|
||||
)
|
||||
}
|
||||
|
||||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
||||
})
|
||||
})
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||
|
||||
@@ -304,17 +344,20 @@ 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
|
||||
}
|
||||
|
||||
await deleteComposant(component.id)
|
||||
// Reload current page after deletion
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadComposants(),
|
||||
fetchComposants(),
|
||||
loadComponentTypes()
|
||||
])
|
||||
})
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,16 +42,37 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/composants',
|
||||
filterKey: 'typeComposant',
|
||||
labels: {
|
||||
singular: 'composant',
|
||||
plural: 'composants',
|
||||
verifying: 'Vérification des composants liés en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name
|
||||
? `Modifier « ${initialData.value.name} »`
|
||||
@@ -86,8 +111,10 @@ 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)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -101,6 +128,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -109,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
description: payload?.notes ?? null,
|
||||
}
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { createModelType } from '~/services/modelTypes'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
useHead(() => ({
|
||||
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
description: payload.notes ?? null,
|
||||
}
|
||||
await createModelType(enrichedPayload)
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie de composant créée avec succès.')
|
||||
await router.push('/component-category')
|
||||
} catch (error: any) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@
|
||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
@@ -212,6 +212,9 @@
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:components-loading="componentsLoading"
|
||||
:piece-type-label-map="pieceTypeLabelMap"
|
||||
:product-type-label-map="productTypeLabelMap"
|
||||
:component-type-label-map="componentTypeLabelMap"
|
||||
/>
|
||||
<p v-else class="text-xs text-error">
|
||||
Impossible de générer les emplacements définis par le squelette.
|
||||
@@ -349,11 +352,18 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { 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,
|
||||
@@ -370,22 +380,22 @@ interface ComponentCatalogType extends ModelType {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
createComposant,
|
||||
composants: componentCatalogRef,
|
||||
loadComposants,
|
||||
loading: componentsLoading,
|
||||
} = useComposants()
|
||||
const {
|
||||
pieces: pieceCatalogRef,
|
||||
loadPieces,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loadProducts,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
@@ -414,6 +424,30 @@ const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(pieceTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const productTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
)
|
||||
const componentTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(componentTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
@@ -717,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(
|
||||
@@ -736,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 : []
|
||||
}
|
||||
@@ -778,6 +791,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
parts.push(piece.typePiece.name)
|
||||
} else if (piece.typePieceLabel) {
|
||||
parts.push(piece.typePieceLabel)
|
||||
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
|
||||
} else if (piece.typePiece?.code) {
|
||||
parts.push(`Famille ${piece.typePiece.code}`)
|
||||
} else if (piece.familyCode) {
|
||||
@@ -788,6 +803,43 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||
}
|
||||
|
||||
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const ids = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
@@ -870,7 +922,7 @@ const submitCreation = async () => {
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,9 +986,8 @@ const submitCreation = async () => {
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieces(),
|
||||
loadComposants(),
|
||||
loadProducts(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1094,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)
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
@@ -139,14 +141,17 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
const { documents, loading, loadDocuments } = useDocuments()
|
||||
const { get } = useApi()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const attachmentFilter = ref('all')
|
||||
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
filter: { default: 'all' },
|
||||
})
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
loadDocuments({ itemsPerPage: 200 })
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
@@ -156,10 +161,10 @@ const filteredDocuments = computed(() => {
|
||||
return documents.value.filter((document) => {
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'site' && document.siteId) ||
|
||||
(filter === 'machine' && document.machineId) ||
|
||||
(filter === 'composant' && document.composantId) ||
|
||||
(filter === 'piece' && document.pieceId)
|
||||
(filter === 'site' && document.site) ||
|
||||
(filter === 'machine' && document.machine) ||
|
||||
(filter === 'composant' && document.composant) ||
|
||||
(filter === 'piece' && document.piece)
|
||||
|
||||
if (!matchesFilter) { return false }
|
||||
|
||||
@@ -192,22 +197,36 @@ const formatSize = (size) => {
|
||||
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
const downloadDocument = (doc) => {
|
||||
if (!doc?.path) { return }
|
||||
/** Fetch the full document (with path) from the API on demand. */
|
||||
const fetchDocumentPath = async (doc) => {
|
||||
if (doc?.path) { return doc.path }
|
||||
if (!doc?.id) { return null }
|
||||
const result = await get(`/documents/${doc.id}`)
|
||||
if (result.success && result.data?.path) {
|
||||
doc.path = result.data.path
|
||||
return result.data.path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (doc.path.startsWith('data:')) {
|
||||
const downloadDocument = async (doc) => {
|
||||
const path = await fetchDocumentPath(doc)
|
||||
if (!path) { return }
|
||||
|
||||
if (path.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = doc.path
|
||||
link.href = path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
window.open(path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc) => {
|
||||
const openPreview = async (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
await fetchDocumentPath(doc)
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
@@ -472,10 +472,11 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||
import IconLucideTag from '~icons/lucide/tag'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
|
||||
const { sites, loading, loadSites, createSite } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
const { createMachineFromType, deleteMachine } = useMachines()
|
||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
@@ -517,8 +518,50 @@ const categories = computed(() => {
|
||||
return Array.from(cats)
|
||||
})
|
||||
|
||||
const machinesWithType = computed(() => {
|
||||
return machines.value.map((machine) => {
|
||||
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
|
||||
const resolvedTypeMachine = resolvedTypeMachineId
|
||||
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
|
||||
: null
|
||||
|
||||
return {
|
||||
...machine,
|
||||
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
|
||||
typeMachine:
|
||||
machine.typeMachine && typeof machine.typeMachine === 'object'
|
||||
? machine.typeMachine
|
||||
: resolvedTypeMachine
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const machinesBySiteId = computed(() => {
|
||||
const map = new Map()
|
||||
|
||||
machinesWithType.value.forEach((machine) => {
|
||||
const siteId = machine.siteId || extractRelationId(machine.site)
|
||||
if (!siteId) { return }
|
||||
|
||||
if (!map.has(siteId)) {
|
||||
map.set(siteId, [])
|
||||
}
|
||||
|
||||
map.get(siteId).push(machine)
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const sitesWithMachines = computed(() => {
|
||||
return sites.value.map((site) => ({
|
||||
...site,
|
||||
machines: machinesBySiteId.value.get(site.id) || []
|
||||
}))
|
||||
})
|
||||
|
||||
const totalMachines = computed(() => {
|
||||
return sites.value.reduce((total, site) => {
|
||||
return sitesWithMachines.value.reduce((total, site) => {
|
||||
return total + (site.machines?.length || 0)
|
||||
}, 0)
|
||||
})
|
||||
@@ -532,7 +575,7 @@ const formatPhoneDisplay = (value) => {
|
||||
}
|
||||
|
||||
const filteredSites = computed(() => {
|
||||
let filtered = sites.value
|
||||
let filtered = sitesWithMachines.value
|
||||
|
||||
// Filtrer par terme de recherche
|
||||
if (searchTerm.value) {
|
||||
@@ -551,9 +594,11 @@ const filteredSites = computed(() => {
|
||||
})
|
||||
|
||||
const machineMatches = site.machines?.some(
|
||||
machine =>
|
||||
machine.name.toLowerCase().includes(lowerTerm) ||
|
||||
machine.reference?.toLowerCase().includes(lowerTerm)
|
||||
machine => {
|
||||
const name = (machine.name || '').toLowerCase()
|
||||
const reference = (machine.reference || '').toLowerCase()
|
||||
return name.includes(lowerTerm) || reference.includes(lowerTerm)
|
||||
}
|
||||
)
|
||||
|
||||
return siteMatches || machineMatches
|
||||
@@ -637,6 +682,7 @@ const handleCreateMachine = async () => {
|
||||
newMachine.typeMachineId = ''
|
||||
newMachine.reference = ''
|
||||
showAddMachineModal.value = false
|
||||
await loadMachines()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,18 +705,21 @@ 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)
|
||||
if (result.success) {
|
||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||
await loadMachines()
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
}
|
||||
@@ -698,6 +747,6 @@ const getCategoryBadgeClass = (category) => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSites(), loadMachineTypes()])
|
||||
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
import IconLucideList from '~icons/lucide/list'
|
||||
@@ -142,6 +143,20 @@ const parseOptions = (field = {}) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const toModelTypeIri = (value) => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
||||
return value
|
||||
}
|
||||
const relationId = extractRelationId(value)
|
||||
if (relationId) {
|
||||
return `/api/model_types/${relationId}`
|
||||
}
|
||||
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (fields = []) =>
|
||||
fields
|
||||
.filter(field => field?.name && field.name.trim() !== '')
|
||||
@@ -165,9 +180,9 @@ const toIntegerOrNull = (value, fallback = null) => {
|
||||
|
||||
const normalizeComponentRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeComposantId)
|
||||
.filter(req => req?.typeComposantId || req?.typeComposant)
|
||||
.map((req, index) => ({
|
||||
typeComposantId: req.typeComposantId,
|
||||
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 1),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -180,9 +195,9 @@ const normalizeComponentRequirements = (requirements = []) =>
|
||||
|
||||
const normalizePieceRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typePieceId)
|
||||
.filter(req => req?.typePieceId || req?.typePiece)
|
||||
.map((req, index) => ({
|
||||
typePieceId: req.typePieceId,
|
||||
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -195,9 +210,9 @@ const normalizePieceRequirements = (requirements = []) =>
|
||||
|
||||
const normalizeProductRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeProductId)
|
||||
.filter(req => req?.typeProductId || req?.typeProduct)
|
||||
.map((req, index) => ({
|
||||
typeProductId: req.typeProductId,
|
||||
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user