Compare commits
39 Commits
9ee348fff0
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e | ||
|
|
e459da7c20 | ||
|
|
e84b5cf674 | ||
|
|
cc70fe2b29 | ||
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
861
app/app.vue
861
app/app.vue
@@ -1,861 +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">
|
||||
<div class="w-14">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventory
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Vue d'ensemble
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machines')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Parc Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machine-skeleton"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="
|
||||
isActive('/machine-skeleton')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Squelettes de machine
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('pieces-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('pieces-desktop')"
|
||||
@focusin="setDropdown('pieces-desktop')"
|
||||
@focusout="scheduleDropdownClose('pieces-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category') || isActive('/pieces-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('pieces-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
|
||||
:aria-expanded="openDropdown === 'pieces-desktop'"
|
||||
>
|
||||
Pièces
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'pieces-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'pieces-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/piece-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/piece-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de pièce
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/pieces-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/pieces-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des pièces
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('products-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('products-desktop')"
|
||||
@focusin="setDropdown('products-desktop')"
|
||||
@focusout="scheduleDropdownClose('products-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('products-desktop')"
|
||||
:aria-expanded="openDropdown === 'products-desktop'"
|
||||
>
|
||||
Produits
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('component-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('component-desktop')"
|
||||
@focusin="setDropdown('component-desktop')"
|
||||
@focusout="scheduleDropdownClose('component-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category') ||
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('component-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('component-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('component-desktop')"
|
||||
:aria-expanded="openDropdown === 'component-desktop'"
|
||||
>
|
||||
Composant
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'component-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'component-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de composant
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/component-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/component-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des composants
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('resources-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('resources-desktop')"
|
||||
@focusin="setDropdown('resources-desktop')"
|
||||
@focusout="scheduleDropdownClose('resources-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/sites') ||
|
||||
isActive('/documents') ||
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('resources-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('resources-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('resources-desktop')"
|
||||
:aria-expanded="openDropdown === 'resources-desktop'"
|
||||
>
|
||||
Ressources liées
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'resources-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'resources-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/sites')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/documents')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/constructeurs')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Fournisseurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Bouton paramètres d'affichage -->
|
||||
<button
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
@click="openDisplaySettings"
|
||||
>
|
||||
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
<LayoutAppNavbar
|
||||
@open-settings="displaySettingsOpen = true"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="activeProfile" class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
>
|
||||
<div
|
||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||
>
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
|
||||
>
|
||||
{{ activeProfileInitials }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
|
||||
>
|
||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||
Connecté en tant que<br />
|
||||
<span class="font-semibold text-base-content">{{
|
||||
activeProfileLabel
|
||||
}}</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
||||
Gestion des profils
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="text-error justify-between"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<NuxtPage />
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Paramètres d'affichage -->
|
||||
<CommonConfirmModal />
|
||||
|
||||
<DisplaySettings
|
||||
:is-open="displaySettingsOpen"
|
||||
@close="closeDisplaySettings"
|
||||
@close="displaySettingsOpen = false"
|
||||
@update-settings="handleSettingsUpdate"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||
<div class="items-center grid-flow-col">
|
||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
||||
<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 IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucideCpu from "~icons/lucide/cpu";
|
||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||
import logoSrc from "~/assets/LOGO_CARRE_BLANC.png";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { navigateTo, useRuntimeConfig } from '#imports'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
|
||||
// État du modal des paramètres d'affichage
|
||||
const displaySettingsOpen = ref(false);
|
||||
const { activeProfile, ensureSession, logout } = useProfileSession();
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const appVersion = computed(() => runtimeConfig.public?.appVersion ?? "0.1.0");
|
||||
const displaySettingsOpen = ref(false)
|
||||
const { ensureSession, logout } = useProfileSession()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
|
||||
|
||||
// Route active pour souligner l'onglet sélectionné dans la navbar
|
||||
const route = useRoute();
|
||||
const isActive = (path) => {
|
||||
if (path === "/") {
|
||||
return route.path === "/";
|
||||
}
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
|
||||
// Ouvrir les paramètres d'affichage
|
||||
const openDisplaySettings = () => {
|
||||
displaySettingsOpen.value = true;
|
||||
};
|
||||
|
||||
// Fermer les paramètres d'affichage
|
||||
const closeDisplaySettings = () => {
|
||||
displaySettingsOpen.value = false;
|
||||
};
|
||||
|
||||
// Gérer les mises à jour des paramètres
|
||||
const handleSettingsUpdate = (settings) => {
|
||||
console.log("Paramètres d'affichage mis à jour:", settings);
|
||||
};
|
||||
const handleSettingsUpdate = (_settings: unknown) => {
|
||||
// Placeholder for future persistence
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
await navigateTo("/profiles");
|
||||
};
|
||||
|
||||
const openDropdown = ref(null);
|
||||
let dropdownCloseTimer = null;
|
||||
|
||||
const setDropdown = (name) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
if (openDropdown.value !== name) {
|
||||
openDropdown.value = name;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDropdownClose = (name) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
}
|
||||
dropdownCloseTimer = setTimeout(() => {
|
||||
if (openDropdown.value === name) {
|
||||
openDropdown.value = null;
|
||||
}
|
||||
dropdownCloseTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const closeDropdownNow = () => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
openDropdown.value = null;
|
||||
};
|
||||
|
||||
const toggleDropdown = (name) => {
|
||||
if (openDropdown.value === name) {
|
||||
closeDropdownNow();
|
||||
return;
|
||||
}
|
||||
setDropdown(name);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeDropdownNow();
|
||||
},
|
||||
);
|
||||
|
||||
const activeProfileLabel = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return "Profil inconnu";
|
||||
}
|
||||
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`;
|
||||
});
|
||||
|
||||
const activeProfileInitials = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return "??";
|
||||
}
|
||||
const { firstName = "", lastName = "" } = activeProfile.value;
|
||||
return (
|
||||
`${firstName.charAt(0) || ""}${lastName.charAt(0) || ""}`.toUpperCase() ||
|
||||
"??"
|
||||
);
|
||||
});
|
||||
await logout()
|
||||
await navigateTo('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSession();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer);
|
||||
dropdownCloseTimer = null;
|
||||
}
|
||||
});
|
||||
await ensureSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-dropdown-desktop-enter-active,
|
||||
.nav-dropdown-desktop-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.nav-dropdown-desktop-enter-from,
|
||||
.nav-dropdown-desktop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
.nav-dropdown-desktop-enter-to,
|
||||
.nav-dropdown-desktop-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.nav-dropdown-mobile-enter-active,
|
||||
.nav-dropdown-mobile-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-from,
|
||||
.nav-dropdown-mobile-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-to,
|
||||
.nav-dropdown-mobile-leave-from {
|
||||
max-height: 12rem;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
212
app/components/CommentSection.vue
Normal file
212
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||
<IconLucideMessageSquare class="w-5 h-5" />
|
||||
Commentaires
|
||||
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||
{{ openComments.length }}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
v-if="showResolved && resolvedComments.length"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="showResolvedList = !showResolvedList"
|
||||
>
|
||||
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="newContent"
|
||||
class="textarea textarea-bordered flex-1 text-sm"
|
||||
rows="2"
|
||||
placeholder="Ajouter un commentaire..."
|
||||
:disabled="submitting"
|
||||
@keydown.ctrl.enter="handleSubmit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||
<IconLucideSend v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des commentaires ouverts -->
|
||||
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||
Aucun commentaire ouvert.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="comment in openComments"
|
||||
:key="comment.id"
|
||||
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||
<span>
|
||||
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||
</span>
|
||||
<div v-if="canEdit" class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(comment.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loading"
|
||||
@click="handleDelete(comment.id)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commentaires résolus -->
|
||||
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||
<div class="divider text-xs text-base-content/40">
|
||||
Résolus
|
||||
</div>
|
||||
<div
|
||||
v-for="comment in resolvedComments"
|
||||
:key="comment.id"
|
||||
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||
<span v-if="comment.resolvedByName">
|
||||
Résolu par {{ comment.resolvedByName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useComments, type Comment } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||
import IconLucideSend from '~icons/lucide/send'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName?: string
|
||||
showResolved?: boolean
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const {
|
||||
loading,
|
||||
fetchComments,
|
||||
createComment,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
} = useComments()
|
||||
|
||||
const comments = ref<Comment[]>([])
|
||||
const newContent = ref('')
|
||||
const submitting = ref(false)
|
||||
const loadingComments = ref(false)
|
||||
const showResolvedList = ref(false)
|
||||
|
||||
const openComments = computed(() =>
|
||||
comments.value.filter(c => c.status === 'open'),
|
||||
)
|
||||
|
||||
const resolvedComments = computed(() =>
|
||||
comments.value.filter(c => c.status === 'resolved'),
|
||||
)
|
||||
|
||||
const formatCommentDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
loadingComments.value = true
|
||||
const [openResult, resolvedResult] = await Promise.all([
|
||||
fetchComments(props.entityType, props.entityId, 'open'),
|
||||
props.showResolved
|
||||
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||
])
|
||||
const open = openResult.success ? (openResult.data ?? []) : []
|
||||
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||
comments.value = [...open, ...resolved]
|
||||
loadingComments.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = newContent.value.trim()
|
||||
if (!content) return
|
||||
submitting.value = true
|
||||
const result = await createComment(
|
||||
props.entityType,
|
||||
props.entityId,
|
||||
content,
|
||||
props.entityName,
|
||||
)
|
||||
submitting.value = false
|
||||
if (result.success) {
|
||||
newContent.value = ''
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (commentId: string) => {
|
||||
const result = await resolveComment(commentId)
|
||||
if (result.success) {
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
const result = await deleteComment(commentId)
|
||||
if (result.success) {
|
||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.entityId) {
|
||||
loadComments()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -401,7 +401,7 @@
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode && !piece.skeletonOnly"
|
||||
|
||||
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@@ -437,200 +437,98 @@ import { ref, watch, computed } from 'vue'
|
||||
import PieceItem from './PieceItem.vue'
|
||||
import DocumentUpload from './DocumentUpload.vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
collapseAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
toggleToken: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
component: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update',
|
||||
'edit-piece',
|
||||
'custom-field-update'
|
||||
])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
documents: componentDocuments,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
} = useEntityProductDisplay({ entity: () => props.component })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
const selectedFiles = ref([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length))
|
||||
const componentDocuments = computed(() => props.component.documents || [])
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
const shouldInlinePdf = (document) => {
|
||||
if (!document || !isPdfDocument(document) || !document.path) {
|
||||
return false
|
||||
}
|
||||
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const appendPdfViewerParams = (src) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src || ''
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
const documentPreviewSrc = (document) => {
|
||||
if (!document?.path) {
|
||||
return ''
|
||||
}
|
||||
if (isPdfDocument(document)) {
|
||||
return appendPdfViewerParams(document.path)
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
const documentThumbnailClass = (document) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
}
|
||||
return 'h-16 w-16'
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
}
|
||||
|
||||
// --- Child components ---
|
||||
const childComponents = computed(() => {
|
||||
const list = props.component.subcomponents || props.component.subComponents || []
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const buildProductDisplay = (product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? product.constructeurs
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name) => typeof name === 'string' && name.trim().length > 0)
|
||||
.join(', ')
|
||||
: product.supplierLabel || null
|
||||
|
||||
const priceValue =
|
||||
product.supplierPrice ??
|
||||
product.price ??
|
||||
product.priceLabel ??
|
||||
product.priceDisplay ??
|
||||
null
|
||||
|
||||
let price = null
|
||||
if (priceValue !== null && priceValue !== undefined) {
|
||||
const parsed = Number(priceValue)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = currencyFormatter.format(parsed)
|
||||
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
|
||||
price = priceValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name:
|
||||
product.name ||
|
||||
product.label ||
|
||||
product.reference ||
|
||||
product.productName ||
|
||||
null,
|
||||
reference: product.reference || null,
|
||||
category: product.typeProduct?.name || product.category || null,
|
||||
suppliers,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
const displayProduct = computed(() => {
|
||||
const explicit = props.component.product || null
|
||||
const normalized = buildProductDisplay(explicit)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
const fallback = props.component.__productDisplay
|
||||
if (fallback) {
|
||||
return {
|
||||
name: fallback.name || null,
|
||||
reference: fallback.reference || null,
|
||||
category: fallback.category || null,
|
||||
suppliers: fallback.suppliers || null,
|
||||
price: fallback.price || null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (displayProduct.value?.name) {
|
||||
return displayProduct.value.name
|
||||
}
|
||||
return (
|
||||
props.component.product?.name ||
|
||||
props.component.productName ||
|
||||
props.component.productLabel ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const displayProductCategory = computed(() => displayProduct.value?.category || null)
|
||||
const displayProductReference = computed(() => displayProduct.value?.reference || null)
|
||||
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
|
||||
const displayProductPrice = computed(() => displayProduct.value?.price || null)
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return []
|
||||
}
|
||||
const rows = []
|
||||
if (displayProductReference.value) {
|
||||
rows.push({ label: 'Référence', value: displayProductReference.value })
|
||||
}
|
||||
if (displayProductPrice.value) {
|
||||
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
|
||||
}
|
||||
if (displayProductSuppliers.value) {
|
||||
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
|
||||
}
|
||||
if (displayProductCategory.value) {
|
||||
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product = props.component.product
|
||||
return Array.isArray(product?.documents) ? product.documents : []
|
||||
})
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.component,
|
||||
@@ -651,332 +549,8 @@ const componentConstructeursDisplay = computed(() =>
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
const extractStructureCustomFields = (structure) => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const customFields = structure.customFields
|
||||
return Array.isArray(customFields) ? customFields : []
|
||||
}
|
||||
|
||||
function fieldKeyFromNameAndType(name, type) {
|
||||
const normalizedName =
|
||||
typeof name === 'string' ? name.trim().toLowerCase() : ''
|
||||
const normalizedType =
|
||||
typeof type === 'string' ? type.trim().toLowerCase() : ''
|
||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
||||
}
|
||||
|
||||
function resolveOrderIndex(field) {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return 0
|
||||
}
|
||||
if (typeof field.orderIndex === 'number') {
|
||||
return field.orderIndex
|
||||
}
|
||||
if (
|
||||
field.customField &&
|
||||
typeof field.customField.orderIndex === 'number'
|
||||
) {
|
||||
return field.customField.orderIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function deduplicateFieldDefinitions(definitions) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
|
||||
const orderedDefinitions = (Array.isArray(definitions)
|
||||
? definitions.slice()
|
||||
: []
|
||||
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedDefinitions.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const id =
|
||||
field.id ??
|
||||
field.customFieldId ??
|
||||
field.customField?.id ??
|
||||
null
|
||||
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
|
||||
if (!id && !nameKey) {
|
||||
return
|
||||
}
|
||||
const key = id || nameKey
|
||||
if (key && seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (key) {
|
||||
seen.add(key)
|
||||
}
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function mergeFieldDefinitionsWithValues(definitions, values) {
|
||||
const definitionList = Array.isArray(definitions) ? definitions : []
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const valueMap = new Map()
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
if (fieldId) {
|
||||
valueMap.set(fieldId, entry)
|
||||
}
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
if (nameKey) {
|
||||
valueMap.set(nameKey, entry)
|
||||
}
|
||||
})
|
||||
|
||||
const merged = definitionList.map((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return field
|
||||
}
|
||||
|
||||
const fieldId = ensureCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
||||
|
||||
const matchedValue =
|
||||
(fieldId ? valueMap.get(fieldId) : undefined) ??
|
||||
(nameKey ? valueMap.get(nameKey) : undefined)
|
||||
|
||||
if (!matchedValue) {
|
||||
return {
|
||||
...field,
|
||||
value: field?.value ?? '',
|
||||
orderIndex: resolveOrderIndex(field),
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedOrder = Math.min(
|
||||
resolveOrderIndex(field),
|
||||
resolveOrderIndex(matchedValue.customField),
|
||||
)
|
||||
|
||||
return {
|
||||
...field,
|
||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||
customFieldId:
|
||||
matchedValue.customField?.id ??
|
||||
matchedValue.customFieldId ??
|
||||
fieldId ??
|
||||
null,
|
||||
customField: matchedValue.customField ?? field.customField ?? null,
|
||||
value: matchedValue.value ?? field.value ?? '',
|
||||
orderIndex: resolvedOrder,
|
||||
}
|
||||
})
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
|
||||
const exists = merged.some((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
|
||||
return true
|
||||
}
|
||||
const existingId = ensureCustomFieldId(field)
|
||||
if (fieldId && existingId && existingId === fieldId) {
|
||||
return true
|
||||
}
|
||||
if (!fieldId && nameKey) {
|
||||
return (
|
||||
fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
merged.push({
|
||||
customFieldValueId: entry.id ?? null,
|
||||
customFieldId: fieldId,
|
||||
name: entry.customField?.name ?? '',
|
||||
type: entry.customField?.type ?? 'text',
|
||||
required: entry.customField?.required ?? false,
|
||||
options: entry.customField?.options ?? [],
|
||||
value: entry.value ?? '',
|
||||
customField: entry.customField ?? null,
|
||||
orderIndex: resolveOrderIndex(entry.customField),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
function dedupeMergedFields(fields) {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields)
|
||||
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
: []
|
||||
}
|
||||
|
||||
const seen = new Map()
|
||||
const result = []
|
||||
|
||||
const orderedFields = fields
|
||||
.slice()
|
||||
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedFields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const rawName = resolveFieldName(field)
|
||||
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
if (!normalizedName) {
|
||||
return
|
||||
}
|
||||
field.name = normalizedName
|
||||
field.type = resolveFieldType(field)
|
||||
|
||||
const fieldId = ensureCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
|
||||
const key = fieldId || nameKey
|
||||
|
||||
if (!key) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = seen.get(key)
|
||||
if (!existing) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
seen.set(key, field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existingHasValue =
|
||||
existing.value !== undefined &&
|
||||
existing.value !== null &&
|
||||
String(existing.value).trim().length > 0
|
||||
|
||||
const incomingHasValue =
|
||||
field.value !== undefined &&
|
||||
field.value !== null &&
|
||||
String(field.value).trim().length > 0
|
||||
|
||||
if (!existingHasValue && incomingHasValue) {
|
||||
Object.assign(existing, field)
|
||||
existing.orderIndex = Math.min(
|
||||
resolveOrderIndex(existing),
|
||||
resolveOrderIndex(field),
|
||||
)
|
||||
seen.set(key, existing)
|
||||
}
|
||||
})
|
||||
|
||||
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
const componentDefinitionSources = computed(() => {
|
||||
const requirement = props.component.typeMachineComponentRequirement || {}
|
||||
const type = requirement.typeComposant || props.component.typeComposant || {}
|
||||
|
||||
const definitions = []
|
||||
const pushFields = (collection) => {
|
||||
if (Array.isArray(collection)) {
|
||||
definitions.push(...collection)
|
||||
}
|
||||
}
|
||||
|
||||
pushFields(props.component.customFields)
|
||||
pushFields(props.component.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
pushFields(requirement.customFields)
|
||||
pushFields(requirement.definition?.customFields)
|
||||
|
||||
;[
|
||||
props.component.definition?.structure,
|
||||
type.structure,
|
||||
type.componentSkeleton,
|
||||
requirement.structure,
|
||||
requirement.componentSkeleton,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) {
|
||||
definitions.push(...fields)
|
||||
}
|
||||
})
|
||||
|
||||
return deduplicateFieldDefinitions(definitions)
|
||||
})
|
||||
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
componentDefinitionSources.value,
|
||||
props.component.customFieldValues,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() => {
|
||||
const map = new Map()
|
||||
const register = (collection) => {
|
||||
if (!Array.isArray(collection)) {
|
||||
return
|
||||
}
|
||||
collection.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return
|
||||
}
|
||||
const id = item.id || item.customFieldId
|
||||
const name = typeof item.name === 'string' ? item.name : null
|
||||
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
||||
if (!key || map.has(key)) {
|
||||
return
|
||||
}
|
||||
map.set(key, item)
|
||||
})
|
||||
}
|
||||
|
||||
register(props.component.customFieldValues?.map((value) => value?.customField))
|
||||
register(componentDefinitionSources.value)
|
||||
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const handleConstructeurChange = async (value) => {
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
|
||||
props.component.constructeurIds = [...ids]
|
||||
props.component.constructeurId = null
|
||||
props.component.constructeur = null
|
||||
@@ -985,42 +559,10 @@ const handleConstructeurChange = async (value) => {
|
||||
constructeurs.value,
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
)
|
||||
|
||||
await updateComponent()
|
||||
}
|
||||
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
||||
const {
|
||||
updateCustomFieldValue: updateComponentCustomFieldValueApi,
|
||||
upsertCustomFieldValue: upsertComponentCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) {
|
||||
ensureDocumentsLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.component.documents,
|
||||
(docs) => {
|
||||
documentsLoaded.value = !!(docs && docs.length)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) {
|
||||
ensureDocumentsLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update / Event forwarding ---
|
||||
const updateComponent = () => {
|
||||
emit('update', {
|
||||
...props.component,
|
||||
@@ -1028,216 +570,6 @@ const updateComponent = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function resolveFieldKey(field, index) {
|
||||
return field?.id
|
||||
?? field?.customFieldValueId
|
||||
?? field?.customFieldId
|
||||
?? field?.name
|
||||
?? `field-${index}`
|
||||
}
|
||||
|
||||
function resolveFieldId(field) {
|
||||
return field?.customFieldValueId ?? null
|
||||
}
|
||||
|
||||
function resolveFieldName(field) {
|
||||
return field?.name ?? 'Champ'
|
||||
}
|
||||
|
||||
function resolveFieldType(field) {
|
||||
return field?.type ?? 'text'
|
||||
}
|
||||
|
||||
function resolveFieldOptions(field) {
|
||||
return field?.options ?? []
|
||||
}
|
||||
|
||||
function resolveFieldRequired(field) {
|
||||
return !!field?.required
|
||||
}
|
||||
|
||||
function resolveFieldReadOnly(field) {
|
||||
return !!field?.readOnly
|
||||
}
|
||||
|
||||
function buildCustomFieldMetadata(field) {
|
||||
return {
|
||||
customFieldName: resolveFieldName(field),
|
||||
customFieldType: resolveFieldType(field),
|
||||
customFieldRequired: resolveFieldRequired(field),
|
||||
customFieldOptions: resolveFieldOptions(field)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCustomFieldId(field) {
|
||||
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
||||
}
|
||||
|
||||
function ensureCustomFieldId(field) {
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (existingId) {
|
||||
return existingId
|
||||
}
|
||||
|
||||
const name = resolveFieldName(field)
|
||||
if (!name || name === 'Champ') {
|
||||
return null
|
||||
}
|
||||
|
||||
const matches = candidateCustomFields.value.filter((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidateId = candidate.id || candidate.customFieldId
|
||||
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
|
||||
return true
|
||||
}
|
||||
return typeof candidate.name === 'string' && candidate.name === name
|
||||
})
|
||||
|
||||
if (matches.length) {
|
||||
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
|
||||
const id = withId?.id || withId?.customFieldId || null
|
||||
if (id) {
|
||||
field.customFieldId = id
|
||||
}
|
||||
if (!field.customField && typeof withId === 'object') {
|
||||
field.customField = withId
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
const formatFieldDisplayValue = (field) => {
|
||||
const type = resolveFieldType(field)
|
||||
const rawValue = field?.value ?? ''
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true') return 'Oui'
|
||||
if (normalized === 'false') return 'Non'
|
||||
}
|
||||
return rawValue || 'Non défini'
|
||||
}
|
||||
|
||||
const updateComponentCustomField = async (field) => {
|
||||
if (!field || resolveFieldReadOnly(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (fieldValueId) {
|
||||
const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
|
||||
if (existingValue?.customField?.id) {
|
||||
field.customFieldId = existingValue.customField.id
|
||||
field.customField = existingValue.customField
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const customFieldId = ensureCustomFieldId(field)
|
||||
const fieldName = resolveFieldName(field)
|
||||
if (!props.component?.id) {
|
||||
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||
return
|
||||
}
|
||||
|
||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||
showError('Impossible de créer la valeur pour ce champ de composant')
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||
|
||||
const result = await upsertComponentCustomFieldValue(
|
||||
customFieldId,
|
||||
'composant',
|
||||
props.component.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
field.customField = newValue.customField
|
||||
}
|
||||
|
||||
if (Array.isArray(props.component.customFieldValues)) {
|
||||
const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
|
||||
if (index !== -1) {
|
||||
props.component.customFieldValues.splice(index, 1, newValue)
|
||||
} else {
|
||||
props.component.customFieldValues.push(newValue)
|
||||
}
|
||||
} else {
|
||||
props.component.customFieldValues = [newValue]
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||
|
||||
const definitions = Array.isArray(props.component.customFields)
|
||||
? [...props.component.customFields]
|
||||
: []
|
||||
const fieldIdentifier = ensureCustomFieldId(field)
|
||||
const existingIndex = definitions.findIndex((definition) => {
|
||||
const definitionId = ensureCustomFieldId(definition)
|
||||
if (fieldIdentifier && definitionId) {
|
||||
return definitionId === fieldIdentifier
|
||||
}
|
||||
return definition?.name === resolveFieldName(field)
|
||||
})
|
||||
|
||||
const updatedDefinition = {
|
||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||
customFieldValueId: field.customFieldValueId,
|
||||
customFieldId: fieldIdentifier,
|
||||
name: resolveFieldName(field),
|
||||
type: resolveFieldType(field),
|
||||
required: resolveFieldRequired(field),
|
||||
options: resolveFieldOptions(field),
|
||||
value: field.value ?? '',
|
||||
customField: field.customField ?? null,
|
||||
}
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||
} else {
|
||||
definitions.push(updatedDefinition)
|
||||
}
|
||||
|
||||
props.component.customFields = definitions
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePiece = (updatedPiece) => {
|
||||
emit('edit-piece', updatedPiece)
|
||||
}
|
||||
@@ -1250,87 +582,4 @@ const updatePieceCustomField = (fieldUpdate) => {
|
||||
emit('custom-field-update', fieldUpdate)
|
||||
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !props.component?.id) { return }
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(props.component.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
props.component.documents = result.data || []
|
||||
documentsLoaded.value = true
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files) => {
|
||||
if (!files.length || !props.component?.id) { return }
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { composantId: props.component.id }
|
||||
},
|
||||
{ updateStore: false }
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newDocs = result.data || []
|
||||
props.component.documents = [...newDocs, ...(props.component.documents || [])]
|
||||
documentsLoaded.value = true
|
||||
selectedFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId) => {
|
||||
if (!documentId) { return }
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadDocument = (doc) => {
|
||||
if (!doc?.path) { return }
|
||||
|
||||
if (doc.path.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = doc.path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
if (size === 0) { return '0 B' }
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,7 +115,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
if (Array.isArray(node.customFields)) {
|
||||
node.customFields = node.customFields.map((field: any) => {
|
||||
node.customFields = node.customFields.map((field: Record<string, any>) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return field
|
||||
}
|
||||
@@ -145,7 +145,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
|
||||
}
|
||||
|
||||
if (Array.isArray(node.subcomponents)) {
|
||||
node.subcomponents = node.subcomponents.map((sub: any) => {
|
||||
node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
|
||||
if (!sub || typeof sub !== 'object') {
|
||||
return sub
|
||||
}
|
||||
@@ -246,7 +246,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const loaders: Promise<any>[] = []
|
||||
const loaders: Promise<unknown>[] = []
|
||||
if (!availablePieceTypes.value.length) {
|
||||
loaders.push(loadPieceTypes())
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import { useApi } from '~/composables/useApi';
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
@@ -243,22 +244,6 @@ const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
||||
|
||||
const extractCollection = (payload: any): any[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member;
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member'];
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value;
|
||||
};
|
||||
@@ -362,7 +347,7 @@ const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = ''
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || (definition as any).typePiece?.id || null;
|
||||
definition.typePieceId || definition.typePiece?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
@@ -392,7 +377,7 @@ const fetchProductOptions = async (assignment: StructureProductAssignment, term
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || (definition as any).typeProduct?.id || null;
|
||||
definition.typeProductId || definition.typeProduct?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
@@ -447,14 +432,14 @@ const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel ||
|
||||
(definition as any).typePiece?.name ||
|
||||
definition.typePiece?.name ||
|
||||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
(definition as any).typePiece?.code ||
|
||||
definition.typePiece?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
@@ -483,7 +468,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typeProductId ||
|
||||
(definition as any).typeProduct?.id ||
|
||||
definition.typeProduct?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -494,7 +479,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typeProductId || (definition as any).typeProduct?.id) {
|
||||
if (definition.typeProductId || definition.typeProduct?.id) {
|
||||
return (
|
||||
product.typeProductId === requiredTypeId ||
|
||||
product.typeProduct?.id === requiredTypeId
|
||||
@@ -550,14 +535,14 @@ const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
addPart(definition.role);
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel ||
|
||||
(definition as any).typeProduct?.name ||
|
||||
definition.typeProduct?.name ||
|
||||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
(definition as any).typeProduct?.code ||
|
||||
definition.typeProduct?.code ||
|
||||
fallbackType?.code ||
|
||||
null;
|
||||
if (family) {
|
||||
@@ -617,7 +602,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
(definition as any).typePiece?.id ||
|
||||
definition.typePiece?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
@@ -628,7 +613,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typePieceId || (definition as any).typePiece?.id) {
|
||||
if (definition.typePieceId || definition.typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId ||
|
||||
piece.typePiece?.id === requiredTypeId
|
||||
|
||||
@@ -20,16 +20,16 @@
|
||||
</button>
|
||||
<div
|
||||
v-if="openDropdown"
|
||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||
>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-3 py-2 text-xs text-gray-500"
|
||||
>
|
||||
Aucun fournisseur trouvé
|
||||
</div>
|
||||
<button
|
||||
v-for="option in options"
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const options = ref<ConstructeurSummary[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let lastSearchTerm = ''
|
||||
|
||||
|
||||
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||
const seen = new Map<string, ConstructeurSummary>()
|
||||
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
|
||||
)
|
||||
|
||||
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||
const normalized = uniqueOptions([
|
||||
options.value = uniqueOptions([
|
||||
...normalizedInitialOptions.value,
|
||||
...items,
|
||||
])
|
||||
const limited = normalized.slice(0, 10)
|
||||
|
||||
selectedIds.value.forEach((id) => {
|
||||
if (!limited.some((item) => item.id === id)) {
|
||||
const match =
|
||||
normalized.find((item) => item.id === id) ||
|
||||
constructeurs.value.find((item) => item.id === id)
|
||||
if (match) {
|
||||
if (limited.length >= 10) {
|
||||
limited.pop()
|
||||
}
|
||||
limited.unshift(match)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
options.value = uniqueOptions([
|
||||
...normalizedInitialOptions.value,
|
||||
...limited,
|
||||
])
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
if (!term) return options.value
|
||||
return options.value.filter((option) =>
|
||||
(option.name ?? '').toLowerCase().includes(term)
|
||||
|| (option.email && option.email.toLowerCase().includes(term))
|
||||
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
||||
}
|
||||
|
||||
const ensureOptionsLoaded = async (force = false) => {
|
||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
||||
if (!force && constructeurs.value.length) {
|
||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && searchTerm.value === lastSearchTerm && options.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.value.length && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await searchConstructeurs(searchTerm.value)
|
||||
const result = await searchConstructeurs('')
|
||||
if (result.success) {
|
||||
applyOptions(extractDataArray(result.data))
|
||||
lastSearchTerm = searchTerm.value
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
openDropdown.value = true
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
if (!searchTerm.value && constructeurs.value.length) {
|
||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||
lastSearchTerm = ''
|
||||
return
|
||||
}
|
||||
if (searchTerm.value === lastSearchTerm) {
|
||||
return
|
||||
}
|
||||
const result = await searchConstructeurs(searchTerm.value)
|
||||
if (result.success) {
|
||||
applyOptions(extractDataArray(result.data))
|
||||
lastSearchTerm = searchTerm.value
|
||||
}
|
||||
}, 250)
|
||||
ensureOptionsLoaded()
|
||||
}
|
||||
|
||||
const toggleOption = (option: ConstructeurSummary) => {
|
||||
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmedName = createForm.value.name.trim()
|
||||
const duplicate = options.value.find(
|
||||
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
|
||||
)
|
||||
if (duplicate) {
|
||||
emitSelection([...selectedIds.value, duplicate.id])
|
||||
closeCreateModal()
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const payload: { name: string; email?: string; phone?: string } = {
|
||||
name: createForm.value.name,
|
||||
name: trimmedName,
|
||||
}
|
||||
if (createForm.value.email) {
|
||||
payload.email = createForm.value.email
|
||||
@@ -383,9 +356,6 @@ watch(
|
||||
constructeurs,
|
||||
(list) => {
|
||||
applyOptions((list as ConstructeurSummary[]) || [])
|
||||
if (!searchTerm.value) {
|
||||
lastSearchTerm = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -405,9 +375,6 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', clickHandler)
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
|
||||
@@ -55,16 +55,16 @@
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="fieldValues[field.id] === 'true'"
|
||||
@change="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
@@ -81,7 +81,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { reactive, onMounted, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
customFields: {
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,7 +118,6 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isFieldLocked(field)"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
@@ -511,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()
|
||||
|
||||
@@ -114,7 +114,6 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||||
<option value="text">Texte</option>
|
||||
@@ -541,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))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
@@ -91,11 +91,5 @@ onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
await loadPieceTypes()
|
||||
}
|
||||
console.log('[PieceRequirementsSection] pieceTypes loaded:', pieceTypes.value.map(t => ({ id: t.id, name: t.name })))
|
||||
console.log('[PieceRequirementsSection] requirements on mount:', props.modelValue.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
console.log('[PieceRequirementsSection] requirements updated:', newVal.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
41
app/components/common/ConfirmModal.vue
Normal file
41
app/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="confirmState.open"
|
||||
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-md mx-4 p-6 space-y-4">
|
||||
<h3 class="font-bold text-lg">
|
||||
{{ confirmState.title }}
|
||||
</h3>
|
||||
|
||||
<p class="whitespace-pre-line text-base-content/80">
|
||||
{{ confirmState.message }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ confirmState.cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="confirmState.dangerous ? 'btn-error' : 'btn-primary'"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmState.confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const { confirmState, handleConfirm, handleCancel } = useConfirm()
|
||||
</script>
|
||||
@@ -184,8 +184,7 @@ watch(
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
(newOptions) => {
|
||||
console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
|
||||
(_newOptions) => {
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
|
||||
425
app/components/layout/AppNavbar.vue
Normal file
425
app/components/layout/AppNavbar.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<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 }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</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 }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</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 indicator"
|
||||
>
|
||||
<span
|
||||
v-if="unresolvedCount > 0"
|
||||
class="indicator-item badge badge-warning badge-xs"
|
||||
>
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<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>
|
||||
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||
</li>
|
||||
<li v-if="isAdmin">
|
||||
<NuxtLink to="/admin" class="justify-between">
|
||||
Administration
|
||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/comments" class="justify-between">
|
||||
Commentaires
|
||||
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<IconLucideChevronRight v-else 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 { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
import { useComments } from '~/composables/useComments'
|
||||
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', '/comments'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/comments', label: 'Commentaires' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||
const { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
|
||||
const unresolvedCount = ref(0)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const refreshUnresolvedCount = async () => {
|
||||
if (!activeProfile.value) return
|
||||
unresolvedCount.value = await fetchUnresolvedCount()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshUnresolvedCount()
|
||||
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
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 roleLabel = computed(() => {
|
||||
if (isAdmin.value) return 'Admin'
|
||||
if (canEdit.value) return 'Gestionnaire'
|
||||
return 'Lecteur'
|
||||
})
|
||||
|
||||
const roleBadgeClass = computed(() => {
|
||||
if (isAdmin.value) return 'badge-error'
|
||||
if (canEdit.value) return 'badge-warning'
|
||||
return 'badge-info'
|
||||
})
|
||||
|
||||
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>
|
||||
78
app/components/machine/MachineDetailHeader.vue
Normal file
78
app/components/machine/MachineDetailHeader.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<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'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
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>
|
||||
184
app/components/machine/MachineInfoCard.vue
Normal file
184
app/components/machine/MachineInfoCard.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<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>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-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" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<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>
|
||||
@@ -16,6 +16,7 @@
|
||||
:dir="dir"
|
||||
:loading="loading"
|
||||
:show-category-tabs="allowCategorySwitch"
|
||||
:can-edit="canEdit"
|
||||
@update:category="onCategoryChange"
|
||||
@update:search="onSearchInput"
|
||||
@update:sort="onSortChange"
|
||||
@@ -29,12 +30,22 @@
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
:can-edit="canEdit"
|
||||
@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">
|
||||
@@ -92,11 +103,14 @@
|
||||
</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,
|
||||
@@ -105,6 +119,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.";
|
||||
@@ -123,11 +138,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);
|
||||
@@ -139,6 +171,7 @@ let activeController: AbortController | null = null;
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
const { canEdit } = usePermissions();
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
@@ -153,7 +186,7 @@ useHead(() => ({
|
||||
const extractErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object") {
|
||||
const maybeFetchError = error as {
|
||||
data?: any;
|
||||
data?: Record<string, unknown>;
|
||||
statusMessage?: string;
|
||||
message?: string;
|
||||
};
|
||||
@@ -208,8 +241,8 @@ const refresh = async ({
|
||||
total.value = response.total;
|
||||
offset.value = response.offset;
|
||||
limit.value = response.limit;
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
showError(extractErrorMessage(error));
|
||||
@@ -292,16 +325,19 @@ const openEditPage = (item: ModelType) => {
|
||||
});
|
||||
};
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = window.confirm(
|
||||
"Supprimer ce type ? Cette action est irréversible."
|
||||
);
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id);
|
||||
invalidateEntityTypeCache(item.category);
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
@@ -363,22 +399,6 @@ const relatedModalSubtitle = computed(() => {
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
|
||||
const extractCollection = (payload: any): any[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member;
|
||||
}
|
||||
if (Array.isArray(payload?.["hydra:member"])) {
|
||||
return payload["hydra:member"];
|
||||
}
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
@@ -401,22 +421,26 @@ const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: any): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object" || typeof item.id !== "string") {
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof item.name === "string" && item.name.trim()
|
||||
? item.name
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof item.reference === "string" && item.reference.trim()
|
||||
? item.reference
|
||||
: typeof item.code === "string" && item.code.trim()
|
||||
? item.code
|
||||
typeof record.reference === "string" && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === "string" && record.code.trim()
|
||||
? record.code
|
||||
: null;
|
||||
return {
|
||||
id: item.id,
|
||||
id: record.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
@@ -473,6 +497,26 @@ 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) => {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
class="select select-bordered w-full"
|
||||
name="category"
|
||||
required
|
||||
:disabled="lockCategory"
|
||||
:disabled="lockCategory || isReadonly"
|
||||
>
|
||||
<option value="COMPONENT">Composants</option>
|
||||
<option value="PIECE">Pièces</option>
|
||||
@@ -134,7 +134,7 @@
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
|
||||
disableSubmitMessage?: string
|
||||
restrictedMode?: boolean
|
||||
restrictedModeMessage?: string
|
||||
readonly?: boolean
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
|
||||
disableSubmitMessage: '',
|
||||
restrictedMode: false,
|
||||
restrictedModeMessage: '',
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
|
||||
? props.disableSubmitMessage
|
||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||
)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
|
||||
const restrictedModeMessage = computed(() =>
|
||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||
? props.restrictedModeMessage
|
||||
@@ -278,10 +281,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
|
||||
@@ -290,7 +294,7 @@ const resetForm = () => {
|
||||
}
|
||||
|
||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
|
||||
|
||||
const validate = () => {
|
||||
errors.name = undefined
|
||||
@@ -307,6 +311,7 @@ const validate = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isReadonly.value) return
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,10 +48,19 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && 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>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
@@ -78,10 +87,19 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && 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>
|
||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
Supprimer
|
||||
</button>
|
||||
</footer>
|
||||
@@ -118,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<{
|
||||
@@ -126,15 +145,22 @@ const props = defineProps<{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: ModelCategory;
|
||||
canEdit?: boolean;
|
||||
}>();
|
||||
|
||||
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',
|
||||
|
||||
@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
|
||||
type SortField = 'name' | 'createdAt';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = defineProps<{
|
||||
category: ModelCategory;
|
||||
search: string;
|
||||
sort: SortField;
|
||||
dir: SortDirection;
|
||||
loading?: boolean;
|
||||
showCategoryTabs?: boolean;
|
||||
canEdit?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||
Modifier
|
||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
|
||||
import IconLucideUser from '~icons/lucide/user'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const props = defineProps({
|
||||
site: {
|
||||
type: Object,
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldPhone v-model="contactPhone" required />
|
||||
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -24,6 +25,7 @@
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -38,6 +40,7 @@
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -51,6 +54,7 @@
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -77,6 +81,10 @@ const props = defineProps({
|
||||
type: Object as PropType<SiteForm>,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const form = toRef(props, 'form')
|
||||
|
||||
@@ -12,17 +12,18 @@
|
||||
type="text"
|
||||
placeholder="Ex: Usine principale"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="siteRef" />
|
||||
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||
Créer le site
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,6 +54,10 @@ const props = defineProps({
|
||||
site: {
|
||||
type: Object as PropType<SiteForm>,
|
||||
required: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="visible" class="modal modal-open">
|
||||
<div class="modal-box max-w-md">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Modifier le site
|
||||
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
||||
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
||||
</h3>
|
||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||
@@ -15,11 +15,12 @@
|
||||
type="text"
|
||||
placeholder="Nom du site"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="props.form" />
|
||||
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||
|
||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -37,6 +38,7 @@
|
||||
</div>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="!disabled"
|
||||
v-model="selectedFilesModel"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
||||
@@ -90,7 +92,7 @@
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
||||
Télécharger
|
||||
</button>
|
||||
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,7 +105,7 @@
|
||||
<button type="button" class="btn" @click="emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
|
||||
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
||||
Enregistrer
|
||||
</button>
|
||||
@@ -155,6 +157,10 @@ const props = defineProps({
|
||||
formatSize: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
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 }
|
||||
}
|
||||
80
app/composables/useAdminProfiles.ts
Normal file
80
app/composables/useAdminProfiles.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
export interface AdminProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string | null
|
||||
isActive: boolean
|
||||
hasPassword: boolean
|
||||
roles: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function useAdminProfiles() {
|
||||
const { get, post, put } = useApi()
|
||||
const profiles = ref<AdminProfile[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get<AdminProfile[]>('/admin/profiles')
|
||||
if (result.success && result.data) {
|
||||
profiles.value = result.data
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createProfile = async (data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
password?: string
|
||||
role?: string
|
||||
}) => {
|
||||
const result = await post<AdminProfile>('/admin/profiles', data)
|
||||
if (result.success) {
|
||||
await fetchAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const updateRole = async (id: string, role: string) => {
|
||||
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
|
||||
if (result.success) {
|
||||
await fetchAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const setPassword = async (id: string, password: string) => {
|
||||
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
|
||||
if (result.success) {
|
||||
await fetchAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const deactivateProfile = async (id: string) => {
|
||||
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
|
||||
if (result.success) {
|
||||
await fetchAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loading,
|
||||
fetchAll,
|
||||
createProfile,
|
||||
updateRole,
|
||||
setPassword,
|
||||
deactivateProfile,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
130
app/composables/useApi.ts
Normal file
130
app/composables/useApi.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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 = response.status === 403
|
||||
? 'Permissions insuffisantes pour cette action.'
|
||||
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage, status: response.status }
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
||||
showError(`Erreur réseau: ${errorMessage}`)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
const get = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'GET' })
|
||||
}
|
||||
|
||||
const post = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const patch = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const put = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return {
|
||||
apiCall,
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
put,
|
||||
delete: del,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export function useCategoryEditGuard (config: GuardConfig) {
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
@@ -80,9 +80,9 @@ export function useCategoryEditGuard (config: GuardConfig) {
|
||||
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, mais pas modifier ou supprimer les existants.`
|
||||
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
||||
}
|
||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
|
||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
|
||||
})
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
|
||||
184
app/composables/useComments.ts
Normal file
184
app/composables/useComments.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
content: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName?: string | null
|
||||
authorId: string
|
||||
authorName: string
|
||||
status: 'open' | 'resolved'
|
||||
resolvedById?: string | null
|
||||
resolvedByName?: string | null
|
||||
resolvedAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface CommentResult {
|
||||
success: boolean
|
||||
data?: Comment | Comment[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface CommentListResult {
|
||||
success: boolean
|
||||
data?: Comment[]
|
||||
total?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useComments() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchComments = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
status: string = 'open',
|
||||
): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
entityType,
|
||||
entityId,
|
||||
status,
|
||||
'order[createdAt]': 'desc',
|
||||
itemsPerPage: '200',
|
||||
})
|
||||
const result = await get(`/comments?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection<Comment>(result.data)
|
||||
return { success: true, data: items }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllComments = async (options: {
|
||||
status?: string
|
||||
entityType?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
} = {}): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
params.set('order[createdAt]', 'desc')
|
||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||
params.set('page', String(options.page || 1))
|
||||
|
||||
const result = await get(`/comments?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection<Comment>(result.data)
|
||||
const raw = result.data as Record<string, unknown> | null
|
||||
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
||||
return { success: true, data: items, total }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComment = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
content: string,
|
||||
entityName?: string,
|
||||
): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: Record<string, string> = { entityType, entityId, content }
|
||||
if (entityName) payload.entityName = entityName
|
||||
const result = await post('/comments', payload)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire ajouté')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible d\'ajouter le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/comments/${commentId}/resolve`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire résolu')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de résoudre le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/comments/${commentId}`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire supprimé')
|
||||
return { success: true }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de supprimer le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||
try {
|
||||
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||
if (result.success && result.data) {
|
||||
return result.data.count
|
||||
}
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
fetchComments,
|
||||
fetchAllComments,
|
||||
createComment,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
fetchUnresolvedCount,
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ComponentHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
export type ComponentHistoryActor = EntityHistoryActor
|
||||
export type ComponentHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useComponentHistory() {
|
||||
return useEntityHistory('composant')
|
||||
}
|
||||
|
||||
export type ComponentHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ComponentHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ComponentHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useComponentHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ComponentHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (componentId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/composants/${componentId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ComponentHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
let hasWarned = false
|
||||
|
||||
const warnDeprecated = () => {
|
||||
if (hasWarned) return
|
||||
if (process.dev) {
|
||||
console.warn('[useComponentModels] Ce composable est conservé pour compatibilité mais les modèles ont été remplacés par les catégories enrichies de squelette. Utilisez useComponentTypes / useComposants à la place.')
|
||||
}
|
||||
hasWarned = true
|
||||
}
|
||||
|
||||
const buildUnsupportedResult = () => ({
|
||||
success: false,
|
||||
error: 'Les modèles de composants ont été retirés. Gérez les squelettes via les catégories et utilisez les requirements machine pour instancier des composants.'
|
||||
})
|
||||
|
||||
export function useComponentModels () {
|
||||
warnDeprecated()
|
||||
|
||||
const componentModelsBuckets = ref({})
|
||||
const loadingComponentModels = ref(false)
|
||||
|
||||
const componentModels = computed(() => [])
|
||||
|
||||
const noLongerSupported = async () => {
|
||||
warnDeprecated()
|
||||
return buildUnsupportedResult()
|
||||
}
|
||||
|
||||
const getComponentModels = () => componentModels.value
|
||||
const getComponentModelsForType = () => []
|
||||
const isComponentModelLoading = () => loadingComponentModels.value
|
||||
|
||||
return {
|
||||
componentModels,
|
||||
componentModelsBuckets,
|
||||
loadingComponentModels,
|
||||
loadComponentModels: noLongerSupported,
|
||||
createComponentModel: noLongerSupported,
|
||||
updateComponentModel: noLongerSupported,
|
||||
deleteComponentModel: noLongerSupported,
|
||||
getComponentModels,
|
||||
getComponentModelsForType,
|
||||
isComponentModelLoading
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes'
|
||||
/**
|
||||
* 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 ModelType {
|
||||
export interface ComponentType extends EntityType {
|
||||
structure: ComponentModelStructure | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface ComponentTypePayload {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
structure?: ComponentModelStructure | null
|
||||
}
|
||||
|
||||
interface ComponentTypeResult {
|
||||
success: boolean
|
||||
data?: ComponentType | ComponentType[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const componentTypes = ref<ComponentType[]>([])
|
||||
const loadingComponentTypes = ref(false)
|
||||
|
||||
export function useComponentTypes() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadComponentTypes = async (): Promise<ComponentTypeResult> => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'COMPONENT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
componentTypes.value = data.items.map((item) => ({
|
||||
...item,
|
||||
structure: item.structure as ComponentModelStructure | null,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
}))
|
||||
|
||||
return { success: true, data: componentTypes.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComponentType = async (payload: ComponentTypePayload): Promise<ComponentTypeResult> => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'COMPONENT',
|
||||
notes: payload.description ?? payload.notes ?? undefined,
|
||||
description: payload.description ?? undefined,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: ComponentType = {
|
||||
...data,
|
||||
structure: data.structure as ComponentModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
componentTypes.value.push(normalized)
|
||||
showSuccess(`Type de composant "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateComponentType = async (id: string, payload: ComponentTypePayload): Promise<ComponentTypeResult> => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description ?? undefined,
|
||||
notes: payload.notes ?? undefined,
|
||||
code: payload.code,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: ComponentType = {
|
||||
...data,
|
||||
structure: data.structure as ComponentModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
const index = componentTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1) {
|
||||
componentTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de composant "${data.name}" mis à jour`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComponentType = async (id: string): Promise<ComponentTypeResult> => {
|
||||
loadingComponentTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
componentTypes.value = componentTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess('Type de composant supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de composant: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingComponentTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getComponentTypes = () => componentTypes.value
|
||||
const isComponentTypeLoading = () => loadingComponentTypes.value
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'COMPONENT',
|
||||
label: 'composant',
|
||||
})
|
||||
|
||||
return {
|
||||
componentTypes,
|
||||
loadingComponentTypes,
|
||||
loadComponentTypes,
|
||||
createComponentType,
|
||||
updateComponentType,
|
||||
deleteComponentType,
|
||||
getComponentTypes,
|
||||
isComponentTypeLoading,
|
||||
componentTypes: types as Ref<ComponentType[]>,
|
||||
loadingComponentTypes: loading,
|
||||
loadComponentTypes: loadTypes,
|
||||
createComponentType: createType,
|
||||
updateComponentType: updateType,
|
||||
deleteComponentType: deleteType,
|
||||
getComponentTypes: () => types.value as ComponentType[],
|
||||
isComponentTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -39,28 +40,13 @@ interface LoadComposantsOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const composants = ref<Composant[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload: unknown): Composant[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Composant[]
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Composant[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Composant[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Composant[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
@@ -114,15 +100,31 @@ export function useComposants() {
|
||||
}
|
||||
|
||||
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 {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
@@ -140,6 +142,7 @@ export function useComposants() {
|
||||
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: {
|
||||
@@ -232,15 +235,23 @@ export function useComposants() {
|
||||
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,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Constructeur {
|
||||
id: string
|
||||
@@ -17,6 +18,7 @@ interface ConstructeurResult {
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
|
||||
const map = new Map<string, Constructeur>()
|
||||
@@ -52,30 +54,16 @@ const upsertConstructeurs = (items: Constructeur[] = []) => {
|
||||
const getIndexedConstructeur = (id: string): Constructeur | null =>
|
||||
constructeurs.value.find((item) => item && item.id === id) || null
|
||||
|
||||
const extractCollection = (payload: unknown): Constructeur[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Constructeur[]
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Constructeur[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Constructeur[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Constructeur[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const pendingFetches = new Map<string, Promise<Constructeur | null>>()
|
||||
|
||||
export function useConstructeurs() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const loadConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
|
||||
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
|
||||
if (!search && !options.force && loaded.value) {
|
||||
return { success: true, data: constructeurs.value }
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
@@ -83,6 +71,7 @@ export function useConstructeurs() {
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
if (!search) loaded.value = true
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,66 +1,75 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
|
||||
export function useCustomFields () {
|
||||
export interface CustomFieldValue {
|
||||
id: string
|
||||
customFieldId: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
value: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useCustomFields() {
|
||||
const { apiCall } = useApi()
|
||||
const customFieldValues = ref([])
|
||||
const customFieldValues = ref<CustomFieldValue[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Créer une valeur de champ personnalisé
|
||||
const createCustomFieldValue = async (customFieldValueData) => {
|
||||
const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(customFieldValueData)
|
||||
body: JSON.stringify(customFieldValueData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les valeurs de champs personnalisés pour une entité
|
||||
const getCustomFieldValuesByEntity = async (entityType, entityId) => {
|
||||
const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
|
||||
method: 'GET'
|
||||
method: 'GET',
|
||||
})
|
||||
if (result.success) {
|
||||
customFieldValues.value = result.data
|
||||
customFieldValues.value = result.data as CustomFieldValue[]
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une valeur de champ personnalisé
|
||||
const updateCustomFieldValue = async (id, updateData) => {
|
||||
const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData)
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||
const upsertCustomFieldValue = async (
|
||||
customFieldId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
metadata = {},
|
||||
) => {
|
||||
customFieldId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values/upsert', {
|
||||
method: 'POST',
|
||||
@@ -69,26 +78,26 @@ export function useCustomFields () {
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
...metadata
|
||||
})
|
||||
...metadata,
|
||||
}),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une valeur de champ personnalisé
|
||||
const deleteCustomFieldValue = async (id) => {
|
||||
const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +108,6 @@ export function useCustomFields () {
|
||||
getCustomFieldValuesByEntity,
|
||||
updateCustomFieldValue,
|
||||
upsertCustomFieldValue,
|
||||
deleteCustomFieldValue
|
||||
deleteCustomFieldValue,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -34,23 +35,6 @@ export interface DocumentResult {
|
||||
const documents = ref<Document[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload: unknown): Document[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Document[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Document[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Document[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -65,11 +49,12 @@ export function useDocuments() {
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
endpoint: string,
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get(endpoint)
|
||||
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||
const result = await get(url)
|
||||
if (result.success) {
|
||||
const data = extractCollection(result.data)
|
||||
if (updateStore) {
|
||||
@@ -92,9 +77,9 @@ export function useDocuments() {
|
||||
}
|
||||
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean } = {},
|
||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
465
app/composables/useMachineCreatePage.ts
Normal file
465
app/composables/useMachineCreatePage.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 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, addMissingCustomFields, deleteMachine } = 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) {
|
||||
const machineId = result.data?.id
|
||||
if (hasRequirements && machineId) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
} as any)
|
||||
if (!skeletonResult.success) {
|
||||
// Rollback: delete the orphaned machine
|
||||
await deleteMachine(machineId).catch(() => {})
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
|
||||
return
|
||||
}
|
||||
}
|
||||
// Initialize custom fields for the machine type
|
||||
if (machineId) {
|
||||
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
|
||||
}
|
||||
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({ itemsPerPage: 200, force: true }),
|
||||
loadPieces({ itemsPerPage: 200, force: true }),
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
@@ -17,14 +18,6 @@ export interface MachineCreateSelectionsDeps {
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
const extractCollection = (payload: unknown): unknown[] => {
|
||||
if (Array.isArray(payload)) return payload
|
||||
if (Array.isArray((payload as AnyRecord)?.member)) return (payload as AnyRecord).member as unknown[]
|
||||
if (Array.isArray((payload as AnyRecord)?.['hydra:member'])) return (payload as AnyRecord)['hydra:member'] as unknown[]
|
||||
if (Array.isArray((payload as AnyRecord)?.data)) return (payload as AnyRecord).data as unknown[]
|
||||
return []
|
||||
}
|
||||
|
||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||
|
||||
@@ -317,11 +310,12 @@ export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
pieceRequirementSelections[requirement.id as string] = Array.from(
|
||||
const entries = Array.from(
|
||||
{ length: initialCount },
|
||||
() => createPieceSelectionEntry(requirement),
|
||||
)
|
||||
pieceRequirementSelections[requirement.id as string].forEach((_: unknown, index: number) => {
|
||||
pieceRequirementSelections[requirement.id as string] = entries
|
||||
entries.forEach((_: unknown, index: number) => {
|
||||
fetchPieceOptions(requirement, index).catch(() => {})
|
||||
})
|
||||
} else {
|
||||
|
||||
1410
app/composables/useMachineDetailData.ts
Normal file
1410
app/composables/useMachineDetailData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
|
||||
*/
|
||||
|
||||
import { resolveIdentifier, resolveProductReference, getProductDisplay } from '~/shared/utils/productDisplayUtils'
|
||||
import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
|
||||
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
|
||||
import { resolveIdentifier } from '~/shared/utils/productDisplayUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
|
||||
838
app/composables/useMachineSkeletonEditor.ts
Normal file
838
app/composables/useMachineSkeletonEditor.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* Machine skeleton editor — selection state, validation & save logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue (F1.1).
|
||||
* Manages the reactive selection state for component / piece / product
|
||||
* skeleton requirements, validation, and reconfiguration API calls.
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import {
|
||||
resolveIdentifier,
|
||||
extractParentLinkIdentifiers,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface MachineSkeletonEditorDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
components: Ref<AnyRecord[]>
|
||||
pieces: Ref<AnyRecord[]>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
machineType: ComputedRef<AnyRecord | null>
|
||||
machineHasSkeletonRequirements: ComputedRef<boolean>
|
||||
componentRequirements: ComputedRef<AnyRecord[]>
|
||||
pieceRequirements: ComputedRef<AnyRecord[]>
|
||||
productRequirements: ComputedRef<AnyRecord[]>
|
||||
componentTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
pieceTypeLabelMap: ComputedRef<Map<string, string>>
|
||||
productInventory: ComputedRef<AnyRecord[]>
|
||||
flattenedComponents: ComputedRef<AnyRecord[]>
|
||||
machinePieces: ComputedRef<AnyRecord[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
|
||||
findPieceById: (id: string) => AnyRecord | null
|
||||
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
|
||||
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
|
||||
applyMachineLinks: (source: AnyRecord) => boolean
|
||||
collapseAllComponents: () => void
|
||||
initMachineFields: () => void
|
||||
collectPiecesForSkeleton: () => AnyRecord[]
|
||||
constructeurs: Ref<AnyRecord[]>
|
||||
loadProducts: () => Promise<void>
|
||||
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
|
||||
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
|
||||
}
|
||||
|
||||
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
|
||||
const {
|
||||
machine,
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
machineType,
|
||||
machineHasSkeletonRequirements,
|
||||
productRequirements,
|
||||
componentTypeLabelMap,
|
||||
pieceTypeLabelMap,
|
||||
productInventory,
|
||||
flattenedComponents,
|
||||
machineDocumentsLoaded,
|
||||
findProductById,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
applyMachineLinks,
|
||||
collapseAllComponents,
|
||||
initMachineFields,
|
||||
collectPiecesForSkeleton,
|
||||
loadProducts,
|
||||
reconfigureMachineSkeleton,
|
||||
toast,
|
||||
} = deps
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeMachineView = ref<'details' | 'skeleton'>('details')
|
||||
const isDetailsView = computed(() => activeMachineView.value === 'details')
|
||||
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const skeletonEditor = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (value: unknown): boolean =>
|
||||
Object.prototype.toString.call(value) === '[object Object]'
|
||||
|
||||
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
componentRequirementSelections[requirementId] || []
|
||||
|
||||
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
pieceRequirementSelections[requirementId] || []
|
||||
|
||||
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||
productRequirementSelections[requirementId] || []
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
|
||||
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||
}
|
||||
|
||||
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||
const typeId =
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
if (typeId) {
|
||||
const typeMatch = productRequirements.value.find(
|
||||
(req: AnyRecord) =>
|
||||
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
|
||||
)
|
||||
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
|
||||
return (typeMatch.typeProduct as AnyRecord).name as string
|
||||
}
|
||||
}
|
||||
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
|
||||
}
|
||||
|
||||
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
|
||||
const requirementTypeId =
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return (productInventory.value as AnyRecord[]).filter((product) => {
|
||||
if (!product?.id) return false
|
||||
if (!requirementTypeId) return true
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
return productTypeId === requirementTypeId
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection entry factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineComponentLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
|
||||
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
typeComposantId:
|
||||
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
||||
source?.typeComposantId ||
|
||||
(source?.typeComposant as AnyRecord)?.id ||
|
||||
requirement?.typeComposantId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machinePieceLink as AnyRecord) || null
|
||||
|
||||
const entry: AnyRecord = {
|
||||
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
|
||||
pieceId: resolveIdentifier(source?.pieceId, source?.id),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
|
||||
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
|
||||
typePieceId:
|
||||
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
||||
source?.typePieceId ||
|
||||
(source?.typePiece as AnyRecord)?.id ||
|
||||
requirement?.typePieceId ||
|
||||
null,
|
||||
definition: {
|
||||
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
|
||||
reference: source?.reference || '',
|
||||
constructeurIds: [] as string[],
|
||||
constructeurId: null as string | null,
|
||||
prix: source?.prix ?? source?.price ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
const defConstructeurIds = uniqueConstructeurIds(
|
||||
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||
(link?.overrides as AnyRecord)?.constructeurId,
|
||||
source?.constructeurIds,
|
||||
source?.constructeurId,
|
||||
source?.constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||
|
||||
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||
}
|
||||
|
||||
const finalConstructeurIds = uniqueConstructeurIds(
|
||||
(entry.definition as AnyRecord).constructeurIds,
|
||||
(entry.definition as AnyRecord).constructeurId,
|
||||
(entry.definition as AnyRecord).constructeur,
|
||||
)
|
||||
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||
const link = (source?.machineProductLink as AnyRecord) || source || null
|
||||
|
||||
return {
|
||||
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
|
||||
productId: resolveIdentifier(source?.productId, link?.productId),
|
||||
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
|
||||
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
|
||||
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
|
||||
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resetSkeletonRequirementSelections = () => {
|
||||
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
|
||||
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
|
||||
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
|
||||
}
|
||||
|
||||
const addComponentSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
componentRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createComponentSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getComponentRequirementEntries(requirementId)
|
||||
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeComposantId = value || null
|
||||
}
|
||||
|
||||
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addPieceSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pieceRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getPieceRequirementEntries(requirementId)
|
||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typePieceId = value || null
|
||||
}
|
||||
|
||||
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||
}
|
||||
|
||||
const addProductSelectionEntry = (requirement: AnyRecord) => {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length >= max) {
|
||||
toast.showError(
|
||||
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
productRequirementSelections[requirement.id as string] = [
|
||||
...entries,
|
||||
createProductSelectionEntry(requirement),
|
||||
]
|
||||
}
|
||||
|
||||
const removeProductSelectionEntry = (requirementId: string, index: number) => {
|
||||
const entries = getProductRequirementEntries(requirementId)
|
||||
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
const normalizedProductId = productId || null
|
||||
entry.productId = normalizedProductId
|
||||
if (normalizedProductId) {
|
||||
const product = findProductById(normalizedProductId)
|
||||
entry.typeProductId =
|
||||
(product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||
const entry = getProductRequirementEntries(requirementId)[index]
|
||||
if (!entry) return
|
||||
entry.typeProductId = value || entry.typeProductId || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initializeSkeletonRequirementSelections = async () => {
|
||||
skeletonEditor.loading = true
|
||||
try {
|
||||
resetSkeletonRequirementSelections()
|
||||
const type = machineType.value as AnyRecord
|
||||
if (!type) return
|
||||
|
||||
try {
|
||||
await loadProducts()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des produits pour le squelette:', error)
|
||||
}
|
||||
|
||||
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = flattenedComponents.value.filter(
|
||||
(c) => c.typeMachineComponentRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
|
||||
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const allPieces = collectPiecesForSkeleton()
|
||||
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const existing = allPieces.filter(
|
||||
(p) => p.typeMachinePieceRequirementId === requirement.id,
|
||||
)
|
||||
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
|
||||
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
|
||||
const existingProductLinks = Array.isArray(machineProductLinks.value)
|
||||
? machineProductLinks.value
|
||||
: Array.isArray(machine.value?.productLinks)
|
||||
? (machine.value.productLinks as AnyRecord[])
|
||||
: []
|
||||
|
||||
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||
const matches = existingProductLinks.filter((link) => {
|
||||
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
|
||||
return reqId === requirement.id
|
||||
})
|
||||
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
|
||||
if (entries.length) productRequirementSelections[requirement.id as string] = entries
|
||||
})
|
||||
} finally {
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor open/close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openSkeletonEditor = async () => {
|
||||
if (skeletonEditor.open) return
|
||||
skeletonEditor.open = true
|
||||
await initializeSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const closeSkeletonEditor = () => {
|
||||
if (!skeletonEditor.open) return
|
||||
if (skeletonEditor.submitting) return
|
||||
skeletonEditor.open = false
|
||||
skeletonEditor.loading = false
|
||||
skeletonEditor.submitting = false
|
||||
resetSkeletonRequirementSelections()
|
||||
}
|
||||
|
||||
const changeMachineView = async (view: 'details' | 'skeleton') => {
|
||||
if (view === activeMachineView.value) return
|
||||
|
||||
if (view === 'skeleton') {
|
||||
if (!machineHasSkeletonRequirements.value) {
|
||||
toast.showInfo('Aucun squelette configuré pour cette machine.')
|
||||
return
|
||||
}
|
||||
activeMachineView.value = 'skeleton'
|
||||
if (!skeletonEditor.open) {
|
||||
try {
|
||||
await openSkeletonEditor()
|
||||
} catch (error) {
|
||||
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
|
||||
toast.showError('Impossible de charger les éléments du squelette.')
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
closeSkeletonEditor()
|
||||
activeMachineView.value = 'details'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation & save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
|
||||
const usage = new Map<string, number>()
|
||||
|
||||
const increment = (typeProductId: string | null) => {
|
||||
if (!typeProductId) return
|
||||
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.composantId) return
|
||||
const component = findComponentById(components.value, entry.composantId as string)
|
||||
const typeProductId =
|
||||
((component?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.pieceId) return
|
||||
const piece = findPieceById(entry.pieceId as string)
|
||||
const typeProductId =
|
||||
((piece?.product as AnyRecord)?.typeProductId as string) ||
|
||||
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||
if (!entry?.productId) return
|
||||
const product = findProductById(entry.productId as string)
|
||||
const typeProductId =
|
||||
((product?.typeProductId as string) ||
|
||||
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry?.typeProductId as string) ||
|
||||
(requirement?.typeProductId as string) ||
|
||||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||
null)
|
||||
increment(typeProductId)
|
||||
})
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
const validateSkeletonSelections = (type: AnyRecord) => {
|
||||
const errors: string[] = []
|
||||
const componentLinksPayload: AnyRecord[] = []
|
||||
const pieceLinksPayload: AnyRecord[] = []
|
||||
const productLinksPayload: AnyRecord[] = []
|
||||
|
||||
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
componentLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
|
||||
if (entries.length < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||
}
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
|
||||
if (!resolvedTypeId) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.pieceId) payload.pieceId = entry.pieceId
|
||||
if (entry.composantId) payload.composantId = entry.composantId
|
||||
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||
if (overrides) payload.overrides = overrides
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
pieceLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
const productUsage = computeSkeletonProductUsage(type)
|
||||
|
||||
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||
const entries = getProductRequirementEntries(requirement.id as string)
|
||||
const max = (requirement.maxCount as number | null) ?? null
|
||||
if (max !== null && entries.length > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
|
||||
}
|
||||
|
||||
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
|
||||
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
|
||||
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||
|
||||
if (count < min) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
|
||||
}
|
||||
if (max !== null && count > max) {
|
||||
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.productId) {
|
||||
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||
return
|
||||
}
|
||||
const product = findProductById(entry.productId as string)
|
||||
if (!product) {
|
||||
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||
return
|
||||
}
|
||||
const productTypeId =
|
||||
(product.typeProductId as string) ||
|
||||
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||
(entry.typeProductId as string) ||
|
||||
null
|
||||
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
|
||||
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||
return
|
||||
}
|
||||
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
|
||||
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||
productLinksPayload.push(payload)
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { valid: false as const, error: errors[0] }
|
||||
return {
|
||||
valid: true as const,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply reconfiguration result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
|
||||
if (!data) return
|
||||
|
||||
const updatedMachine = (data.machine as AnyRecord) || data
|
||||
if (updatedMachine) {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...updatedMachine,
|
||||
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
|
||||
}
|
||||
initMachineFields()
|
||||
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
||||
}
|
||||
|
||||
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
|
||||
if (linksApplied) {
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
collapseAllComponents()
|
||||
return
|
||||
}
|
||||
|
||||
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newComponents)) {
|
||||
components.value = transformComponentCustomFields(newComponents)
|
||||
collapseAllComponents()
|
||||
}
|
||||
|
||||
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
|
||||
if (Array.isArray(newPieces)) {
|
||||
pieces.value = transformCustomFields(newPieces)
|
||||
}
|
||||
|
||||
const prodLinks =
|
||||
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
|
||||
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
|
||||
if (Array.isArray(prodLinks)) {
|
||||
machineProductLinks.value = prodLinks as AnyRecord[]
|
||||
if (machine.value) machine.value.productLinks = prodLinks
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const saveSkeletonConfiguration = async () => {
|
||||
if (!machine.value?.id) return
|
||||
|
||||
const type = machineType.value as AnyRecord
|
||||
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
|
||||
|
||||
if (type && machineHasSkeletonRequirements.value) {
|
||||
const validation = validateSkeletonSelections(type)
|
||||
if (!validation.valid) {
|
||||
toast.showError((validation as AnyRecord).error as string)
|
||||
return
|
||||
}
|
||||
payload = {
|
||||
componentLinks: (validation as AnyRecord).componentLinks,
|
||||
pieceLinks: (validation as AnyRecord).pieceLinks,
|
||||
productLinks: (validation as AnyRecord).productLinks,
|
||||
}
|
||||
}
|
||||
|
||||
skeletonEditor.submitting = true
|
||||
try {
|
||||
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
|
||||
if ((result as AnyRecord).success) {
|
||||
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
|
||||
await changeMachineView('details')
|
||||
} else if ((result as AnyRecord).error) {
|
||||
toast.showError((result as AnyRecord).error as string)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
|
||||
} finally {
|
||||
skeletonEditor.submitting = false
|
||||
skeletonEditor.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// View state
|
||||
activeMachineView,
|
||||
isDetailsView,
|
||||
isSkeletonView,
|
||||
|
||||
// Editor state
|
||||
skeletonEditor,
|
||||
componentRequirementSelections,
|
||||
pieceRequirementSelections,
|
||||
productRequirementSelections,
|
||||
|
||||
// Entry getters
|
||||
getComponentRequirementEntries,
|
||||
getPieceRequirementEntries,
|
||||
getProductRequirementEntries,
|
||||
|
||||
// Label resolvers
|
||||
resolveComponentRequirementTypeLabel,
|
||||
resolvePieceRequirementTypeLabel,
|
||||
resolveProductRequirementTypeLabel,
|
||||
getProductOptionsForRequirement,
|
||||
|
||||
// Selection CRUD
|
||||
addComponentSelectionEntry,
|
||||
removeComponentSelectionEntry,
|
||||
setComponentRequirementType,
|
||||
setComponentRequirementConstructeur,
|
||||
addPieceSelectionEntry,
|
||||
removePieceSelectionEntry,
|
||||
setPieceRequirementType,
|
||||
setPieceRequirementConstructeur,
|
||||
addProductSelectionEntry,
|
||||
removeProductSelectionEntry,
|
||||
setProductRequirementProduct,
|
||||
setProductRequirementType,
|
||||
|
||||
// Editor lifecycle
|
||||
openSkeletonEditor,
|
||||
closeSkeletonEditor,
|
||||
changeMachineView,
|
||||
initializeSkeletonRequirementSelections,
|
||||
|
||||
// Validation & save
|
||||
validateSkeletonSelections,
|
||||
saveSkeletonConfiguration,
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const machineTypes = ref([])
|
||||
export interface MachineTypeRequirement {
|
||||
id?: string
|
||||
label?: string
|
||||
minCount?: number
|
||||
maxCount?: number
|
||||
required?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MachineType {
|
||||
id: string
|
||||
name: string
|
||||
componentRequirements: MachineTypeRequirement[]
|
||||
pieceRequirements: MachineTypeRequirement[]
|
||||
productRequirements: MachineTypeRequirement[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machineTypes = ref<MachineType[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const normalizeRequirementList = (value, relationKey) => {
|
||||
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry, index) => {
|
||||
return value.map((entry: Record<string, unknown>, _index: number) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
const relationField = relationKey.replace('Id', '')
|
||||
const relationValue = normalized[relationField]
|
||||
console.log(`[normalizeRequirementList] Entry ${index}:`, {
|
||||
relationKey,
|
||||
relationField,
|
||||
hasRelationKey: !!normalized[relationKey],
|
||||
relationValue,
|
||||
relationValueType: typeof relationValue
|
||||
})
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationId = extractRelationId(relationValue)
|
||||
console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
console.log(`[normalizeRequirementList] Normalized entry:`, normalized)
|
||||
return normalized
|
||||
return normalized as MachineTypeRequirement
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMachineType = (type) => {
|
||||
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
|
||||
if (!type || typeof type !== 'object') {
|
||||
return type
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...type,
|
||||
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||
}
|
||||
} as MachineType
|
||||
}
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useMachineTypesApi () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useMachineTypesApi() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async () => {
|
||||
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/type_machines')
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
machineTypes.value = items.map(normalizeMachineType)
|
||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||
machineTypes.value = items
|
||||
.map((item) => normalizeMachineType(item as Record<string, unknown>))
|
||||
.filter((item): item is MachineType => item !== null)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des types de machines:', error)
|
||||
@@ -84,31 +82,32 @@ export function useMachineTypesApi () {
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineType = async (typeData) => {
|
||||
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/type_machines', typeData)
|
||||
if (result.success) {
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
if (normalized) machineTypes.value.push(normalized)
|
||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineType = async (id, typeData) => {
|
||||
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await put(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||
@@ -116,34 +115,34 @@ export function useMachineTypesApi () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachineType = async (id) => {
|
||||
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedType = machineTypes.value.find(type => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter(type => type.id !== id)
|
||||
const deletedType = machineTypes.value.find((type) => type.id === id)
|
||||
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypeById = async (id, forceRefresh = false) => {
|
||||
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
|
||||
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
||||
if (!forceRefresh) {
|
||||
const localType = machineTypes.value.find(type => type.id === id)
|
||||
const localType = machineTypes.value.find((type) => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
@@ -153,12 +152,12 @@ export function useMachineTypesApi () {
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
|
||||
// Mettre à jour le cache local
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
const index = machineTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1 && normalized) {
|
||||
machineTypes.value[index] = normalized
|
||||
} else {
|
||||
} else if (normalized) {
|
||||
machineTypes.value.push(normalized)
|
||||
}
|
||||
return { success: true, data: normalized }
|
||||
@@ -166,12 +165,12 @@ export function useMachineTypesApi () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du type de machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypes = () => machineTypes.value
|
||||
const isLoading = () => loading.value
|
||||
const getMachineTypes = (): MachineType[] => machineTypes.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machineTypes,
|
||||
@@ -182,6 +181,6 @@ export function useMachineTypesApi () {
|
||||
deleteMachineType,
|
||||
getMachineTypeById,
|
||||
getMachineTypes,
|
||||
isLoading
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
const machines = ref([])
|
||||
export interface Machine {
|
||||
id: string
|
||||
name?: string
|
||||
siteId?: string | null
|
||||
typeMachineId?: string | null
|
||||
componentLinks?: unknown[]
|
||||
pieceLinks?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machines = ref<Machine[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const resolveLinkCollection = (source, keys) => {
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
@@ -22,16 +34,17 @@ const resolveLinkCollection = (source, keys) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeMachineResponse = (payload) => {
|
||||
const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const container = payload.machine && typeof payload.machine === 'object'
|
||||
? payload.machine
|
||||
: payload
|
||||
const raw = payload as Record<string, unknown>
|
||||
const container = raw.machine && typeof raw.machine === 'object'
|
||||
? raw.machine as Record<string, unknown>
|
||||
: raw
|
||||
|
||||
const normalized = { ...container }
|
||||
const normalized: Record<string, unknown> = { ...container }
|
||||
|
||||
if (!normalized.siteId) {
|
||||
const siteId = extractRelationId(container.site)
|
||||
@@ -47,44 +60,35 @@ const normalizeMachineResponse = (payload) => {
|
||||
}
|
||||
}
|
||||
|
||||
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ??
|
||||
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||
[]
|
||||
const pieceLinks = resolveLinkCollection(payload, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
const pieceLinks = resolveLinkCollection(raw, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
[]
|
||||
|
||||
normalized.componentLinks = componentLinks
|
||||
normalized.pieceLinks = pieceLinks
|
||||
|
||||
return normalized
|
||||
return normalized as Machine
|
||||
}
|
||||
|
||||
export function useMachines () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachines = async () => {
|
||||
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/machines')
|
||||
if (result.success) {
|
||||
const machineList = Array.isArray(result.data)
|
||||
? result.data
|
||||
: Array.isArray(result.data?.member)
|
||||
? result.data.member
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member']
|
||||
: Array.isArray(result.data?.machines)
|
||||
? result.data.machines
|
||||
: Array.isArray(result.data?.data)
|
||||
? result.data.data
|
||||
: []
|
||||
const machineList = extractCollection(result.data)
|
||||
const normalized = machineList
|
||||
.map((item) => normalizeMachineResponse(item))
|
||||
.filter(Boolean)
|
||||
.filter((item): item is Machine => item !== null)
|
||||
machines.value = normalized
|
||||
showInfo(`Chargement de ${normalized.length} machine(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des machines:', error)
|
||||
@@ -93,14 +97,14 @@ export function useMachines () {
|
||||
}
|
||||
}
|
||||
|
||||
const createMachine = async (machineData) => {
|
||||
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await post('/machines', normalizedPayload)
|
||||
if (result.success) {
|
||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (createdMachine) {
|
||||
machines.value.push(createdMachine)
|
||||
@@ -111,34 +115,31 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachineFromType = async (machineData, typeMachine) => {
|
||||
// Créer la machine avec la structure héritée du type
|
||||
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
|
||||
const machineWithStructure = {
|
||||
...machineData,
|
||||
typeMachineId: typeMachine.id
|
||||
// La structure sera automatiquement héritée du type
|
||||
// Les composants et pièces seront créés automatiquement
|
||||
typeMachineId: typeMachine.id,
|
||||
}
|
||||
|
||||
return await createMachine(machineWithStructure)
|
||||
}
|
||||
|
||||
const updateMachineData = async (id, machineData) => {
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
const index = machines.value.findIndex(machine => machine.id === id)
|
||||
const index = machines.value.findIndex((machine) => machine.id === id)
|
||||
if (index !== -1 && updatedMachine) {
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
@@ -150,13 +151,13 @@ export function useMachines () {
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reconfigureSkeleton = async (machineId, payload) => {
|
||||
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Identifiant de machine manquant' }
|
||||
}
|
||||
@@ -165,28 +166,28 @@ export function useMachines () {
|
||||
try {
|
||||
const result = await patch(`/machines/${machineId}/skeleton`, payload)
|
||||
if (result.success) {
|
||||
const index = machines.value.findIndex(machine => machine.id === machineId)
|
||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||
if (index !== -1) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse(result.data?.machine) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
machines.value[index]
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
...(updatedMachine || {}),
|
||||
}
|
||||
...updatedMachine,
|
||||
} as Machine
|
||||
}
|
||||
showSuccess('Structure de la machine mise à jour avec succès')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMissingCustomFields = async (machineId, { showToast: shouldShowToast = true } = {}) => {
|
||||
const addMissingCustomFields = async (machineId: string, { showToast: shouldShowToast = true } = {}): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
const error = 'Identifiant de machine manquant'
|
||||
if (shouldShowToast) {
|
||||
@@ -206,46 +207,46 @@ export function useMachines () {
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l’ajout des champs personnalisés manquants:', error)
|
||||
console.error('Erreur lors de l\'ajout des champs personnalisés manquants:', error)
|
||||
if (shouldShowToast) {
|
||||
showError('Erreur lors de la complétion des champs personnalisés')
|
||||
}
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachine = async (id) => {
|
||||
const deleteMachine = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedMachine = machines.value.find(machine => machine.id === id)
|
||||
machines.value = machines.value.filter(machine => machine.id !== id)
|
||||
const deletedMachine = machines.value.find((machine) => machine.id === id)
|
||||
machines.value = machines.value.filter((machine) => machine.id !== id)
|
||||
showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la machine:', error)
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineById = (id) => {
|
||||
return machines.value.find(machine => machine.id === id)
|
||||
const getMachineById = (id: string): Machine | undefined => {
|
||||
return machines.value.find((machine) => machine.id === id)
|
||||
}
|
||||
|
||||
const getMachinesBySite = (siteId) => {
|
||||
return machines.value.filter(machine => machine.siteId === siteId)
|
||||
const getMachinesBySite = (siteId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||
}
|
||||
|
||||
const getMachinesByType = (typeMachineId) => {
|
||||
return machines.value.filter(machine => machine.typeMachineId === typeMachineId)
|
||||
const getMachinesByType = (typeMachineId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
|
||||
}
|
||||
|
||||
const getMachines = () => machines.value
|
||||
const isLoading = () => loading.value
|
||||
const getMachines = (): Machine[] => machines.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machines,
|
||||
@@ -261,6 +262,6 @@ export function useMachines () {
|
||||
getMachinesByType,
|
||||
getMachines,
|
||||
isLoading,
|
||||
addMissingCustomFields
|
||||
addMissingCustomFields,
|
||||
}
|
||||
}
|
||||
65
app/composables/useNavDropdown.ts
Normal file
65
app/composables/useNavDropdown.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
|
||||
export function useNavDropdown() {
|
||||
const openDropdown = ref<string | null>(null)
|
||||
let dropdownCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const route = useRoute()
|
||||
|
||||
const setDropdown = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
if (openDropdown.value !== name) {
|
||||
openDropdown.value = name
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDropdownClose = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
}
|
||||
dropdownCloseTimer = setTimeout(() => {
|
||||
if (openDropdown.value === name) {
|
||||
openDropdown.value = null
|
||||
}
|
||||
dropdownCloseTimer = null
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const closeDropdownNow = () => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
openDropdown.value = null
|
||||
}
|
||||
|
||||
const toggleDropdown = (name: string) => {
|
||||
if (openDropdown.value === name) {
|
||||
closeDropdownNow()
|
||||
return
|
||||
}
|
||||
setDropdown(name)
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
closeDropdownNow()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
openDropdown,
|
||||
setDropdown,
|
||||
scheduleDropdownClose,
|
||||
closeDropdownNow,
|
||||
toggleDropdown,
|
||||
}
|
||||
}
|
||||
41
app/composables/usePermissions.ts
Normal file
41
app/composables/usePermissions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { computed } from 'vue'
|
||||
import { useProfileSession } from './useProfileSession'
|
||||
|
||||
const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
|
||||
ROLE_USER: ['ROLE_USER'],
|
||||
}
|
||||
|
||||
export function usePermissions() {
|
||||
const { activeProfile } = useProfileSession()
|
||||
|
||||
const effectiveRoles = computed<string[]>(() => {
|
||||
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
|
||||
const all = new Set<string>()
|
||||
for (const role of roles) {
|
||||
const inherited = ROLE_HIERARCHY[role] ?? [role]
|
||||
for (const r of inherited) {
|
||||
all.add(r)
|
||||
}
|
||||
}
|
||||
return [...all]
|
||||
})
|
||||
|
||||
const isGranted = (role: string): boolean => {
|
||||
return effectiveRoles.value.includes(role)
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
|
||||
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
|
||||
const canView = computed(() => isGranted('ROLE_VIEWER'))
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
canEdit,
|
||||
canView,
|
||||
isGranted,
|
||||
effectiveRoles,
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type PieceHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
export type PieceHistoryActor = EntityHistoryActor
|
||||
export type PieceHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function usePieceHistory() {
|
||||
return useEntityHistory('piece')
|
||||
}
|
||||
|
||||
export type PieceHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: PieceHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): PieceHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function usePieceHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<PieceHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (pieceId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/pieces/${pieceId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as PieceHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
let hasWarned = false
|
||||
|
||||
const warnDeprecated = () => {
|
||||
if (hasWarned) return
|
||||
if (process.dev) {
|
||||
console.warn('[usePieceModels] Ce composable est conservé pour compatibilité mais les modèles ont été remplacés par les catégories enrichies de squelette. Utilisez usePieceTypes / usePieces à la place.')
|
||||
}
|
||||
hasWarned = true
|
||||
}
|
||||
|
||||
const buildUnsupportedResult = () => ({
|
||||
success: false,
|
||||
error: 'Les modèles de pièces ont été retirés. Gérez les squelettes via les catégories et utilisez les requirements machine pour instancier des pièces.'
|
||||
})
|
||||
|
||||
export function usePieceModels () {
|
||||
warnDeprecated()
|
||||
|
||||
const pieceModelsBuckets = ref({})
|
||||
const loadingPieceModels = ref(false)
|
||||
|
||||
const pieceModels = computed(() => [])
|
||||
|
||||
const noLongerSupported = async () => {
|
||||
warnDeprecated()
|
||||
return buildUnsupportedResult()
|
||||
}
|
||||
|
||||
const getPieceModels = () => pieceModels.value
|
||||
const getPieceModelsForType = () => []
|
||||
const isPieceModelLoading = () => loadingPieceModels.value
|
||||
|
||||
return {
|
||||
pieceModels,
|
||||
pieceModelsBuckets,
|
||||
loadingPieceModels,
|
||||
loadPieceModels: noLongerSupported,
|
||||
createPieceModel: noLongerSupported,
|
||||
updatePieceModel: noLongerSupported,
|
||||
deletePieceModel: noLongerSupported,
|
||||
getPieceModels,
|
||||
getPieceModelsForType,
|
||||
isPieceModelLoading
|
||||
}
|
||||
}
|
||||
@@ -1,164 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes'
|
||||
/**
|
||||
* 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 ModelType {
|
||||
export interface PieceType extends EntityType {
|
||||
structure: PieceModelStructure | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface PieceTypePayload {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
structure?: PieceModelStructure | null
|
||||
}
|
||||
|
||||
interface PieceTypeResult {
|
||||
success: boolean
|
||||
data?: PieceType | PieceType[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const pieceTypes = ref<PieceType[]>([])
|
||||
const loadingPieceTypes = ref(false)
|
||||
|
||||
export function usePieceTypes() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadPieceTypes = async (): Promise<PieceTypeResult> => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'PIECE',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
pieceTypes.value = data.items.map((item) => ({
|
||||
...item,
|
||||
structure: item.structure as PieceModelStructure | null,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
}))
|
||||
|
||||
return { success: true, data: pieceTypes.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createPieceType = async (payload: PieceTypePayload): Promise<PieceTypeResult> => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'PIECE',
|
||||
notes: payload.description ?? payload.notes ?? undefined,
|
||||
description: payload.description ?? undefined,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: PieceType = {
|
||||
...data,
|
||||
structure: data.structure as PieceModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
pieceTypes.value.push(normalized)
|
||||
showSuccess(`Type de pièce "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceType = async (id: string, payload: PieceTypePayload): Promise<PieceTypeResult> => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description ?? undefined,
|
||||
notes: payload.notes ?? undefined,
|
||||
code: payload.code,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: PieceType = {
|
||||
...data,
|
||||
structure: data.structure as PieceModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
const index = pieceTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1) {
|
||||
pieceTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de pièce "${data.name}" mis à jour`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePieceType = async (id: string): Promise<PieceTypeResult> => {
|
||||
loadingPieceTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
pieceTypes.value = pieceTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess('Type de pièce supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de pièce: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingPieceTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPieceTypes = () => pieceTypes.value
|
||||
const isPieceTypeLoading = () => loadingPieceTypes.value
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PIECE',
|
||||
label: 'pièce',
|
||||
})
|
||||
|
||||
return {
|
||||
pieceTypes,
|
||||
loadingPieceTypes,
|
||||
loadPieceTypes,
|
||||
createPieceType,
|
||||
updatePieceType,
|
||||
deletePieceType,
|
||||
getPieceTypes,
|
||||
isPieceTypeLoading,
|
||||
pieceTypes: types as Ref<PieceType[]>,
|
||||
loadingPieceTypes: loading,
|
||||
loadPieceTypes: loadTypes,
|
||||
createPieceType: createType,
|
||||
updatePieceType: updateType,
|
||||
deletePieceType: deleteType,
|
||||
getPieceTypes: () => types.value as PieceType[],
|
||||
isPieceTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -40,28 +41,13 @@ interface LoadPiecesOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const pieces = ref<Piece[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload: unknown): Piece[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Piece[]
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Piece[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Piece[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Piece[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
@@ -124,15 +110,31 @@ export function usePieces() {
|
||||
}
|
||||
|
||||
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 {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
@@ -150,6 +152,7 @@ export function usePieces() {
|
||||
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: {
|
||||
@@ -242,15 +245,23 @@ export function usePieces() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ProductHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
export type ProductHistoryActor = EntityHistoryActor
|
||||
export type ProductHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useProductHistory() {
|
||||
return useEntityHistory('product')
|
||||
}
|
||||
|
||||
export type ProductHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ProductHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ProductHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useProductHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ProductHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (productId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/products/${productId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ProductHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,159 +1,27 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes'
|
||||
/**
|
||||
* 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 ModelType {
|
||||
export interface ProductType extends EntityType {
|
||||
structure: ProductModelStructure | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface ProductTypePayload {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
structure?: ProductModelStructure | null
|
||||
}
|
||||
|
||||
interface ProductTypeResult {
|
||||
success: boolean
|
||||
data?: ProductType | ProductType[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const productTypes = ref<ProductType[]>([])
|
||||
const loadingProductTypes = ref(false)
|
||||
|
||||
export function useProductTypes() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
const loadProductTypes = async (): Promise<ProductTypeResult> => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category: 'PRODUCT',
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
productTypes.value = data.items.map((item) => ({
|
||||
...item,
|
||||
structure: item.structure as ProductModelStructure | null,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
}))
|
||||
|
||||
return { success: true, data: productTypes.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createProductType = async (payload: ProductTypePayload): Promise<ProductTypeResult> => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category: 'PRODUCT',
|
||||
notes: payload.description ?? payload.notes ?? undefined,
|
||||
description: payload.description ?? undefined,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: ProductType = {
|
||||
...data,
|
||||
structure: data.structure as ProductModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
productTypes.value.push(normalized)
|
||||
showSuccess(`Type de produit "${data.name}" créé`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateProductType = async (id: string, payload: ProductTypePayload): Promise<ProductTypeResult> => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description ?? undefined,
|
||||
notes: payload.notes ?? undefined,
|
||||
code: payload.code,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
|
||||
const normalized: ProductType = {
|
||||
...data,
|
||||
structure: data.structure as ProductModelStructure | null,
|
||||
description: data.description ?? data.notes ?? null,
|
||||
}
|
||||
|
||||
const index = productTypes.value.findIndex((type) => type.id === id)
|
||||
if (index !== -1) {
|
||||
productTypes.value[index] = normalized
|
||||
}
|
||||
showSuccess(`Type de produit "${data.name}" mis à jour`)
|
||||
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProductType = async (id: string): Promise<ProductTypeResult> => {
|
||||
loadingProductTypes.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
productTypes.value = productTypes.value.filter((type) => type.id !== id)
|
||||
showSuccess('Type de produit supprimé')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de produit: ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loadingProductTypes.value = false
|
||||
}
|
||||
}
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PRODUCT',
|
||||
label: 'produit',
|
||||
})
|
||||
|
||||
return {
|
||||
productTypes,
|
||||
loadingProductTypes,
|
||||
loadProductTypes,
|
||||
createProductType,
|
||||
updateProductType,
|
||||
deleteProductType,
|
||||
productTypes: types as Ref<ProductType[]>,
|
||||
loadingProductTypes: loading,
|
||||
loadProductTypes: loadTypes,
|
||||
createProductType: createType,
|
||||
updateProductType: updateType,
|
||||
deleteProductType: deleteType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 Product {
|
||||
id: string
|
||||
@@ -62,23 +63,6 @@ const replaceInCache = (item: Product): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
const extractCollection = (payload: unknown): Product[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Product[]
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Product[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Product[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Product[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
@@ -131,8 +115,16 @@ export function useProducts() {
|
||||
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,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseUrl = process.server
|
||||
? (config.apiBaseUrl || config.public.apiBaseUrl || '')
|
||||
: (config.public.apiBaseUrl || '')
|
||||
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)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) { return undefined }
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchCurrentProfile = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
activeProfile.value = null
|
||||
}
|
||||
} finally {
|
||||
sessionLoaded.value = true
|
||||
loading.value = false
|
||||
}
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = () => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
const activateProfile = async (profileId) => {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { profileId },
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders()
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
sessionLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfile,
|
||||
loading,
|
||||
sessionLoaded,
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
logout
|
||||
}
|
||||
}
|
||||
78
app/composables/useProfileSession.ts
Normal file
78
app/composables/useProfileSession.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useRuntimeConfig } from '#imports'
|
||||
import type { Profile } from './useProfiles'
|
||||
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
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 fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch (error) {
|
||||
const err = error as { status?: number }
|
||||
if (err?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
activeProfile.value = null
|
||||
}
|
||||
} finally {
|
||||
sessionLoaded.value = true
|
||||
loading.value = false
|
||||
}
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = (): Promise<Profile | null> => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
const activateProfile = async (profileId: string, password?: string): Promise<void> => {
|
||||
const body: Record<string, string> = { profileId }
|
||||
if (password) {
|
||||
body.password = password
|
||||
}
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
sessionLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfile,
|
||||
loading,
|
||||
sessionLoaded,
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
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
|
||||
}
|
||||
}
|
||||
49
app/composables/useProfiles.ts
Normal file
49
app/composables/useProfiles.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useRuntimeConfig } from '#imports'
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string | null
|
||||
isActive?: boolean
|
||||
hasPassword?: boolean
|
||||
roles?: 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 fetchProfiles = async (): Promise<Profile[]> => {
|
||||
loadingProfiles.value = true
|
||||
try {
|
||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loadingProfiles,
|
||||
profilesLoaded,
|
||||
fetchProfiles,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,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,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
@@ -23,38 +24,21 @@ interface SiteResult {
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload: unknown): Site[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as Site[]
|
||||
}
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) {
|
||||
return p.member as Site[]
|
||||
}
|
||||
if (Array.isArray(p?.['hydra:member'])) {
|
||||
return p['hydra:member'] as Site[]
|
||||
}
|
||||
if (Array.isArray(p?.data)) {
|
||||
return p.data as Site[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSites() {
|
||||
const { showSuccess, showInfo } = useToast()
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async (): Promise<void> => {
|
||||
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
console.log('sites api result', result)
|
||||
|
||||
if (result.success) {
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useProfileSession } from "#imports";
|
||||
import { useProfileSession, usePermissions } from "#imports";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { ensureSession, fetchCurrentProfile, activeProfile } =
|
||||
useProfileSession();
|
||||
const { ensureSession, activeProfile } = useProfileSession();
|
||||
await ensureSession();
|
||||
|
||||
const rawPath = to?.path ?? "";
|
||||
@@ -14,11 +13,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
fullPath.startsWith("/profiles") ||
|
||||
routeName.startsWith("profiles");
|
||||
|
||||
if (process.client && !activeProfile.value) {
|
||||
await fetchCurrentProfile();
|
||||
}
|
||||
|
||||
if (process.client && !activeProfile.value && !isProfilesRoute) {
|
||||
// Redirect to login if no active profile
|
||||
if (!activeProfile.value && !isProfilesRoute) {
|
||||
return navigateTo("/profiles");
|
||||
}
|
||||
|
||||
// Permission checks
|
||||
if (activeProfile.value) {
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
// Admin-only routes
|
||||
if (normalizedPath.startsWith("/admin")) {
|
||||
if (!isAdmin.value) {
|
||||
return navigateTo("/");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
245
app/pages/admin/index.vue
Normal file
245
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-6 max-w-6xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">
|
||||
Administration des profils
|
||||
</h1>
|
||||
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
|
||||
Nouveau profil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="profiles.length" class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Mot de passe</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in profiles" :key="profile.id">
|
||||
<td class="font-medium">
|
||||
{{ profile.firstName }} {{ profile.lastName }}
|
||||
</td>
|
||||
<td class="text-sm text-base-content/70">
|
||||
{{ profile.email || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="select select-bordered select-xs"
|
||||
:value="primaryRole(profile)"
|
||||
@change="handleRoleChange(profile.id, $event.target.value)"
|
||||
>
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs ml-1"
|
||||
@click="openPasswordDialog(profile.id)"
|
||||
>
|
||||
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
||||
>
|
||||
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="profile.isActive"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="handleDeactivate(profile.id)"
|
||||
>
|
||||
Desactiver
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12 text-base-content/60">
|
||||
Aucun profil.
|
||||
</div>
|
||||
|
||||
<!-- Create Profile Dialog -->
|
||||
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Nouveau profil
|
||||
</h3>
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Prenom</span></label>
|
||||
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Email</span></label>
|
||||
<input v-model="createForm.email" type="email" class="input input-bordered">
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Mot de passe</span></label>
|
||||
<input v-model="createForm.password" type="password" class="input input-bordered">
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Role</span></label>
|
||||
<select v-model="createForm.role" class="select select-bordered">
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" />
|
||||
Creer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" @click="showCreateDialog = false">
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Set Password Dialog -->
|
||||
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Definir le mot de passe
|
||||
</h3>
|
||||
<form @submit.prevent="handleSetPassword">
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
|
||||
<input v-model="newPassword" type="password" class="input input-bordered" required>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
|
||||
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
|
||||
Valider
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" @click="showPasswordDialog = false">
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAdminProfiles } from '#imports'
|
||||
|
||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showPasswordDialog = ref(false)
|
||||
const creating = ref(false)
|
||||
const settingPassword = ref(false)
|
||||
const passwordProfileId = ref(null)
|
||||
const newPassword = ref('')
|
||||
|
||||
const createForm = ref({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'ROLE_VIEWER',
|
||||
})
|
||||
|
||||
const primaryRole = (profile) => {
|
||||
const roles = profile.roles || []
|
||||
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
|
||||
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
|
||||
return 'ROLE_VIEWER'
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const data = { ...createForm.value }
|
||||
if (!data.email) { delete data.email }
|
||||
if (!data.password) { delete data.password }
|
||||
await createProfile(data)
|
||||
showCreateDialog.value = false
|
||||
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleChange = async (profileId, role) => {
|
||||
await updateRole(profileId, role)
|
||||
}
|
||||
|
||||
const openPasswordDialog = (profileId) => {
|
||||
passwordProfileId.value = profileId
|
||||
newPassword.value = ''
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
const handleSetPassword = async () => {
|
||||
if (!passwordProfileId.value) { return }
|
||||
settingPassword.value = true
|
||||
try {
|
||||
await setPassword(passwordProfileId.value, newPassword.value)
|
||||
showPasswordDialog.value = false
|
||||
} finally {
|
||||
settingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async (profileId) => {
|
||||
await deactivateProfile(profileId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
})
|
||||
</script>
|
||||
182
app/pages/changelog.vue
Normal file
182
app/pages/changelog.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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.1',
|
||||
date: '2026-02-12',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
|
||||
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
|
||||
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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>
|
||||
331
app/pages/comments.vue
Normal file
331
app/pages/comments.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold text-base-content">
|
||||
Commentaires
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Filtres -->
|
||||
<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="comment-status"
|
||||
>
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
id="comment-status"
|
||||
v-model="statusFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="open">
|
||||
Ouverts
|
||||
</option>
|
||||
<option value="resolved">
|
||||
Résolus
|
||||
</option>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="comment-entity-type"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="comment-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
<option value="machine">
|
||||
Machine
|
||||
</option>
|
||||
<option value="piece">
|
||||
Pièce
|
||||
</option>
|
||||
<option value="composant">
|
||||
Composant
|
||||
</option>
|
||||
<option value="product">
|
||||
Produit
|
||||
</option>
|
||||
<option value="piece_category">
|
||||
Catégorie pièce
|
||||
</option>
|
||||
<option value="component_category">
|
||||
Catégorie composant
|
||||
</option>
|
||||
<option value="product_category">
|
||||
Catégorie produit
|
||||
</option>
|
||||
<option value="machine_skeleton">
|
||||
Squelette machine
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="comment-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="comment-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">
|
||||
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingList" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Empty states -->
|
||||
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
||||
Aucun commentaire trouvé.
|
||||
</p>
|
||||
|
||||
<!-- Table -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contenu</th>
|
||||
<th>Type</th>
|
||||
<th>Item</th>
|
||||
<th>Auteur</th>
|
||||
<th>Date</th>
|
||||
<th>Statut</th>
|
||||
<th v-if="canEdit">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="hover"
|
||||
>
|
||||
<td class="max-w-xs">
|
||||
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{{ entityTypeLabel(comment.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="getEntityRoute(comment)"
|
||||
:to="getEntityRoute(comment)!"
|
||||
class="link link-primary text-sm font-medium"
|
||||
>
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm">
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{{ comment.authorName }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ formatCommentDate(comment.createdAt) }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||
>
|
||||
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="canEdit" @click.stop>
|
||||
<button
|
||||
v-if="comment.status === 'open'"
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(comment.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">
|
||||
{{ comment.resolvedByName }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page <= 1"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span class="flex items-center text-sm text-base-content/70">
|
||||
Page {{ page }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useComments, type Comment } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const {
|
||||
loading,
|
||||
fetchAllComments,
|
||||
resolveComment,
|
||||
} = useComments()
|
||||
|
||||
const comments = ref<Comment[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(20)
|
||||
const statusFilter = ref('open')
|
||||
const entityTypeFilter = ref('')
|
||||
const loadingList = ref(false)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
||||
)
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
machine: 'Machine',
|
||||
piece: 'Pièce',
|
||||
composant: 'Composant',
|
||||
product: 'Produit',
|
||||
piece_category: 'Cat. pièce',
|
||||
component_category: 'Cat. composant',
|
||||
product_category: 'Cat. produit',
|
||||
machine_skeleton: 'Squelette',
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string): string =>
|
||||
ENTITY_TYPE_LABELS[type] ?? type
|
||||
|
||||
const formatCommentDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
loadingList.value = true
|
||||
const result = await fetchAllComments({
|
||||
status: statusFilter.value || undefined,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
page: page.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
})
|
||||
if (result.success) {
|
||||
comments.value = result.data ?? []
|
||||
total.value = result.total ?? 0
|
||||
}
|
||||
loadingList.value = false
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
page.value = 1
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const goToPage = (p: number) => {
|
||||
page.value = p
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const handleResolve = async (commentId: string) => {
|
||||
const result = await resolveComment(commentId)
|
||||
if (result.success) {
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
||||
machine: (id: string) => `/machine/${id}`,
|
||||
piece: (id: string) => `/pieces/${id}/edit`,
|
||||
composant: (id: string) => `/component/${id}/edit`,
|
||||
product: (id: string) => `/product/${id}/edit`,
|
||||
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
||||
component_category: (id: string) => `/component-category/${id}/edit`,
|
||||
product_category: (id: string) => `/product-category/${id}/edit`,
|
||||
machine_skeleton: (id: string) => `/type/${id}`,
|
||||
}
|
||||
|
||||
const getEntityRoute = (comment: Comment): string | null => {
|
||||
const builder = ENTITY_ROUTE_MAP[comment.entityType]
|
||||
return builder ? builder(comment.entityId) : null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadComments()
|
||||
})
|
||||
</script>
|
||||
@@ -130,7 +130,16 @@
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>{{ resolveComponentType(component) }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="component.typeComposant?.id"
|
||||
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
@@ -140,6 +149,7 @@
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@@ -167,29 +177,43 @@
|
||||
</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 { canEdit } = usePermissions()
|
||||
const { showError } = useToast()
|
||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(30)
|
||||
// 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 state with debounce
|
||||
const searchTerm = ref('')
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
@@ -202,12 +226,6 @@ const debouncedSearch = () => {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Sort state
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'component-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
|
||||
// Enrichir les composants avec les types de composants complets
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
@@ -225,7 +243,8 @@ const fetchComposants = async () => {
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -327,7 +346,8 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
)
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(confirmLines.join('\n\n'))
|
||||
const { confirm } = useConfirm()
|
||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,7 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:readonly="!canEdit"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
@@ -34,6 +35,16 @@
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="component_category"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="initialData?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -42,12 +53,16 @@ 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 { canEdit } = usePermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -108,7 +123,7 @@ const loadCategory = async () => {
|
||||
code: response.code,
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
@@ -125,6 +140,7 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (!canEdit.value) return
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
@@ -136,6 +152,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>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
initial-category="COMPONENT"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:readonly="!canEdit"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -32,8 +33,11 @@ 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'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
useHead(() => ({
|
||||
title: 'Nouvelle catégorie de composant',
|
||||
}))
|
||||
@@ -49,6 +53,7 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||
if (!canEdit.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const enrichedPayload = {
|
||||
@@ -56,6 +61,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) {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6">
|
||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||
@@ -33,9 +33,9 @@
|
||||
Mettez à jour les informations du composant et ses champs personnalisés.
|
||||
</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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -72,7 +72,7 @@
|
||||
v-model="editionForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
@@ -88,7 +88,7 @@
|
||||
v-model="editionForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<ConstructeurSelect
|
||||
v-model="editionForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
:initial-options="component?.constructeurs || []"
|
||||
/>
|
||||
@@ -118,7 +118,7 @@
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
</div>
|
||||
@@ -277,7 +277,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
@@ -286,14 +286,14 @@
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
@@ -304,24 +304,24 @@
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
@@ -329,7 +329,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="saving"
|
||||
:disabled="!canEdit || saving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,7 +347,7 @@
|
||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedFiles"
|
||||
title="Déposer vos fichiers"
|
||||
@@ -419,6 +419,7 @@
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="uploadingDocuments"
|
||||
@@ -511,6 +512,16 @@
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="composant"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="component?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -558,7 +569,6 @@ import {
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
formatHistoryValue,
|
||||
historyDiffEntries as _historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
@@ -567,6 +577,7 @@ interface ComponentCatalogType extends ModelType {
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
@@ -577,7 +588,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
@@ -709,7 +720,7 @@ const refreshDocuments = async () => {
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = result.data || []
|
||||
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
@@ -747,6 +758,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value &&
|
||||
component.value &&
|
||||
editionForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
@@ -765,12 +777,10 @@ const fetchComponent = async () => {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
component.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadHistory(result.data.id)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
@@ -806,7 +816,9 @@ watch(
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
|
||||
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
|
||||
// the stale destructured currentStructure which was captured before the ID change.
|
||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
@@ -851,8 +863,8 @@ const submitEdition = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateComposant(component.value.id, payload)
|
||||
if (result.success) {
|
||||
const updatedComponent = result.data
|
||||
if (result.success && result.data) {
|
||||
const updatedComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
@@ -925,13 +937,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
@@ -968,13 +981,14 @@ const fetchProductTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedProductTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedProductTypeMap.value = next
|
||||
@@ -1129,14 +1143,15 @@ onMounted(async () => {
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500, force: true }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
if (component.value?.id) {
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
// Defer bulk catalog loads — not needed for initial render
|
||||
Promise.allSettled([
|
||||
loadPieces({ itemsPerPage: 200 }),
|
||||
loadProducts({ itemsPerPage: 200 }),
|
||||
loadComposants({ itemsPerPage: 200 }),
|
||||
]).catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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">
|
||||
@@ -28,7 +28,7 @@
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="typeOptionLabel"
|
||||
:option-description="typeOptionDescription"
|
||||
:disabled="loadingTypes || submitting"
|
||||
:disabled="!canEdit || loadingTypes || submitting"
|
||||
/>
|
||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||
Chargement des catégories…
|
||||
@@ -45,7 +45,7 @@
|
||||
v-model="creationForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="submitting || !selectedType"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
@@ -61,7 +61,7 @@
|
||||
v-model="creationForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="submitting || !selectedType"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
<ConstructeurSelect
|
||||
v-model="creationForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="submitting || !selectedType"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="submitting || !selectedType"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
</div>
|
||||
@@ -244,7 +244,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
@@ -253,14 +253,14 @@
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
@@ -271,24 +271,24 @@
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
@@ -296,7 +296,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
:disabled="!canEdit || submitting"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@
|
||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||
<DocumentUpload
|
||||
v-model="selectedDocuments"
|
||||
title="Déposer vos fichiers"
|
||||
@@ -401,6 +401,7 @@ const {
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
@@ -755,6 +756,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value &&
|
||||
selectedType.value &&
|
||||
creationForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
@@ -813,13 +815,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
@@ -1144,7 +1147,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]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Gérez les fournisseurs et leurs coordonnées.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Nouveau fournisseur
|
||||
</button>
|
||||
@@ -73,9 +73,9 @@
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
||||
Modifier
|
||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||
</button>
|
||||
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
||||
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
@@ -90,22 +90,22 @@
|
||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
|
||||
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||
</h3>
|
||||
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="form.name" type="text" class="input input-bordered" required>
|
||||
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FieldEmail v-model="form.email" label="Email" />
|
||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
||||
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeModal">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
||||
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
||||
</button>
|
||||
@@ -117,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
@@ -126,8 +126,9 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
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')
|
||||
@@ -194,8 +195,18 @@ const closeModal = () => {
|
||||
}
|
||||
|
||||
const saveConstructeur = async () => {
|
||||
const trimmedName = form.value.name.trim()
|
||||
const duplicate = constructeurs.value.find(
|
||||
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
&& c.id !== editingConstructeur.value?.id,
|
||||
)
|
||||
if (duplicate) {
|
||||
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
const payload = { ...form.value }
|
||||
const payload = { ...form.value, name: trimmedName }
|
||||
if (!payload.email) { delete payload.email }
|
||||
if (!payload.phone) { delete payload.phone }
|
||||
let result
|
||||
@@ -211,15 +222,17 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
loadConstructeurs()
|
||||
onMounted(() => loadConstructeurs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -104,10 +104,11 @@
|
||||
Commencez par ajouter des sites et des machines.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button class="btn btn-primary" @click="showAddSiteModal = true">
|
||||
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
|
||||
Ajouter un site
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-secondary"
|
||||
@click="showAddMachineModal = true"
|
||||
>
|
||||
@@ -239,12 +240,14 @@
|
||||
|
||||
<div class="card-actions justify-end mt-3">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-xs btn-outline"
|
||||
@click.stop="editMachine(machine)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-xs btn-error"
|
||||
@click.stop="confirmDeleteMachine(machine)"
|
||||
>
|
||||
@@ -277,6 +280,7 @@
|
||||
Aucune machine dans ce site
|
||||
</p>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="addMachineToSite(site)"
|
||||
>
|
||||
@@ -304,11 +308,12 @@
|
||||
type="text"
|
||||
placeholder="Ex: Usine de production"
|
||||
class="input input-bordered"
|
||||
:disabled="!canEdit"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="newSite" />
|
||||
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
@@ -318,7 +323,7 @@
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||
Créer le site
|
||||
</button>
|
||||
</div>
|
||||
@@ -343,6 +348,7 @@
|
||||
type="text"
|
||||
placeholder="Ex: Presse hydraulique #1"
|
||||
class="input input-bordered"
|
||||
:disabled="!canEdit"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
@@ -354,6 +360,7 @@
|
||||
<select
|
||||
v-model="newMachine.siteId"
|
||||
class="select select-bordered"
|
||||
:disabled="!canEdit"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
@@ -374,6 +381,7 @@
|
||||
<select
|
||||
v-model="newMachine.typeMachineId"
|
||||
class="select select-bordered"
|
||||
:disabled="!canEdit"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
@@ -398,6 +406,7 @@
|
||||
type="text"
|
||||
placeholder="Ex: PRESS-001"
|
||||
class="input input-bordered"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +455,7 @@
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||
Créer la machine
|
||||
</button>
|
||||
</div>
|
||||
@@ -474,6 +483,7 @@ import IconLucideTag from '~icons/lucide/tag'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const { sites, loading, loadSites, createSite } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||
@@ -705,13 +715,15 @@ const editMachine = (machine) => {
|
||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||
}
|
||||
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const confirmDeleteMachine = async (machine) => {
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`
|
||||
)
|
||||
await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const result = await deleteMachine(machine.id)
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-sm btn-error"
|
||||
@click.stop="confirmDeleteType(type)"
|
||||
>
|
||||
@@ -108,7 +109,8 @@ 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 { canEdit } = usePermissions();
|
||||
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
||||
useMachineTypesApi();
|
||||
|
||||
const categories = ref([
|
||||
@@ -131,13 +133,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);
|
||||
|
||||
@@ -19,15 +19,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypeEditForm
|
||||
:key="formKey"
|
||||
v-model="draftType"
|
||||
:saving="creating"
|
||||
:resettable="false"
|
||||
submit-label="Créer le type"
|
||||
submit-loading-label="Création..."
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
|
||||
<TypeEditForm
|
||||
:key="formKey"
|
||||
v-model="draftType"
|
||||
:saving="!canEdit || creating"
|
||||
:resettable="false"
|
||||
submit-label="Créer le type"
|
||||
submit-loading-label="Création..."
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,6 +96,7 @@ import IconLucideBox from '~icons/lucide/box'
|
||||
|
||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||
const { showError } = useToast()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const formKey = ref(0)
|
||||
const creating = ref(false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,7 +118,7 @@
|
||||
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
||||
Modifier
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
||||
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
||||
@@ -144,6 +144,7 @@ import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||
import IconLucideTag from '~icons/lucide/tag'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
@@ -202,10 +203,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