Compare commits
34 Commits
da447e4ea2
...
v1.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
94239031d6 | ||
|
|
b27662d2bc | ||
|
|
55739fe50f | ||
|
|
1f5f1509a9 | ||
|
|
a8cb4d1ac0 | ||
| 8af8374282 | |||
| 9cc7ac10f0 | |||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 | ||
| 2f3d4c5260 | |||
| 51edd7f655 | |||
| 2e4d61c3ea | |||
| 52f75c5301 | |||
| 84048bf3a2 | |||
| 0bfb69ad13 | |||
| ddce3ff3ae | |||
| b5af7f13b6 | |||
| e99f053233 | |||
|
|
936a73fde3 | ||
|
|
34af59d054 | ||
|
|
d860f24e69 | ||
|
|
3af6c50892 | ||
|
|
dc2bc6c70a | ||
|
|
ef9a8b5b7b | ||
|
|
53dab13489 | ||
|
|
f59255e684 | ||
|
|
76cd3fac98 | ||
|
|
4c714b3647 | ||
|
|
b752fba69a |
120
app/app.vue
120
app/app.vue
@@ -114,6 +114,61 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</Transition>
|
</Transition>
|
||||||
</li>
|
</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">
|
<li class="mt-1 border-t border-base-200 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +288,7 @@
|
|||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Constructeurs
|
Fournisseurs
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -356,6 +411,67 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</Transition>
|
</Transition>
|
||||||
</li>
|
</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
|
<li
|
||||||
class="relative"
|
class="relative"
|
||||||
@mouseenter="setDropdown('component-desktop')"
|
@mouseenter="setDropdown('component-desktop')"
|
||||||
@@ -488,7 +604,7 @@
|
|||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Constructeurs
|
Fournisseurs
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -32,8 +32,22 @@
|
|||||||
Défini dans le catalogue
|
Défini dans le catalogue
|
||||||
</span>
|
</span>
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
||||||
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span>
|
<template v-if="componentConstructeursDisplay.length">
|
||||||
|
<span
|
||||||
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}€</span>
|
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}€</span>
|
||||||
|
<span
|
||||||
|
v-if="displayProductName"
|
||||||
|
class="badge badge-info badge-sm"
|
||||||
|
>
|
||||||
|
Produit : {{ displayProductName }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="component.typeMachineComponentRequirement"
|
v-if="component.typeMachineComponentRequirement"
|
||||||
class="badge badge-outline badge-sm"
|
class="badge badge-outline badge-sm"
|
||||||
@@ -90,19 +104,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text font-medium">Constructeur</span></label>
|
<label class="label"><span class="label-text font-medium">Fournisseur</span></label>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:model-value="component.constructeurId || component.constructeur?.id || null"
|
:model-value="componentConstructeurIds"
|
||||||
|
:initial-options="componentConstructeursDisplay"
|
||||||
@update:model-value="handleConstructeurChange"
|
@update:model-value="handleConstructeurChange"
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered input-sm bg-base-200">
|
<div v-else class="input input-bordered input-sm bg-base-200">
|
||||||
<div class="flex flex-col">
|
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
|
||||||
<span class="font-medium">{{ component.constructeur?.name || 'Non défini' }}</span>
|
<div
|
||||||
<span class="text-xs text-gray-500">
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
{{ [component.constructeur?.email, component.constructeur?.phone].filter(Boolean).join(' • ') }}
|
:key="constructeur.id"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ constructeur.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="formatConstructeurContact(constructeur)"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-medium">Non défini</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Produit catalogue</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
|
||||||
|
<template v-if="displayProduct">
|
||||||
|
<span class="font-semibold text-base-content">
|
||||||
|
{{ displayProductName || 'Produit catalogue' }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="text-xs text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ info.label }} : {{ info.value }}
|
||||||
|
</span>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.product?.id"
|
||||||
|
:to="`/product/${component.product.id}/edit`"
|
||||||
|
class="link link-primary text-xs"
|
||||||
|
>
|
||||||
|
Ouvrir la fiche produit
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<span v-else class="font-medium">Non défini</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="productDocuments.length"
|
||||||
|
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-base-content">
|
||||||
|
Documents du produit
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && document.path"
|
||||||
|
:src="document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-6 w-6"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-base-content">
|
||||||
|
{{ document.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-base-content/70">
|
||||||
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,6 +444,12 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import {
|
||||||
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
|
resolveConstructeurs,
|
||||||
|
uniqueConstructeurIds,
|
||||||
|
} from '~/shared/constructeurUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: {
|
component: {
|
||||||
@@ -406,6 +525,132 @@ const childComponents = computed(() => {
|
|||||||
return Array.isArray(list) ? list : []
|
return Array.isArray(list) ? list : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||||
|
props.component.constructeur ? [props.component.constructeur] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const componentConstructeursDisplay = computed(() =>
|
||||||
|
resolveConstructeurs(
|
||||||
|
componentConstructeurIds.value,
|
||||||
|
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||||
|
props.component.constructeur ? [props.component.constructeur] : [],
|
||||||
|
constructeurs.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatConstructeurContact = (constructeur) =>
|
||||||
|
formatConstructeurContactSummary(constructeur)
|
||||||
|
|
||||||
const extractStructureCustomFields = (structure) => {
|
const extractStructureCustomFields = (structure) => {
|
||||||
if (!structure || typeof structure !== 'object') {
|
if (!structure || typeof structure !== 'object') {
|
||||||
return []
|
return []
|
||||||
@@ -415,16 +660,39 @@ const extractStructureCustomFields = (structure) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fieldKeyFromNameAndType(name, type) {
|
function fieldKeyFromNameAndType(name, type) {
|
||||||
const normalizedName = typeof name === 'string' ? name.trim() : ''
|
const normalizedName =
|
||||||
const normalizedType = typeof type === 'string' ? type : ''
|
typeof name === 'string' ? name.trim().toLowerCase() : ''
|
||||||
|
const normalizedType =
|
||||||
|
typeof type === 'string' ? type.trim().toLowerCase() : ''
|
||||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
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) {
|
function deduplicateFieldDefinitions(definitions) {
|
||||||
const result = []
|
const result = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
|
|
||||||
;(Array.isArray(definitions) ? definitions : []).forEach((field) => {
|
const orderedDefinitions = (Array.isArray(definitions)
|
||||||
|
? definitions.slice()
|
||||||
|
: []
|
||||||
|
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
|
|
||||||
|
orderedDefinitions.forEach((field) => {
|
||||||
if (!field || typeof field !== 'object') {
|
if (!field || typeof field !== 'object') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -444,6 +712,7 @@ function deduplicateFieldDefinitions(definitions) {
|
|||||||
if (key) {
|
if (key) {
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
}
|
}
|
||||||
|
field.orderIndex = resolveOrderIndex(field)
|
||||||
result.push(field)
|
result.push(field)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -484,10 +753,16 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
if (!matchedValue) {
|
if (!matchedValue) {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
value: field?.value ?? ''
|
value: field?.value ?? '',
|
||||||
|
orderIndex: resolveOrderIndex(field),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedOrder = Math.min(
|
||||||
|
resolveOrderIndex(field),
|
||||||
|
resolveOrderIndex(matchedValue.customField),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||||
@@ -497,7 +772,8 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
fieldId ??
|
fieldId ??
|
||||||
null,
|
null,
|
||||||
customField: matchedValue.customField ?? field.customField ?? null,
|
customField: matchedValue.customField ?? field.customField ?? null,
|
||||||
value: matchedValue.value ?? field.value ?? ''
|
value: matchedValue.value ?? field.value ?? '',
|
||||||
|
orderIndex: resolvedOrder,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -537,23 +813,30 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
required: entry.customField?.required ?? false,
|
required: entry.customField?.required ?? false,
|
||||||
options: entry.customField?.options ?? [],
|
options: entry.customField?.options ?? [],
|
||||||
value: entry.value ?? '',
|
value: entry.value ?? '',
|
||||||
customField: entry.customField ?? null
|
customField: entry.customField ?? null,
|
||||||
|
orderIndex: resolveOrderIndex(entry.customField),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return merged
|
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeMergedFields(fields) {
|
function dedupeMergedFields(fields) {
|
||||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||||
return Array.isArray(fields) ? fields : []
|
return Array.isArray(fields)
|
||||||
|
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Map()
|
const seen = new Map()
|
||||||
const result = []
|
const result = []
|
||||||
|
|
||||||
fields.forEach((field) => {
|
const orderedFields = fields
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
|
|
||||||
|
orderedFields.forEach((field) => {
|
||||||
if (!field || typeof field !== 'object') {
|
if (!field || typeof field !== 'object') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -571,12 +854,14 @@ function dedupeMergedFields(fields) {
|
|||||||
const key = fieldId || nameKey
|
const key = fieldId || nameKey
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
field.orderIndex = resolveOrderIndex(field)
|
||||||
result.push(field)
|
result.push(field)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = seen.get(key)
|
const existing = seen.get(key)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
field.orderIndex = resolveOrderIndex(field)
|
||||||
seen.set(key, field)
|
seen.set(key, field)
|
||||||
result.push(field)
|
result.push(field)
|
||||||
return
|
return
|
||||||
@@ -594,11 +879,15 @@ function dedupeMergedFields(fields) {
|
|||||||
|
|
||||||
if (!existingHasValue && incomingHasValue) {
|
if (!existingHasValue && incomingHasValue) {
|
||||||
Object.assign(existing, field)
|
Object.assign(existing, field)
|
||||||
|
existing.orderIndex = Math.min(
|
||||||
|
resolveOrderIndex(existing),
|
||||||
|
resolveOrderIndex(field),
|
||||||
|
)
|
||||||
seen.set(key, existing)
|
seen.set(key, existing)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentDefinitionSources = computed(() => {
|
const componentDefinitionSources = computed(() => {
|
||||||
@@ -686,7 +975,17 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleConstructeurChange = async (value) => {
|
const handleConstructeurChange = async (value) => {
|
||||||
props.component.constructeurId = value
|
const ids = uniqueConstructeurIds(value)
|
||||||
|
|
||||||
|
props.component.constructeurIds = [...ids]
|
||||||
|
props.component.constructeurId = null
|
||||||
|
props.component.constructeur = null
|
||||||
|
props.component.constructeurs = resolveConstructeurs(
|
||||||
|
ids,
|
||||||
|
constructeurs.value,
|
||||||
|
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||||
|
)
|
||||||
|
|
||||||
await updateComponent()
|
await updateComponent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +1022,10 @@ const toggleCollapse = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateComponent = () => {
|
const updateComponent = () => {
|
||||||
emit('update', props.component)
|
emit('update', {
|
||||||
|
...props.component,
|
||||||
|
constructeurIds: componentConstructeurIds.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFieldKey(field, index) {
|
function resolveFieldKey(field, index) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
:depth="0"
|
:depth="0"
|
||||||
:component-types="availableComponentTypes"
|
:component-types="availableComponentTypes"
|
||||||
:piece-types="availablePieceTypes"
|
:piece-types="availablePieceTypes"
|
||||||
|
:product-types="availableProductTypes"
|
||||||
:lock-type="lockRootType"
|
:lock-type="lockRootType"
|
||||||
:locked-type-label="displayedRootTypeLabel"
|
:locked-type-label="displayedRootTypeLabel"
|
||||||
:allow-subcomponents="allowSubcomponents"
|
:allow-subcomponents="allowSubcomponents"
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from '~/shared/modelUtils'
|
} from '~/shared/modelUtils'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
|
|
||||||
defineOptions({ name: 'ComponentModelStructureEditor' })
|
defineOptions({ name: 'ComponentModelStructureEditor' })
|
||||||
@@ -62,9 +64,11 @@ const previousLockedLabel = ref(props.rootTypeLabel || '')
|
|||||||
|
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
|
||||||
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
|
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
|
||||||
const availableComponentTypes = computed(() => componentTypes.value ?? [])
|
const availableComponentTypes = computed(() => componentTypes.value ?? [])
|
||||||
|
const availableProductTypes = computed(() => productTypes.value ?? [])
|
||||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||||
const maxSubcomponentDepth = computed(() =>
|
const maxSubcomponentDepth = computed(() =>
|
||||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||||
@@ -187,9 +191,10 @@ const syncFromProps = (value: any) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hydrated = hydrateStructureForEditor(value)
|
const hydrated = hydrateStructureForEditor(value)
|
||||||
localStructure.customFields = hydrated.customFields
|
localStructure.customFields = hydrated.customFields
|
||||||
localStructure.pieces = hydrated.pieces
|
localStructure.pieces = hydrated.pieces
|
||||||
localStructure.subcomponents = hydrated.subcomponents
|
localStructure.products = hydrated.products
|
||||||
|
localStructure.subcomponents = hydrated.subcomponents
|
||||||
localStructure.typeComposantId = hydrated.typeComposantId
|
localStructure.typeComposantId = hydrated.typeComposantId
|
||||||
localStructure.typeComposantLabel = hydrated.typeComposantLabel
|
localStructure.typeComposantLabel = hydrated.typeComposantLabel
|
||||||
localStructure.modelId = hydrated.modelId
|
localStructure.modelId = hydrated.modelId
|
||||||
@@ -243,6 +248,9 @@ onMounted(async () => {
|
|||||||
if (!availableComponentTypes.value.length) {
|
if (!availableComponentTypes.value.length) {
|
||||||
loaders.push(loadComponentTypes())
|
loaders.push(loadComponentTypes())
|
||||||
}
|
}
|
||||||
|
if (!availableProductTypes.value.length) {
|
||||||
|
loaders.push(loadProductTypes())
|
||||||
|
}
|
||||||
if (loaders.length) {
|
if (loaders.length) {
|
||||||
await Promise.allSettled(loaders)
|
await Promise.allSettled(loaders)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@
|
|||||||
<SearchSelect
|
<SearchSelect
|
||||||
:model-value="assignment.selectedComponentId || ''"
|
:model-value="assignment.selectedComponentId || ''"
|
||||||
:options="componentOptions"
|
:options="componentOptions"
|
||||||
:loading="componentsLoading"
|
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="Rechercher un composant..."
|
placeholder="Rechercher un composant..."
|
||||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||||
:option-label="componentOptionLabel"
|
:option-label="componentOptionLabel"
|
||||||
:option-description="componentOptionDescription"
|
:option-description="componentOptionDescription"
|
||||||
|
@search="fetchComponentOptions"
|
||||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,27 +46,67 @@
|
|||||||
>
|
>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-medium text-base-content">
|
<p class="text-xs font-medium text-base-content">
|
||||||
{{ describePieceRequirement(pieceAssignment.definition) }}
|
{{ describePieceRequirement(pieceAssignment) }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error">
|
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
|
||||||
Aucune pièce disponible pour cette famille.
|
Aucune pièce disponible pour cette famille.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
:model-value="pieceAssignment.selectedPieceId || ''"
|
:model-value="pieceAssignment.selectedPieceId || ''"
|
||||||
:options="getPieceOptions(pieceAssignment.definition)"
|
:options="getPieceOptions(pieceAssignment)"
|
||||||
:loading="piecesLoading"
|
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
|
||||||
size="xs"
|
size="xs"
|
||||||
placeholder="Rechercher une pièce..."
|
placeholder="Rechercher une pièce..."
|
||||||
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||||
:option-label="pieceOptionLabel"
|
:option-label="pieceOptionLabel"
|
||||||
:option-description="pieceOptionDescription"
|
:option-description="pieceOptionDescription"
|
||||||
|
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-base-content">
|
||||||
|
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Sélectionnez les produits catalogue à lier sur chaque position définie.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="productAssignment in assignment.products"
|
||||||
|
:key="productAssignment.path"
|
||||||
|
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-medium text-base-content">
|
||||||
|
{{ describeProductRequirement(productAssignment) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
|
||||||
|
Aucun produit disponible pour cette catégorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="productAssignment.selectedProductId || ''"
|
||||||
|
:options="getProductOptions(productAssignment)"
|
||||||
|
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
|
||||||
|
size="xs"
|
||||||
|
placeholder="Rechercher un produit..."
|
||||||
|
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||||
|
:option-label="productOptionLabel"
|
||||||
|
:option-description="productOptionDescription"
|
||||||
|
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||||
|
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="assignment.subcomponents.length" class="space-y-4">
|
<section v-if="assignment.subcomponents.length" class="space-y-4">
|
||||||
<header class="space-y-1">
|
<header class="space-y-1">
|
||||||
<h4 class="text-sm font-semibold text-base-content">
|
<h4 class="text-sm font-semibold text-base-content">
|
||||||
@@ -81,9 +122,11 @@
|
|||||||
:key="subAssignment.path"
|
:key="subAssignment.path"
|
||||||
:assignment="subAssignment"
|
:assignment="subAssignment"
|
||||||
:pieces="pieces"
|
:pieces="pieces"
|
||||||
|
:products="products"
|
||||||
:components="components"
|
:components="components"
|
||||||
:components-loading="componentsLoading"
|
:components-loading="componentsLoading"
|
||||||
:pieces-loading="piecesLoading"
|
:pieces-loading="piecesLoading"
|
||||||
|
:products-loading="productsLoading"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -91,10 +134,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||||
|
import { useApi } from '~/composables/useApi';
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
|
ComponentModelProduct,
|
||||||
ComponentModelStructureNode,
|
ComponentModelStructureNode,
|
||||||
} from '~/shared/types/inventory';
|
} from '~/shared/types/inventory';
|
||||||
|
|
||||||
@@ -122,17 +167,36 @@ interface PieceOption {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProductOption {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
typeProductId?: string | null;
|
||||||
|
typeProduct?: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
code?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StructurePieceAssignment {
|
export interface StructurePieceAssignment {
|
||||||
path: string;
|
path: string;
|
||||||
definition: ComponentModelPiece;
|
definition: ComponentModelPiece;
|
||||||
selectedPieceId: string;
|
selectedPieceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StructureProductAssignment {
|
||||||
|
path: string;
|
||||||
|
definition: ComponentModelProduct;
|
||||||
|
selectedProductId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StructureAssignmentNode {
|
export interface StructureAssignmentNode {
|
||||||
path: string;
|
path: string;
|
||||||
definition: ComponentModelStructureNode;
|
definition: ComponentModelStructureNode;
|
||||||
selectedComponentId: string;
|
selectedComponentId: string;
|
||||||
pieces: StructurePieceAssignment[];
|
pieces: StructurePieceAssignment[];
|
||||||
|
products: StructureProductAssignment[];
|
||||||
subcomponents: StructureAssignmentNode[];
|
subcomponents: StructureAssignmentNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,17 +204,27 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
assignment: StructureAssignmentNode;
|
assignment: StructureAssignmentNode;
|
||||||
pieces: PieceOption[] | null;
|
pieces: PieceOption[] | null;
|
||||||
|
products: ProductOption[] | null;
|
||||||
components: ComponentOption[] | null;
|
components: ComponentOption[] | null;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
componentsLoading?: boolean;
|
componentsLoading?: boolean;
|
||||||
piecesLoading?: boolean;
|
piecesLoading?: boolean;
|
||||||
|
productsLoading?: boolean;
|
||||||
|
pieceTypeLabelMap?: Record<string, string>;
|
||||||
|
productTypeLabelMap?: Record<string, string>;
|
||||||
|
componentTypeLabelMap?: Record<string, string>;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
depth: 0,
|
depth: 0,
|
||||||
pieces: () => [],
|
pieces: () => [],
|
||||||
|
products: () => [],
|
||||||
components: () => [],
|
components: () => [],
|
||||||
componentsLoading: false,
|
componentsLoading: false,
|
||||||
piecesLoading: false,
|
piecesLoading: false,
|
||||||
|
productsLoading: false,
|
||||||
|
pieceTypeLabelMap: () => ({}),
|
||||||
|
productTypeLabelMap: () => ({}),
|
||||||
|
componentTypeLabelMap: () => ({}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,10 +235,42 @@ const wrapperClass = computed(() =>
|
|||||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { get } = useApi();
|
||||||
|
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
||||||
|
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
||||||
|
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
||||||
|
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||||
|
const productLoadingByPath = ref<Record<string, boolean>>({});
|
||||||
|
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
};
|
||||||
|
|
||||||
const componentOptions = computed(() => {
|
const componentOptions = computed(() => {
|
||||||
if (isRoot.value) {
|
if (isRoot.value) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const cached = componentOptionsByPath.value[props.assignment.path];
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
const definition = props.assignment.definition || {};
|
const definition = props.assignment.definition || {};
|
||||||
const requiredTypeId =
|
const requiredTypeId =
|
||||||
definition.typeComposantId || definition.modelId || null;
|
definition.typeComposantId || definition.modelId || null;
|
||||||
@@ -210,6 +316,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
|
|||||||
return parts.join(' • ');
|
return parts.join(' • ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeIri = (id: string) => `/api/model_types/${id}`;
|
||||||
|
const primedPiecePaths = new Set<string>();
|
||||||
|
const primedProductPaths = new Set<string>();
|
||||||
|
const primedComponentPaths = new Set<string>();
|
||||||
|
|
||||||
|
const fetchComponentOptions = async (term = '') => {
|
||||||
|
if (isRoot.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = props.assignment.path;
|
||||||
|
if (componentLoadingByPath.value[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = props.assignment.definition || {};
|
||||||
|
const requiredTypeId =
|
||||||
|
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('itemsPerPage', '50');
|
||||||
|
if (term.trim()) {
|
||||||
|
params.set('name', term.trim());
|
||||||
|
}
|
||||||
|
if (requiredTypeId) {
|
||||||
|
params.set('typeComposant', typeIri(requiredTypeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(componentLoadingByPath.value, key, true);
|
||||||
|
try {
|
||||||
|
const result = await get(`/composants?${params.toString()}`);
|
||||||
|
if (result.success) {
|
||||||
|
componentOptionsByPath.value[key] = extractCollection(result.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(componentLoadingByPath.value, key, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||||
|
const key = assignment.path;
|
||||||
|
if (pieceLoadingByPath.value[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = assignment.definition || {};
|
||||||
|
const requiredTypeId =
|
||||||
|
definition.typePieceId || (definition as any).typePiece?.id || null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('itemsPerPage', '50');
|
||||||
|
if (term.trim()) {
|
||||||
|
params.set('name', term.trim());
|
||||||
|
}
|
||||||
|
if (requiredTypeId) {
|
||||||
|
params.set('typePiece', typeIri(requiredTypeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(pieceLoadingByPath.value, key, true);
|
||||||
|
try {
|
||||||
|
const result = await get(`/pieces?${params.toString()}`);
|
||||||
|
if (result.success) {
|
||||||
|
pieceOptionsByPath.value[key] = extractCollection(result.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(pieceLoadingByPath.value, key, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||||
|
const key = assignment.path;
|
||||||
|
if (productLoadingByPath.value[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = assignment.definition || {};
|
||||||
|
const requiredTypeId =
|
||||||
|
definition.typeProductId || (definition as any).typeProduct?.id || null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('itemsPerPage', '50');
|
||||||
|
if (term.trim()) {
|
||||||
|
params.set('name', term.trim());
|
||||||
|
}
|
||||||
|
if (requiredTypeId) {
|
||||||
|
params.set('typeProduct', typeIri(requiredTypeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(productLoadingByPath.value, key, true);
|
||||||
|
try {
|
||||||
|
const result = await get(`/products?${params.toString()}`);
|
||||||
|
if (result.success) {
|
||||||
|
productOptionsByPath.value[key] = extractCollection(result.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(productLoadingByPath.value, key, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
componentOptions,
|
componentOptions,
|
||||||
(options) => {
|
(options) => {
|
||||||
@@ -226,7 +430,8 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const describePieceRequirement = (definition: ComponentModelPiece) => {
|
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||||
|
const definition = assignment.definition;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const addPart = (value?: string | null) => {
|
const addPart = (value?: string | null) => {
|
||||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -235,16 +440,17 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = getPieceOptions(definition);
|
const options = getPieceOptions(assignment);
|
||||||
const fallbackPiece = options[0] || null;
|
const fallbackPiece = options[0] || null;
|
||||||
const fallbackType = fallbackPiece?.typePiece || null;
|
const fallbackType = fallbackPiece?.typePiece || null;
|
||||||
|
|
||||||
addPart(definition.role);
|
addPart(definition.role);
|
||||||
addPart(
|
const explicitLabel =
|
||||||
definition.typePieceLabel ||
|
definition.typePieceLabel ||
|
||||||
(definition as any).typePiece?.name ||
|
(definition as any).typePiece?.name ||
|
||||||
fallbackType?.name,
|
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
|
||||||
);
|
fallbackType?.name;
|
||||||
|
addPart(explicitLabel);
|
||||||
|
|
||||||
const family =
|
const family =
|
||||||
definition.familyCode ||
|
definition.familyCode ||
|
||||||
@@ -269,12 +475,118 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||||
|
const cached = productOptionsByPath.value[assignment.path];
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const definition = assignment.definition;
|
||||||
|
const requiredTypeId =
|
||||||
|
definition.typeProductId ||
|
||||||
|
(definition as any).typeProduct?.id ||
|
||||||
|
definition.familyCode ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return (props.products || []).filter((product) => {
|
||||||
|
if (!product || typeof product !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!requiredTypeId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (definition.typeProductId || (definition as any).typeProduct?.id) {
|
||||||
|
return (
|
||||||
|
product.typeProductId === requiredTypeId ||
|
||||||
|
product.typeProduct?.id === requiredTypeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (definition.familyCode) {
|
||||||
|
return (
|
||||||
|
product.typeProduct?.code === requiredTypeId ||
|
||||||
|
product.typeProductId === requiredTypeId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const productOptionLabel = (product?: ProductOption | null) => {
|
||||||
|
if (!product) {
|
||||||
|
return 'Produit';
|
||||||
|
}
|
||||||
|
return product.name || product.reference || 'Produit';
|
||||||
|
};
|
||||||
|
|
||||||
|
const productOptionDescription = (product?: ProductOption | null) => {
|
||||||
|
if (!product) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
const typeLabel =
|
||||||
|
product.typeProduct?.name || product.typeProduct?.code || null;
|
||||||
|
if (typeLabel) {
|
||||||
|
parts.push(typeLabel);
|
||||||
|
}
|
||||||
|
if (product.reference) {
|
||||||
|
parts.push(`Ref. ${product.reference}`);
|
||||||
|
}
|
||||||
|
return parts.join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||||
|
const definition = assignment.definition;
|
||||||
|
const parts: string[] = [];
|
||||||
|
const addPart = (value?: string | null) => {
|
||||||
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (trimmed && !parts.includes(trimmed)) {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = getProductOptions(assignment);
|
||||||
|
const fallbackProduct = options[0] || null;
|
||||||
|
const fallbackType = fallbackProduct?.typeProduct || null;
|
||||||
|
|
||||||
|
addPart(definition.role);
|
||||||
|
const explicitLabel =
|
||||||
|
definition.typeProductLabel ||
|
||||||
|
(definition as any).typeProduct?.name ||
|
||||||
|
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
|
||||||
|
fallbackType?.name;
|
||||||
|
addPart(explicitLabel);
|
||||||
|
|
||||||
|
const family =
|
||||||
|
definition.familyCode ||
|
||||||
|
(definition as any).typeProduct?.code ||
|
||||||
|
fallbackType?.code ||
|
||||||
|
null;
|
||||||
|
if (family) {
|
||||||
|
addPart(`Famille ${family}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
addPart(fallbackType?.name);
|
||||||
|
if (fallbackType?.code) {
|
||||||
|
addPart(`Famille ${fallbackType.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0 && definition.typeProductId) {
|
||||||
|
addPart(`#${definition.typeProductId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length ? parts.join(' • ') : 'Produit du squelette';
|
||||||
|
};
|
||||||
|
|
||||||
const requirementLabel = computed(() => {
|
const requirementLabel = computed(() => {
|
||||||
const definition = props.assignment.definition || {};
|
const definition = props.assignment.definition || {};
|
||||||
const alias = definition.alias || definition.typeComposantLabel;
|
const alias = definition.alias || definition.typeComposantLabel;
|
||||||
if (alias) {
|
if (alias) {
|
||||||
return alias;
|
return alias;
|
||||||
}
|
}
|
||||||
|
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
|
||||||
|
return props.componentTypeLabelMap[definition.typeComposantId];
|
||||||
|
}
|
||||||
if (definition.typeComposant?.name) {
|
if (definition.typeComposant?.name) {
|
||||||
return definition.typeComposant.name;
|
return definition.typeComposant.name;
|
||||||
}
|
}
|
||||||
@@ -288,6 +600,7 @@ const requirementDescription = computed(() => {
|
|||||||
const definition = props.assignment.definition || {};
|
const definition = props.assignment.definition || {};
|
||||||
const family =
|
const family =
|
||||||
definition.typeComposantLabel ||
|
definition.typeComposantLabel ||
|
||||||
|
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
||||||
definition.typeComposant?.name ||
|
definition.typeComposant?.name ||
|
||||||
definition.familyCode;
|
definition.familyCode;
|
||||||
if (family) {
|
if (family) {
|
||||||
@@ -296,7 +609,12 @@ const requirementDescription = computed(() => {
|
|||||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||||
});
|
});
|
||||||
|
|
||||||
const getPieceOptions = (definition: ComponentModelPiece) => {
|
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||||
|
const cached = pieceOptionsByPath.value[assignment.path];
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const definition = assignment.definition;
|
||||||
const requiredTypeId =
|
const requiredTypeId =
|
||||||
definition.typePieceId ||
|
definition.typePieceId ||
|
||||||
(definition as any).typePiece?.id ||
|
(definition as any).typePiece?.id ||
|
||||||
@@ -366,15 +684,54 @@ watch(
|
|||||||
() => [props.pieces, props.assignment.pieces],
|
() => [props.pieces, props.assignment.pieces],
|
||||||
() => {
|
() => {
|
||||||
for (const pieceAssignment of props.assignment.pieces) {
|
for (const pieceAssignment of props.assignment.pieces) {
|
||||||
const options = getPieceOptions(pieceAssignment.definition);
|
const options = getPieceOptions(pieceAssignment);
|
||||||
if (
|
if (
|
||||||
pieceAssignment.selectedPieceId &&
|
pieceAssignment.selectedPieceId &&
|
||||||
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||||
) {
|
) {
|
||||||
pieceAssignment.selectedPieceId = '';
|
pieceAssignment.selectedPieceId = '';
|
||||||
}
|
}
|
||||||
|
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||||
|
primedPiecePaths.add(pieceAssignment.path);
|
||||||
|
fetchPieceOptions(pieceAssignment).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true, immediate: true },
|
{ deep: true, immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.products, props.assignment.products],
|
||||||
|
() => {
|
||||||
|
for (const productAssignment of props.assignment.products) {
|
||||||
|
const options = getProductOptions(productAssignment);
|
||||||
|
if (
|
||||||
|
productAssignment.selectedProductId &&
|
||||||
|
!options.some((product) => product.id === productAssignment.selectedProductId)
|
||||||
|
) {
|
||||||
|
productAssignment.selectedProductId = '';
|
||||||
|
}
|
||||||
|
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||||
|
primedProductPaths.add(productAssignment.path);
|
||||||
|
fetchProductOptions(productAssignment).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assignment.definition,
|
||||||
|
() => {
|
||||||
|
if (isRoot.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = props.assignment.path;
|
||||||
|
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||||
|
primedComponentPaths.add(key);
|
||||||
|
fetchComponentOptions().catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-2 constructeur-select">
|
<div class="space-y-2 constructeur-select">
|
||||||
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
|
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<input
|
<input
|
||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
@@ -26,20 +26,24 @@
|
|||||||
v-if="options.length === 0"
|
v-if="options.length === 0"
|
||||||
class="px-3 py-2 text-xs text-gray-500"
|
class="px-3 py-2 text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Aucun constructeur trouvé
|
Aucun fournisseur trouvé
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
@click="selectOption(option)"
|
:class="{ 'bg-base-200': isSelected(option.id) }"
|
||||||
|
@click="toggleOption(option)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class="font-medium">{{ option.name }}</span>
|
<div class="flex flex-col">
|
||||||
<span class="text-xs text-gray-500">
|
<span class="font-medium">{{ option.name }}</span>
|
||||||
{{ [option.email, option.phone].filter(Boolean).join(' • ') || '—' }}
|
<span class="text-xs text-gray-500">
|
||||||
</span>
|
{{ formatConstructeurContact(option) || '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,16 +53,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedConstructeur" class="text-xs text-gray-500">
|
<div class="flex flex-wrap gap-2 min-h-[1.5rem]">
|
||||||
<span class="font-medium">{{ selectedConstructeur.name }}</span>
|
<span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
|
||||||
<span v-if="selectedConstructeur.email"> • {{ selectedConstructeur.email }}</span>
|
Aucun fournisseur sélectionné
|
||||||
<span v-if="selectedConstructeur.phone"> • {{ selectedConstructeur.phone }}</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="constructeur in selectedConstructeurs"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="badge badge-outline gap-1"
|
||||||
|
>
|
||||||
|
<span>{{ constructeur.name }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs p-0"
|
||||||
|
aria-label="Retirer le fournisseur"
|
||||||
|
@click="removeConstructeur(constructeur.id)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
|
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
Nouveau constructeur
|
Nouveau fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="handleCreate">
|
<form @submit.prevent="handleCreate">
|
||||||
<div class="form-control mb-3">
|
<div class="form-control mb-3">
|
||||||
@@ -69,7 +88,7 @@
|
|||||||
v-model="createForm.email"
|
v-model="createForm.email"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="ex: contact@constructeur.com"
|
placeholder="ex: contact@fournisseur.com"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
<FieldPhone
|
<FieldPhone
|
||||||
@@ -94,89 +113,153 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import {
|
||||||
|
type ConstructeurSummary,
|
||||||
|
formatConstructeurContact,
|
||||||
|
resolveConstructeurs,
|
||||||
|
uniqueConstructeurIds,
|
||||||
|
} from '~/shared/constructeurUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: Array as PropType<string[]>,
|
||||||
default: null
|
default: () => [],
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Sélectionner ou créer un constructeur...'
|
default: 'Sélectionner ou créer un fournisseur...',
|
||||||
}
|
},
|
||||||
|
initialOptions: {
|
||||||
|
type: Array as PropType<ConstructeurSummary[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const { constructeurs, searchConstructeurs, createConstructeur } = useConstructeurs()
|
const {
|
||||||
|
constructeurs,
|
||||||
|
searchConstructeurs,
|
||||||
|
createConstructeur,
|
||||||
|
ensureConstructeurs,
|
||||||
|
} = useConstructeurs()
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const openDropdown = ref(false)
|
const openDropdown = ref(false)
|
||||||
const openCreateModal = ref(false)
|
const openCreateModal = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const options = ref([])
|
const options = ref<ConstructeurSummary[]>([])
|
||||||
let searchTimeout = null
|
const selectedIds = ref<string[]>([])
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
let lastSearchTerm = ''
|
let lastSearchTerm = ''
|
||||||
|
|
||||||
const applyOptions = (items = []) => {
|
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const selectedId = props.modelValue
|
const seen = new Map<string, ConstructeurSummary>()
|
||||||
const cloned = [...items]
|
items.forEach((item) => {
|
||||||
const limited = cloned.slice(0, 10)
|
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||||
|
seen.set(item.id, item)
|
||||||
if (selectedId && !limited.some(item => item.id === selectedId)) {
|
|
||||||
const selected = cloned.find(item => item.id === selectedId)
|
|
||||||
if (selected) {
|
|
||||||
if (limited.length >= 10) { limited.pop() }
|
|
||||||
limited.unshift(selected)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
return Array.from(seen.values())
|
||||||
|
}
|
||||||
|
|
||||||
options.value = limited
|
const normalizedInitialOptions = computed(() =>
|
||||||
|
uniqueOptions((props.initialOptions as ConstructeurSummary[]) || []),
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
|
const normalized = 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 createForm = ref({
|
const createForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: ''
|
phone: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedConstructeur = computed(() =>
|
const optionLookup = computed(() => {
|
||||||
constructeurs.value.find(item => item.id === props.modelValue) || null
|
const map = new Map<string, ConstructeurSummary>()
|
||||||
)
|
normalizedInitialOptions.value.forEach((item) => {
|
||||||
|
map.set(item.id, item)
|
||||||
|
})
|
||||||
|
constructeurs.value.forEach((item: ConstructeurSummary) => {
|
||||||
|
map.set(item.id, item)
|
||||||
|
})
|
||||||
|
options.value.forEach((item) => {
|
||||||
|
map.set(item.id, item)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
|
||||||
() => props.modelValue,
|
if (!selectedIds.value.length) {
|
||||||
(newValue) => {
|
return []
|
||||||
if (newValue && !selectedConstructeur.value) {
|
}
|
||||||
// ensure current selection is loaded
|
|
||||||
ensureOptionsLoaded(true)
|
|
||||||
}
|
|
||||||
if (newValue) {
|
|
||||||
const match = constructeurs.value.find(item => item.id === newValue)
|
|
||||||
if (match) {
|
|
||||||
searchTerm.value = match.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
async function ensureOptionsLoaded (force = false) {
|
return selectedIds.value
|
||||||
|
.map((id) => optionLookup.value.get(id))
|
||||||
|
.filter((item): item is ConstructeurSummary => Boolean(item))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelected = (id: string) => selectedIds.value.includes(id)
|
||||||
|
|
||||||
|
const emitSelection = (ids: string[]) => {
|
||||||
|
const normalized = uniqueConstructeurIds(ids)
|
||||||
|
selectedIds.value = normalized
|
||||||
|
emit('update:modelValue', normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureOptionsLoaded = async (force = false) => {
|
||||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
if (!force && !searchTerm.value && constructeurs.value.length) {
|
||||||
applyOptions(constructeurs.value)
|
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!force && searchTerm.value === lastSearchTerm && options.value.length) { return }
|
|
||||||
if (options.value.length && !force) { 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(searchTerm.value)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
applyOptions(result.data || [])
|
applyOptions(result.data || [])
|
||||||
@@ -186,14 +269,18 @@ async function ensureOptionsLoaded (force = false) {
|
|||||||
|
|
||||||
const onSearch = () => {
|
const onSearch = () => {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
clearTimeout(searchTimeout)
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
}
|
||||||
searchTimeout = setTimeout(async () => {
|
searchTimeout = setTimeout(async () => {
|
||||||
if (!searchTerm.value && constructeurs.value.length) {
|
if (!searchTerm.value && constructeurs.value.length) {
|
||||||
applyOptions(constructeurs.value)
|
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||||
lastSearchTerm = ''
|
lastSearchTerm = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (searchTerm.value === lastSearchTerm) { return }
|
if (searchTerm.value === lastSearchTerm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
const result = await searchConstructeurs(searchTerm.value)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
applyOptions(result.data || [])
|
applyOptions(result.data || [])
|
||||||
@@ -202,10 +289,18 @@ const onSearch = () => {
|
|||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (option) => {
|
const toggleOption = (option: ConstructeurSummary) => {
|
||||||
emit('update:modelValue', option.id)
|
const ids = new Set(selectedIds.value)
|
||||||
openDropdown.value = false
|
if (ids.has(option.id)) {
|
||||||
searchTerm.value = option.name
|
ids.delete(option.id)
|
||||||
|
} else {
|
||||||
|
ids.add(option.id)
|
||||||
|
}
|
||||||
|
emitSelection(Array.from(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeConstructeur = (id: string) => {
|
||||||
|
emitSelection(selectedIds.value.filter((item) => item !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeCreateModal = () => {
|
const closeCreateModal = () => {
|
||||||
@@ -216,31 +311,24 @@ const closeCreateModal = () => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
creating.value = true
|
creating.value = true
|
||||||
const payload = { ...createForm.value }
|
const payload = { ...createForm.value }
|
||||||
if (!payload.phone) { delete payload.phone }
|
if (!payload.phone) {
|
||||||
if (!payload.email) { delete payload.email }
|
delete payload.phone
|
||||||
|
}
|
||||||
|
if (!payload.email) {
|
||||||
|
delete payload.email
|
||||||
|
}
|
||||||
const result = await createConstructeur(payload)
|
const result = await createConstructeur(payload)
|
||||||
creating.value = false
|
creating.value = false
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
emit('update:modelValue', result.data.id)
|
emitSelection([...selectedIds.value, result.data.id])
|
||||||
searchTerm.value = result.data.name
|
searchTerm.value = ''
|
||||||
closeCreateModal()
|
closeCreateModal()
|
||||||
await ensureOptionsLoaded(true)
|
await ensureOptionsLoaded(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
const clickHandler = (event: Event) => {
|
||||||
constructeurs,
|
const element = event.target as HTMLElement | null
|
||||||
(list) => {
|
|
||||||
applyOptions(list || [])
|
|
||||||
if (!searchTerm.value) {
|
|
||||||
lastSearchTerm = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const clickHandler = (event) => {
|
|
||||||
const element = event.target
|
|
||||||
if (element && element.closest) {
|
if (element && element.closest) {
|
||||||
if (
|
if (
|
||||||
element.closest('.menu') ||
|
element.closest('.menu') ||
|
||||||
@@ -254,6 +342,50 @@ const clickHandler = (event) => {
|
|||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
selectedIds.value = uniqueConstructeurIds(newValue)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedIds,
|
||||||
|
async (ids) => {
|
||||||
|
if (!ids.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const missing = ids.some((id) => !optionLookup.value.get(id))
|
||||||
|
if (missing) {
|
||||||
|
const fetched = await ensureConstructeurs(ids)
|
||||||
|
if (fetched.length) {
|
||||||
|
applyOptions([...options.value, ...fetched])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
constructeurs,
|
||||||
|
(list) => {
|
||||||
|
applyOptions((list as ConstructeurSummary[]) || [])
|
||||||
|
if (!searchTerm.value) {
|
||||||
|
lastSearchTerm = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
normalizedInitialOptions,
|
||||||
|
() => {
|
||||||
|
applyOptions(options.value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('click', clickHandler)
|
window.addEventListener('click', clickHandler)
|
||||||
ensureOptionsLoaded()
|
ensureOptionsLoaded()
|
||||||
@@ -261,6 +393,24 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('click', clickHandler)
|
window.removeEventListener('click', clickHandler)
|
||||||
clearTimeout(searchTimeout)
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedIds,
|
||||||
|
(ids) => {
|
||||||
|
// ensure options contain newly selected ids
|
||||||
|
const resolved = resolveConstructeurs(
|
||||||
|
ids,
|
||||||
|
constructeurs.value as ConstructeurSummary[],
|
||||||
|
options.value,
|
||||||
|
)
|
||||||
|
if (resolved.length) {
|
||||||
|
applyOptions([...resolved, ...options.value])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="field in customFields"
|
v-for="field in sortedCustomFields"
|
||||||
:key="field.id"
|
:key="field.id"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
>
|
>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
customFields: {
|
customFields: {
|
||||||
@@ -101,6 +101,17 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update'])
|
const emit = defineEmits(['update'])
|
||||||
|
|
||||||
|
const sortedCustomFields = computed(() => {
|
||||||
|
if (!Array.isArray(props.customFields)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [...props.customFields].sort((a, b) => {
|
||||||
|
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||||
|
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||||
|
return left - right
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Valeurs des champs personnalisés
|
// Valeurs des champs personnalisés
|
||||||
const fieldValues = reactive({})
|
const fieldValues = reactive({})
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,6 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
>
|
>
|
||||||
<iframe
|
|
||||||
v-else-if="canRenderPdf"
|
|
||||||
:src="previewSrc"
|
|
||||||
class="h-full w-full border-0 bg-white"
|
|
||||||
title="Aperçu PDF"
|
|
||||||
/>
|
|
||||||
<component
|
<component
|
||||||
v-else
|
v-else
|
||||||
:is="icon.component"
|
:is="icon.component"
|
||||||
@@ -54,8 +48,6 @@ const props = defineProps<{
|
|||||||
alt?: string;
|
alt?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
const normalizedDocument = computed(() => props.document ?? null);
|
const normalizedDocument = computed(() => props.document ?? null);
|
||||||
|
|
||||||
const canRenderImage = computed(() => {
|
const canRenderImage = computed(() => {
|
||||||
@@ -64,14 +56,9 @@ const canRenderImage = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canRenderPdf = computed(() => {
|
const canRenderPdf = computed(() => {
|
||||||
const doc = normalizedDocument.value;
|
// Rendering many PDF iframes in a list is very heavy for the browser.
|
||||||
if (!doc || !isPdfDocument(doc) || !doc.path) {
|
// We intentionally disable inline PDF previews and fall back to an icon.
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const appendPdfViewerParams = (src: string) => {
|
const appendPdfViewerParams = (src: string) => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="font-medium">Informations générales</p>
|
<p class="font-medium">Informations générales</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<p class="text-xs text-base-content/60">
|
||||||
Nom, site et constructeur de la machine.
|
Nom, site et fournisseur de la machine.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
>
|
>
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
Rattachée à {{ piece.parentComponentName }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="displayProductName"
|
||||||
|
class="badge badge-info badge-sm"
|
||||||
|
>
|
||||||
|
Produit : {{ displayProductName }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,23 +73,35 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">Constructeur:</span>
|
<span class="font-medium">Fournisseur:</span>
|
||||||
<span v-if="!isEditMode" class="ml-2">
|
<div v-if="!isEditMode" class="ml-2">
|
||||||
<span class="font-medium">{{
|
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||||
piece.constructeur?.name || "Non défini"
|
<div
|
||||||
}}</span>
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
<span v-if="piece.constructeur" class="block text-xs text-gray-500">
|
:key="constructeur.id"
|
||||||
{{
|
class="flex flex-col"
|
||||||
[piece.constructeur?.email, piece.constructeur?.phone]
|
>
|
||||||
.filter(Boolean)
|
<span class="font-medium">
|
||||||
.join(" • ")
|
{{ constructeur.name }}
|
||||||
}}
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="formatConstructeurContact(constructeur)"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-medium">
|
||||||
|
Non défini
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-else
|
v-else
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:model-value="piece.constructeurId || piece.constructeur?.id || null"
|
:model-value="pieceConstructeurIds"
|
||||||
|
:initial-options="pieceConstructeursDisplay"
|
||||||
|
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||||
@update:model-value="handleConstructeurChange"
|
@update:model-value="handleConstructeurChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,6 +120,120 @@
|
|||||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Produit catalogue:</span>
|
||||||
|
<div v-if="isEditMode" class="mt-2 space-y-2">
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="pieceData.productId"
|
||||||
|
placeholder="Associer un produit…"
|
||||||
|
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||||
|
@update:modelValue="handleProductChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="selectedProduct"
|
||||||
|
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-semibold text-base-content">
|
||||||
|
{{ selectedProduct.name }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ info.label }} :</span>
|
||||||
|
<span>{{ info.value }}</span>
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="selectedProduct.id"
|
||||||
|
:to="`/product/${selectedProduct.id}/edit`"
|
||||||
|
class="link link-primary text-xs"
|
||||||
|
>
|
||||||
|
Ouvrir la fiche produit
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-base-content/60">
|
||||||
|
Aucun produit associé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2">
|
||||||
|
<div v-if="displayProduct" class="space-y-1">
|
||||||
|
<p class="font-medium text-base-content">
|
||||||
|
{{ displayProductName || 'Produit catalogue' }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="text-xs text-base-content/70"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ info.label }} :</span>
|
||||||
|
<span class="ml-1">{{ info.value }}</span>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="productDocuments.length"
|
||||||
|
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||||
|
>
|
||||||
|
<h5 class="font-medium text-base-content">Documents du produit</h5>
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && document.path"
|
||||||
|
:src="document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-base-content">
|
||||||
|
{{ document.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-base-content/70">
|
||||||
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-medium">
|
||||||
|
Non défini
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- Champs personnalisés de la pièce -->
|
||||||
@@ -353,6 +485,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, onMounted, watch, ref, computed } from "vue";
|
import { reactive, onMounted, watch, ref, computed } from "vue";
|
||||||
import ConstructeurSelect from "./ConstructeurSelect.vue";
|
import ConstructeurSelect from "./ConstructeurSelect.vue";
|
||||||
|
import ProductSelect from "~/components/ProductSelect.vue";
|
||||||
|
import { useConstructeurs } from "~/composables/useConstructeurs";
|
||||||
import { useCustomFields } from "~/composables/useCustomFields";
|
import { useCustomFields } from "~/composables/useCustomFields";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
import { useDocuments } from "~/composables/useDocuments";
|
import { useDocuments } from "~/composables/useDocuments";
|
||||||
@@ -361,6 +495,12 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
|
|||||||
import DocumentUpload from "~/components/DocumentUpload.vue";
|
import DocumentUpload from "~/components/DocumentUpload.vue";
|
||||||
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
|
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
|
||||||
import IconLucidePackage from "~icons/lucide/package";
|
import IconLucidePackage from "~icons/lucide/package";
|
||||||
|
import { useProducts } from "~/composables/useProducts";
|
||||||
|
import {
|
||||||
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
|
resolveConstructeurs,
|
||||||
|
uniqueConstructeurIds,
|
||||||
|
} from "~/shared/constructeurUtils";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
piece: {
|
piece: {
|
||||||
@@ -384,6 +524,7 @@ const pieceData = reactive({
|
|||||||
name: props.piece.name || "",
|
name: props.piece.name || "",
|
||||||
reference: props.piece.reference || "",
|
reference: props.piece.reference || "",
|
||||||
prix: props.piece.prix || "",
|
prix: props.piece.prix || "",
|
||||||
|
productId: props.piece.product?.id || props.piece.productId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedFiles = ref([]);
|
const selectedFiles = ref([]);
|
||||||
@@ -439,16 +580,36 @@ const extractStructureCustomFields = (structure) => {
|
|||||||
return Array.isArray(customFields) ? customFields : [];
|
return Array.isArray(customFields) ? customFields : [];
|
||||||
};
|
};
|
||||||
function fieldKeyFromNameAndType(name, type) {
|
function fieldKeyFromNameAndType(name, type) {
|
||||||
const normalizedName = typeof name === 'string' ? name : '';
|
const normalizedName =
|
||||||
const normalizedType = typeof type === 'string' ? type : '';
|
typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||||
|
const normalizedType =
|
||||||
|
typeof type === 'string' ? type.trim().toLowerCase() : '';
|
||||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null;
|
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) {
|
function deduplicateFieldDefinitions(definitions) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
(Array.isArray(definitions) ? definitions : []).forEach((field) => {
|
const orderedDefinitions = (Array.isArray(definitions)
|
||||||
|
? definitions.slice()
|
||||||
|
: []
|
||||||
|
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
|
||||||
|
|
||||||
|
orderedDefinitions.forEach((field) => {
|
||||||
if (!field || typeof field !== 'object') {
|
if (!field || typeof field !== 'object') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -465,6 +626,7 @@ function deduplicateFieldDefinitions(definitions) {
|
|||||||
if (key) {
|
if (key) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
|
field.orderIndex = resolveOrderIndex(field);
|
||||||
result.push(field);
|
result.push(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,9 +674,15 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
value: field?.value ?? '',
|
value: field?.value ?? '',
|
||||||
|
orderIndex: resolveOrderIndex(field),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedOrder = Math.min(
|
||||||
|
resolveOrderIndex(field),
|
||||||
|
resolveOrderIndex(matchedValue.customField),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||||
@@ -525,6 +693,7 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
null,
|
null,
|
||||||
customField: matchedValue.customField ?? field.customField ?? null,
|
customField: matchedValue.customField ?? field.customField ?? null,
|
||||||
value: matchedValue.value ?? field.value ?? '',
|
value: matchedValue.value ?? field.value ?? '',
|
||||||
|
orderIndex: resolvedOrder,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,22 +740,31 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
|
|||||||
options: entry.customField?.options ?? [],
|
options: entry.customField?.options ?? [],
|
||||||
value: entry.value ?? '',
|
value: entry.value ?? '',
|
||||||
customField: entry.customField ?? null,
|
customField: entry.customField ?? null,
|
||||||
|
orderIndex: resolveOrderIndex(entry.customField),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return merged;
|
return merged.sort(
|
||||||
|
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeMergedFields(fields) {
|
function dedupeMergedFields(fields) {
|
||||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||||
return Array.isArray(fields) ? fields : [];
|
return Array.isArray(fields)
|
||||||
|
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
fields.forEach((field) => {
|
const orderedFields = fields
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
|
||||||
|
|
||||||
|
orderedFields.forEach((field) => {
|
||||||
if (!field || typeof field !== 'object') {
|
if (!field || typeof field !== 'object') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -615,12 +793,14 @@ function dedupeMergedFields(fields) {
|
|||||||
const key = fieldId || nameKey;
|
const key = fieldId || nameKey;
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
field.orderIndex = resolveOrderIndex(field);
|
||||||
result.push(field);
|
result.push(field);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = seen.get(key);
|
const existing = seen.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
field.orderIndex = resolveOrderIndex(field);
|
||||||
seen.set(key, field);
|
seen.set(key, field);
|
||||||
result.push(field);
|
result.push(field);
|
||||||
return;
|
return;
|
||||||
@@ -637,11 +817,17 @@ function dedupeMergedFields(fields) {
|
|||||||
|
|
||||||
if (!existingHasValue && incomingHasValue) {
|
if (!existingHasValue && incomingHasValue) {
|
||||||
Object.assign(existing, field);
|
Object.assign(existing, field);
|
||||||
|
existing.orderIndex = Math.min(
|
||||||
|
resolveOrderIndex(existing),
|
||||||
|
resolveOrderIndex(field),
|
||||||
|
);
|
||||||
seen.set(key, existing);
|
seen.set(key, existing);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result.sort(
|
||||||
|
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceDefinitionSources = computed(() => {
|
const pieceDefinitionSources = computed(() => {
|
||||||
@@ -716,8 +902,271 @@ const candidateCustomFields = computed(() => {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { constructeurs } = useConstructeurs();
|
||||||
|
const { products, loadProducts, getProduct } = useProducts();
|
||||||
|
|
||||||
|
const pieceConstructeurIds = computed(() =>
|
||||||
|
uniqueConstructeurIds(
|
||||||
|
props.piece,
|
||||||
|
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||||
|
props.piece.constructeur ? [props.piece.constructeur] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pieceConstructeursDisplay = computed(() =>
|
||||||
|
resolveConstructeurs(
|
||||||
|
pieceConstructeurIds.value,
|
||||||
|
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||||
|
props.piece.constructeur ? [props.piece.constructeur] : [],
|
||||||
|
constructeurs.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatConstructeurContact = (constructeur) =>
|
||||||
|
formatConstructeurContactSummary(constructeur);
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
currencyDisplay: "narrowSymbol",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProduct = computed(() => {
|
||||||
|
const id = pieceData.productId;
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = Array.isArray(products.value) ? products.value : [];
|
||||||
|
const cached = list.find((product) => product && product.id === id) || null;
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const current = props.piece.product;
|
||||||
|
if (current && current.id === id) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const productConstructeurs = computed(() => {
|
||||||
|
const product = selectedProduct.value;
|
||||||
|
if (!product) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = Array.isArray(product.constructeurs) ? product.constructeurs : [];
|
||||||
|
return list.filter((item) => item && typeof item === "object");
|
||||||
|
});
|
||||||
|
|
||||||
|
const productConstructeurNames = computed(() => {
|
||||||
|
const list = productConstructeurs.value;
|
||||||
|
if (!list.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
.map((constructeur) => constructeur?.name)
|
||||||
|
.filter((name) => typeof name === "string" && name.trim().length > 0)
|
||||||
|
.join(", ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const productSupplierPrice = computed(() => {
|
||||||
|
const product = selectedProduct.value;
|
||||||
|
if (!product || product.supplierPrice === undefined || product.supplierPrice === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const number = Number(product.supplierPrice);
|
||||||
|
if (Number.isNaN(number)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currencyFormatter.format(number);
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayProduct = computed(() => selectedProduct.value || props.piece.__productDisplay || null);
|
||||||
|
|
||||||
|
const displayProductName = computed(() => {
|
||||||
|
if (!displayProduct.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const product = displayProduct.value
|
||||||
|
return (
|
||||||
|
product.name ||
|
||||||
|
product.label ||
|
||||||
|
product.reference ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayProductCategory = computed(() =>
|
||||||
|
displayProduct.value
|
||||||
|
? displayProduct.value.typeProduct?.name ||
|
||||||
|
displayProduct.value.category ||
|
||||||
|
null
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayProductReference = computed(() =>
|
||||||
|
displayProduct.value ? displayProduct.value.reference || null : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayProductSuppliers = computed(() => {
|
||||||
|
if (selectedProduct.value) {
|
||||||
|
return productConstructeurNames.value;
|
||||||
|
}
|
||||||
|
if (displayProduct.value) {
|
||||||
|
return (
|
||||||
|
displayProduct.value.suppliers ||
|
||||||
|
displayProduct.value.supplierLabel ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayProductPrice = computed(() => {
|
||||||
|
if (selectedProduct.value) {
|
||||||
|
return productSupplierPrice.value;
|
||||||
|
}
|
||||||
|
if (displayProduct.value) {
|
||||||
|
const price =
|
||||||
|
displayProduct.value.price ||
|
||||||
|
displayProduct.value.priceLabel ||
|
||||||
|
displayProduct.value.priceDisplay ||
|
||||||
|
displayProduct.value.priceLabel;
|
||||||
|
return price || null;
|
||||||
|
}
|
||||||
|
return 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 =
|
||||||
|
selectedProduct.value ||
|
||||||
|
props.piece.product ||
|
||||||
|
null;
|
||||||
|
return Array.isArray(product?.documents) ? product.documents : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureProductLoaded = async (id) => {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = Array.isArray(products.value) ? products.value : [];
|
||||||
|
const cached = list.find((product) => product && product.id === id);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const result = await getProduct(id, { force: true });
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProducts().catch(() => {});
|
||||||
|
if (pieceData.productId) {
|
||||||
|
ensureProductLoaded(pieceData.productId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.piece.product?.id || props.piece.productId || null,
|
||||||
|
async (id, prevId) => {
|
||||||
|
if (pieceData.productId === id) {
|
||||||
|
if (id && !selectedProduct.value) {
|
||||||
|
const resolved = await ensureProductLoaded(id);
|
||||||
|
if (resolved) {
|
||||||
|
props.piece.product = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!id) {
|
||||||
|
props.piece.product = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pieceData.productId = id;
|
||||||
|
if (id) {
|
||||||
|
const resolved = await ensureProductLoaded(id);
|
||||||
|
if (resolved) {
|
||||||
|
props.piece.product = resolved;
|
||||||
|
const supplierPrice = resolved.supplierPrice;
|
||||||
|
if (
|
||||||
|
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||||
|
supplierPrice !== null &&
|
||||||
|
supplierPrice !== undefined
|
||||||
|
) {
|
||||||
|
const number = Number(supplierPrice);
|
||||||
|
if (!Number.isNaN(number)) {
|
||||||
|
pieceData.prix = String(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.piece.product = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProductChange = async (value) => {
|
||||||
|
const nextId = value || null;
|
||||||
|
pieceData.productId = nextId;
|
||||||
|
props.piece.productId = nextId;
|
||||||
|
|
||||||
|
if (!nextId) {
|
||||||
|
props.piece.product = null;
|
||||||
|
updatePiece();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await ensureProductLoaded(nextId);
|
||||||
|
if (resolved) {
|
||||||
|
props.piece.product = resolved;
|
||||||
|
const supplierPrice = resolved.supplierPrice;
|
||||||
|
if (
|
||||||
|
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||||
|
supplierPrice !== null &&
|
||||||
|
supplierPrice !== undefined
|
||||||
|
) {
|
||||||
|
const number = Number(supplierPrice);
|
||||||
|
if (!Number.isNaN(number)) {
|
||||||
|
pieceData.prix = String(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePiece();
|
||||||
|
};
|
||||||
|
|
||||||
const handleConstructeurChange = (value) => {
|
const handleConstructeurChange = (value) => {
|
||||||
props.piece.constructeurId = value;
|
const ids = uniqueConstructeurIds(value);
|
||||||
|
props.piece.constructeurIds = [...ids];
|
||||||
|
props.piece.constructeurId = null;
|
||||||
|
props.piece.constructeur = null;
|
||||||
|
props.piece.constructeurs = resolveConstructeurs(
|
||||||
|
ids,
|
||||||
|
constructeurs.value,
|
||||||
|
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||||
|
);
|
||||||
|
|
||||||
updatePiece();
|
updatePiece();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -967,11 +1416,25 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
|
|||||||
|
|
||||||
const updatePiece = () => {
|
const updatePiece = () => {
|
||||||
const prixValue = pieceData.prix;
|
const prixValue = pieceData.prix;
|
||||||
|
let parsedPrice = null;
|
||||||
|
if (
|
||||||
|
prixValue !== null &&
|
||||||
|
prixValue !== undefined &&
|
||||||
|
String(prixValue).trim().length > 0
|
||||||
|
) {
|
||||||
|
const numeric = Number(prixValue);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
parsedPrice = numeric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const product = selectedProduct.value ? { ...selectedProduct.value } : null;
|
||||||
emit("update", {
|
emit("update", {
|
||||||
...props.piece,
|
...props.piece,
|
||||||
...pieceData,
|
...pieceData,
|
||||||
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null,
|
prix: parsedPrice,
|
||||||
constructeurId: props.piece.constructeurId || null,
|
productId: pieceData.productId || null,
|
||||||
|
product,
|
||||||
|
constructeurIds: pieceConstructeurIds.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
Produits inclus par défaut
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="!products.length" class="text-xs text-gray-500">
|
||||||
|
Aucun produit défini.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2" role="list">
|
||||||
|
<li
|
||||||
|
v-for="(product, index) in products"
|
||||||
|
:key="product.uid"
|
||||||
|
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Famille de produit</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="product.typeProductId"
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
@change="handleProductTypeSelect(product)"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Sélectionner une famille
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="type in productTypeOptions"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ formatProductTypeOption(type) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs btn-square"
|
||||||
|
@click="removeProduct(index)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
Champs personnalisés
|
Champs personnalisés
|
||||||
</h3>
|
</h3>
|
||||||
@@ -9,173 +71,348 @@
|
|||||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<p v-if="!localFields.length" class="text-xs text-gray-500">
|
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||||
Aucun champ personnalisé n'a encore été défini.
|
Aucun champ personnalisé n'a encore été défini.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<ul v-else class="space-y-2" role="list">
|
||||||
<div
|
<li
|
||||||
v-for="(field, index) in localFields"
|
v-for="(field, index) in fields"
|
||||||
:key="`custom-field-${index}`"
|
:key="field.uid"
|
||||||
class="border border-base-200 rounded-md p-3 space-y-2"
|
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
|
||||||
|
:class="reorderClass(index)"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(index, $event)"
|
||||||
|
@dragenter="onDragEnter(index)"
|
||||||
|
@dragover.prevent="onDragEnter(index)"
|
||||||
|
@drop.prevent="onDrop(index)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex-1 space-y-2">
|
<button
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
type="button"
|
||||||
<input
|
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||||
v-model="field.name"
|
title="Réordonner"
|
||||||
type="text"
|
draggable="false"
|
||||||
class="input input-bordered input-xs"
|
>
|
||||||
placeholder="Nom du champ"
|
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||||
>
|
</button>
|
||||||
<select v-model="field.type" class="select select-bordered select-xs">
|
|
||||||
<option value="text">
|
|
||||||
Texte
|
|
||||||
</option>
|
|
||||||
<option value="number">
|
|
||||||
Nombre
|
|
||||||
</option>
|
|
||||||
<option value="select">
|
|
||||||
Liste
|
|
||||||
</option>
|
|
||||||
<option value="boolean">
|
|
||||||
Oui/Non
|
|
||||||
</option>
|
|
||||||
<option value="date">
|
|
||||||
Date
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<div class="flex-1 space-y-2">
|
||||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
Obligatoire
|
<input
|
||||||
</div>
|
v-model="field.name"
|
||||||
|
type="text"
|
||||||
<textarea
|
class="input input-bordered input-xs"
|
||||||
v-if="field.type === 'select'"
|
placeholder="Nom du champ"
|
||||||
v-model="field.optionsText"
|
>
|
||||||
class="textarea textarea-bordered textarea-xs h-20"
|
<select v-model="field.type" class="select select-bordered select-xs">
|
||||||
placeholder="Option 1 Option 2"
|
<option value="text">
|
||||||
/>
|
Texte
|
||||||
|
</option>
|
||||||
|
<option value="number">
|
||||||
|
Nombre
|
||||||
|
</option>
|
||||||
|
<option value="select">
|
||||||
|
Liste
|
||||||
|
</option>
|
||||||
|
<option value="boolean">
|
||||||
|
Oui/Non
|
||||||
|
</option>
|
||||||
|
<option value="date">
|
||||||
|
Date
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<div class="flex items-center gap-2 text-xs">
|
||||||
class="btn btn-error btn-xs btn-square"
|
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||||
@click="removeField(index)"
|
Obligatoire
|
||||||
>
|
</div>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
|
||||||
</button>
|
<textarea
|
||||||
|
v-if="field.type === 'select'"
|
||||||
|
v-model="field.optionsText"
|
||||||
|
class="textarea textarea-bordered textarea-xs h-20"
|
||||||
|
placeholder="Option 1 Option 2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs btn-square"
|
||||||
|
@click="removeField(index)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
|
import type {
|
||||||
|
PieceModelCustomField,
|
||||||
|
PieceModelCustomFieldType,
|
||||||
|
PieceModelProduct,
|
||||||
|
PieceModelStructure,
|
||||||
|
PieceModelStructureEditorField,
|
||||||
|
} from '~/shared/types/inventory'
|
||||||
|
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
|
||||||
const props = defineProps({
|
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ customFields: [] })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||||
|
type EditorProduct = {
|
||||||
|
uid: string
|
||||||
|
typeProductId: string
|
||||||
|
typeProductLabel: string
|
||||||
|
familyCode: string
|
||||||
|
}
|
||||||
|
|
||||||
const ensureArray = value => (Array.isArray(value) ? value : [])
|
const props = defineProps<{
|
||||||
|
modelValue?: PieceModelStructure | null
|
||||||
|
}>()
|
||||||
|
|
||||||
const clone = (input, fallback = {}) => {
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
|
||||||
|
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
||||||
|
Array.isArray(value) ? value : []
|
||||||
|
|
||||||
|
const normalizeLineEndings = (value: string): string =>
|
||||||
|
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
|
||||||
|
const safeClone = <T,>(value: T, fallback: T): T => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(input ?? fallback))
|
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||||
} catch (error) {
|
} catch {
|
||||||
return JSON.parse(JSON.stringify(fallback))
|
return JSON.parse(JSON.stringify(fallback)) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractRest = (structure = {}) => {
|
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
||||||
if (!structure || typeof structure !== 'object') {
|
if (!structure || typeof structure !== 'object') {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return Object.fromEntries(
|
const entries = Object.entries(structure).filter(
|
||||||
Object.entries(structure).filter(([key]) => key !== 'customFields')
|
([key]) => key !== 'customFields' && key !== 'products',
|
||||||
)
|
)
|
||||||
|
return safeClone(Object.fromEntries(entries), {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toEditorField = (input = {}) => ({
|
let uidCounter = 0
|
||||||
name: typeof input.name === 'string' ? input.name : '',
|
const createUid = (scope: 'field' | 'product'): string => {
|
||||||
type: typeof input.type === 'string' && input.type ? input.type : 'text',
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
required: Boolean(input.required),
|
return crypto.randomUUID()
|
||||||
optionsText: Array.isArray(input.options)
|
|
||||||
? input.options.join('\n')
|
|
||||||
: typeof input.optionsText === 'string'
|
|
||||||
? input.optionsText
|
|
||||||
: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const hydrateFields = (structure = {}) => ensureArray(structure.customFields).map(toEditorField)
|
|
||||||
|
|
||||||
const localState = reactive({
|
|
||||||
fields: hydrateFields(props.modelValue)
|
|
||||||
})
|
|
||||||
|
|
||||||
const extraState = reactive({
|
|
||||||
rest: clone(extractRest(props.modelValue))
|
|
||||||
})
|
|
||||||
|
|
||||||
const localFields = computed({
|
|
||||||
get: () => localState.fields,
|
|
||||||
set: (value) => {
|
|
||||||
localState.fields = ensureArray(value).map(toEditorField)
|
|
||||||
}
|
}
|
||||||
|
uidCounter += 1
|
||||||
|
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEditorField = (
|
||||||
|
input: Partial<PieceModelStructureEditorField> | null | undefined,
|
||||||
|
index: number,
|
||||||
|
): EditorField => {
|
||||||
|
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
|
||||||
|
const optionsText = normalizeLineEndings(
|
||||||
|
typeof input?.optionsText === 'string'
|
||||||
|
? input.optionsText
|
||||||
|
: Array.isArray(input?.options)
|
||||||
|
? input.options.join('\n')
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: createUid('field'),
|
||||||
|
name: typeof input?.name === 'string' ? input.name : '',
|
||||||
|
type: baseType as PieceModelCustomFieldType,
|
||||||
|
required: Boolean(input?.required),
|
||||||
|
optionsText,
|
||||||
|
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
|
||||||
|
const source = ensureArray(structure?.customFields)
|
||||||
|
return source
|
||||||
|
.map((field, index) => toEditorField(field, index))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEditorProduct = (
|
||||||
|
input: Partial<PieceModelProduct> | null | undefined,
|
||||||
|
): EditorProduct => ({
|
||||||
|
uid: createUid('product'),
|
||||||
|
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
|
||||||
|
typeProductLabel:
|
||||||
|
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
|
||||||
|
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const normalizeFields = (fields) => {
|
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
||||||
return ensureArray(fields)
|
const source = Array.isArray(structure?.products) ? structure?.products : []
|
||||||
.map((field) => {
|
return source.map((product) => toEditorProduct(product))
|
||||||
const name = typeof field.name === 'string' ? field.name.trim() : ''
|
}
|
||||||
|
|
||||||
|
const productTypeOptions = computed(() => productTypes.value ?? [])
|
||||||
|
|
||||||
|
const productTypeMap = computed(() => {
|
||||||
|
const map = new Map<string, any>()
|
||||||
|
productTypeOptions.value.forEach((type: any) => {
|
||||||
|
if (type?.id) {
|
||||||
|
map.set(type.id, type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatProductTypeOption = (type: any) => {
|
||||||
|
if (!type) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const parts: string[] = []
|
||||||
|
if (type.code) {
|
||||||
|
parts.push(type.code)
|
||||||
|
}
|
||||||
|
if (type.name) {
|
||||||
|
parts.push(type.name)
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : type.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProductTypeMetadata = (product: EditorProduct) => {
|
||||||
|
const option = product.typeProductId
|
||||||
|
? productTypeMap.value.get(product.typeProductId)
|
||||||
|
: null
|
||||||
|
product.typeProductLabel = option?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProductTypeSelect = (product: EditorProduct) => {
|
||||||
|
const option = product.typeProductId
|
||||||
|
? productTypeMap.value.get(product.typeProductId)
|
||||||
|
: null
|
||||||
|
product.typeProductLabel = option?.name ?? ''
|
||||||
|
if (option?.code) {
|
||||||
|
product.familyCode = option.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyProduct = (): EditorProduct => ({
|
||||||
|
uid: createUid('product'),
|
||||||
|
typeProductId: '',
|
||||||
|
typeProductLabel: '',
|
||||||
|
familyCode: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const addProduct = () => {
|
||||||
|
products.value.push(createEmptyProduct())
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProduct = (index: number) => {
|
||||||
|
products.value = products.value.filter((_, idx) => idx !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||||
|
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||||
|
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||||
|
|
||||||
|
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||||
|
list.map((field, index) => ({
|
||||||
|
...field,
|
||||||
|
orderIndex: index,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
||||||
|
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
||||||
|
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
||||||
|
|
||||||
|
if (!typeProductId && !familyCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: PieceModelProduct = {}
|
||||||
|
if (typeProductId) {
|
||||||
|
payload.typeProductId = typeProductId
|
||||||
|
}
|
||||||
|
if (familyCode) {
|
||||||
|
payload.familyCode = familyCode
|
||||||
|
}
|
||||||
|
if (product.typeProductLabel) {
|
||||||
|
payload.typeProductLabel = product.typeProductLabel
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = (
|
||||||
|
fieldsSource: EditorField[],
|
||||||
|
productsSource: EditorProduct[],
|
||||||
|
restSource: Record<string, unknown>,
|
||||||
|
): PieceModelStructure => {
|
||||||
|
const normalizedFields = fieldsSource
|
||||||
|
.map<PieceModelCustomField | null>((field, index) => {
|
||||||
|
const name = field.name.trim()
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = field.type || 'text'
|
const type = (field.type || 'text') as PieceModelCustomFieldType
|
||||||
const required = Boolean(field.required)
|
const required = Boolean(field.required)
|
||||||
let options
|
const payload: PieceModelCustomField = {
|
||||||
if (type === 'select') {
|
name,
|
||||||
const raw = typeof field.optionsText === 'string' ? field.optionsText : ''
|
type,
|
||||||
const parsed = raw
|
required,
|
||||||
.split(/\r?\n/)
|
orderIndex: index,
|
||||||
.map(option => option.trim())
|
|
||||||
.filter(option => option.length > 0)
|
|
||||||
options = parsed.length > 0 ? parsed : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = { name, type, required }
|
if (type === 'select') {
|
||||||
if (options) {
|
const options = normalizeLineEndings(field.optionsText)
|
||||||
normalized.options = options
|
.split('\n')
|
||||||
|
.map((option) => option.trim())
|
||||||
|
.filter((option) => option.length > 0)
|
||||||
|
if (options.length > 0) {
|
||||||
|
payload.options = options
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return normalized
|
|
||||||
|
return payload
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||||
|
|
||||||
|
const normalizedProducts = productsSource
|
||||||
|
.map((product) => normalizeProductEntry(product))
|
||||||
|
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||||
|
|
||||||
|
const draft: PieceModelStructure = {
|
||||||
|
...safeClone(restSource, {}),
|
||||||
|
products: normalizedProducts,
|
||||||
|
customFields: normalizedFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePieceStructureForSave(draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastEmitted = JSON.stringify({
|
const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
||||||
...clone(extraState.rest, {}),
|
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
|
||||||
customFields: normalizeFields(props.modelValue?.customFields)
|
}
|
||||||
})
|
|
||||||
|
let lastEmitted = serializeStructure(props.modelValue)
|
||||||
|
|
||||||
const emitUpdate = () => {
|
const emitUpdate = () => {
|
||||||
const customFields = normalizeFields(localFields.value)
|
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||||
const payload = {
|
|
||||||
...clone(extraState.rest, {}),
|
|
||||||
customFields
|
|
||||||
}
|
|
||||||
const serialized = JSON.stringify(payload)
|
const serialized = JSON.stringify(payload)
|
||||||
if (serialized !== lastEmitted) {
|
if (serialized !== lastEmitted) {
|
||||||
lastEmitted = serialized
|
lastEmitted = serialized
|
||||||
@@ -183,26 +420,121 @@ const emitUpdate = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(fields, emitUpdate, { deep: true })
|
||||||
|
watch(products, emitUpdate, { deep: true })
|
||||||
|
watch(productTypeOptions, () => {
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(value) => {
|
(value) => {
|
||||||
localFields.value = hydrateFields(value)
|
const incomingSerialized = serializeStructure(value)
|
||||||
extraState.rest = clone(extractRest(value), {})
|
if (incomingSerialized === lastEmitted) {
|
||||||
lastEmitted = JSON.stringify({
|
return
|
||||||
...clone(extraState.rest, {}),
|
}
|
||||||
customFields: normalizeFields(value?.customFields)
|
restState.value = extractRest(value)
|
||||||
})
|
fields.value = hydrateFields(value)
|
||||||
|
products.value = hydrateProducts(value)
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
|
lastEmitted = incomingSerialized
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(localFields, emitUpdate, { deep: true })
|
onMounted(async () => {
|
||||||
|
if (!productTypeOptions.value.length) {
|
||||||
|
await loadProductTypes()
|
||||||
|
}
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dragState = reactive({
|
||||||
|
draggingIndex: null as number | null,
|
||||||
|
dropTargetIndex: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
dragState.draggingIndex = null
|
||||||
|
dragState.dropTargetIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderFields = (from: number, to: number) => {
|
||||||
|
if (from === to) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = fields.value.slice()
|
||||||
|
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [moved] = list.splice(from, 1)
|
||||||
|
list.splice(to, 0, moved)
|
||||||
|
fields.value = applyOrderIndex(list)
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragStart = (index: number, event: DragEvent) => {
|
||||||
|
dragState.draggingIndex = index
|
||||||
|
dragState.dropTargetIndex = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnter = (index: number) => {
|
||||||
|
if (dragState.draggingIndex === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dragState.dropTargetIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (index: number) => {
|
||||||
|
if (dragState.draggingIndex === null) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reorderFields(dragState.draggingIndex, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = () => {
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderClass = (index: number) => {
|
||||||
|
if (dragState.draggingIndex === index) {
|
||||||
|
return 'border-dashed border-primary bg-primary/5'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
dragState.draggingIndex !== null &&
|
||||||
|
dragState.dropTargetIndex === index &&
|
||||||
|
dragState.draggingIndex !== index
|
||||||
|
) {
|
||||||
|
return 'border-primary border-dashed bg-primary/10'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||||
|
uid: createUid('field'),
|
||||||
|
name: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
optionsText: '',
|
||||||
|
orderIndex,
|
||||||
|
})
|
||||||
|
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
localFields.value = [...localFields.value, toEditorField()]
|
const next = fields.value.slice()
|
||||||
|
next.push(createEmptyField(next.length))
|
||||||
|
fields.value = applyOrderIndex(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeField = (index) => {
|
const removeField = (index: number) => {
|
||||||
localFields.value = localFields.value.filter((_, i) => i !== index)
|
const next = fields.value.filter((_, i) => i !== index)
|
||||||
|
fields.value = applyOrderIndex(next)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
116
app/components/ProductSelect.vue
Normal file
116
app/components/ProductSelect.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="modelValue"
|
||||||
|
:options="productOptions"
|
||||||
|
:loading="loading"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
size="sm"
|
||||||
|
option-value="id"
|
||||||
|
option-label="name"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:modelValue="updateValue"
|
||||||
|
>
|
||||||
|
<template #option-description="{ option }">
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{{ formatDescription(option) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SearchSelect>
|
||||||
|
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||||
|
{{ helperText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null
|
||||||
|
placeholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
helperText?: string
|
||||||
|
disabled?: boolean
|
||||||
|
typeProductId?: string | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: 'Sélectionner un produit…',
|
||||||
|
emptyText: 'Aucun produit disponible',
|
||||||
|
helperText: '',
|
||||||
|
disabled: false,
|
||||||
|
typeProductId: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { products, loading, loadProducts } = useProducts()
|
||||||
|
|
||||||
|
const productOptions = computed(() => {
|
||||||
|
const baseOptions = Array.isArray(products.value) ? products.value : []
|
||||||
|
if (!props.typeProductId) {
|
||||||
|
return baseOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypeId = String(props.typeProductId)
|
||||||
|
return baseOptions.filter((product) => {
|
||||||
|
const typeId =
|
||||||
|
product?.typeProductId ||
|
||||||
|
product?.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
return typeId ? String(typeId) === allowedTypeId : false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (productOptions.value.length === 0) {
|
||||||
|
loadProducts().catch((error) => {
|
||||||
|
console.error('Erreur lors du chargement des produits:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const exists = productOptions.value.some((product) => product.id === value)
|
||||||
|
if (!exists && productOptions.value.length === 0 && !loading.value) {
|
||||||
|
loadProducts().catch((error) => {
|
||||||
|
console.error('Erreur lors du chargement des produits:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateValue = (value: string | number | null | undefined) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDescription = (option: any) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (option?.reference) {
|
||||||
|
parts.push(option.reference)
|
||||||
|
}
|
||||||
|
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||||
|
const price = Number(option.supplierPrice)
|
||||||
|
if (!Number.isNaN(price)) {
|
||||||
|
parts.push(`${price.toFixed(2)} €`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -79,9 +79,27 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(field, index) in node.customFields"
|
v-for="(field, index) in node.customFields"
|
||||||
:key="`field-${index}`"
|
:key="`field-${index}`"
|
||||||
class="border border-base-200 rounded-md p-3 space-y-2"
|
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
|
||||||
|
:class="customFieldReorderClass(index)"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onCustomFieldDragStart(index, $event)"
|
||||||
|
@dragenter="onCustomFieldDragEnter(index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onCustomFieldDrop(index)"
|
||||||
|
@dragend="onCustomFieldDragEnd"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
|
title="Réorganiser"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart.stop="onCustomFieldDragStart(index, $event)"
|
||||||
|
>
|
||||||
|
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -121,6 +139,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="isRoot" class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h4 :class="headingClass">
|
||||||
|
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
|
||||||
|
Aucun produit défini.
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(product, index) in node.products"
|
||||||
|
:key="`product-${index}`"
|
||||||
|
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
|
||||||
|
:class="productReorderClass(index)"
|
||||||
|
@dragenter="onProductDragEnter(index)"
|
||||||
|
@dragover="onProductDragOver"
|
||||||
|
@drop="onProductDrop(index)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
|
draggable="true"
|
||||||
|
title="Réorganiser"
|
||||||
|
@dragstart="onProductDragStart(index, $event)"
|
||||||
|
@dragend="onProductDragEnd"
|
||||||
|
>
|
||||||
|
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
|
||||||
|
<select
|
||||||
|
v-model="product.typeProductId"
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
@change="handleProductTypeSelect(product)"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Sélectionner une famille
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="type in productTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ formatProductTypeOption(type) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h4 :class="headingClass">
|
<h4 :class="headingClass">
|
||||||
@@ -233,6 +314,7 @@
|
|||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
:component-types="componentTypes"
|
:component-types="componentTypes"
|
||||||
:piece-types="pieceTypes"
|
:piece-types="pieceTypes"
|
||||||
|
:product-types="productTypes"
|
||||||
:allow-subcomponents="childAllowSubcomponents"
|
:allow-subcomponents="childAllowSubcomponents"
|
||||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||||
@remove="removeSubComponent(index)"
|
@remove="removeSubComponent(index)"
|
||||||
@@ -250,7 +332,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||||
import type { ComponentModelPiece, ComponentModelStructureNode } from '~/shared/types/inventory'
|
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
|
||||||
|
|
||||||
defineOptions({ name: 'StructureNodeEditor' })
|
defineOptions({ name: 'StructureNodeEditor' })
|
||||||
|
|
||||||
@@ -263,6 +345,7 @@ type ModelTypeOption = {
|
|||||||
type EditableStructureNode = ComponentModelStructureNode & {
|
type EditableStructureNode = ComponentModelStructureNode & {
|
||||||
customFields?: any[]
|
customFields?: any[]
|
||||||
pieces?: ComponentModelPiece[]
|
pieces?: ComponentModelPiece[]
|
||||||
|
products?: ComponentModelProduct[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -270,6 +353,7 @@ const props = withDefaults(defineProps<{
|
|||||||
depth?: number
|
depth?: number
|
||||||
componentTypes?: ModelTypeOption[]
|
componentTypes?: ModelTypeOption[]
|
||||||
pieceTypes?: ModelTypeOption[]
|
pieceTypes?: ModelTypeOption[]
|
||||||
|
productTypes?: ModelTypeOption[]
|
||||||
isRoot?: boolean
|
isRoot?: boolean
|
||||||
lockType?: boolean
|
lockType?: boolean
|
||||||
lockedTypeLabel?: string
|
lockedTypeLabel?: string
|
||||||
@@ -279,6 +363,7 @@ const props = withDefaults(defineProps<{
|
|||||||
depth: 0,
|
depth: 0,
|
||||||
componentTypes: () => [],
|
componentTypes: () => [],
|
||||||
pieceTypes: () => [],
|
pieceTypes: () => [],
|
||||||
|
productTypes: () => [],
|
||||||
isRoot: false,
|
isRoot: false,
|
||||||
lockType: false,
|
lockType: false,
|
||||||
lockedTypeLabel: '',
|
lockedTypeLabel: '',
|
||||||
@@ -290,6 +375,7 @@ const emit = defineEmits(['remove'])
|
|||||||
|
|
||||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||||
|
const productTypes = computed(() => props.productTypes ?? [])
|
||||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||||
const maxSubcomponentDepth = computed(() =>
|
const maxSubcomponentDepth = computed(() =>
|
||||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||||
@@ -354,6 +440,16 @@ const pieceTypeMap = computed(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const productTypeMap = computed(() => {
|
||||||
|
const map = new Map<string, ModelTypeOption>()
|
||||||
|
productTypes.value.forEach((type) => {
|
||||||
|
if (type && typeof type.id === 'string') {
|
||||||
|
map.set(type.id, type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
const getComponentTypeLabel = (id?: string) => {
|
const getComponentTypeLabel = (id?: string) => {
|
||||||
if (!id) return ''
|
if (!id) return ''
|
||||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||||
@@ -364,16 +460,26 @@ const getPieceTypeLabel = (id?: string) => {
|
|||||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getProductTypeLabel = (id?: string) => {
|
||||||
|
if (!id) return ''
|
||||||
|
return formatModelTypeOption(productTypeMap.value.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||||
formatModelTypeOption(type)
|
formatModelTypeOption(type)
|
||||||
|
|
||||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||||
formatModelTypeOption(type)
|
formatModelTypeOption(type)
|
||||||
|
|
||||||
const ensureArray = (key: 'customFields' | 'pieces' | 'subcomponents') => {
|
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||||
|
formatModelTypeOption(type)
|
||||||
|
|
||||||
|
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
|
||||||
if (!Array.isArray((props.node as any)[key])) {
|
if (!Array.isArray((props.node as any)[key])) {
|
||||||
if (key === 'subcomponents') {
|
if (key === 'subcomponents') {
|
||||||
props.node.subcomponents = []
|
props.node.subcomponents = []
|
||||||
|
} else if (key === 'products') {
|
||||||
|
props.node.products = []
|
||||||
} else {
|
} else {
|
||||||
(props.node as any)[key] = []
|
(props.node as any)[key] = []
|
||||||
}
|
}
|
||||||
@@ -475,6 +581,37 @@ const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
|
||||||
|
if (!product) return
|
||||||
|
|
||||||
|
if (product.typeProductId) {
|
||||||
|
const option = productTypeMap.value.get(product.typeProductId)
|
||||||
|
if (option) {
|
||||||
|
product.typeProductLabel = formatProductTypeOption(option)
|
||||||
|
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.typeProductLabel) {
|
||||||
|
const normalized = product.typeProductLabel.trim().toLowerCase()
|
||||||
|
if (normalized) {
|
||||||
|
const match = productTypes.value.find((type) => {
|
||||||
|
const formatted = formatProductTypeOption(type).toLowerCase()
|
||||||
|
const name = (type?.name ?? '').toLowerCase()
|
||||||
|
const code = (type?.code ?? '').toLowerCase()
|
||||||
|
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||||
|
})
|
||||||
|
if (match) {
|
||||||
|
product.typeProductId = match.id
|
||||||
|
product.typeProductLabel = formatProductTypeOption(match)
|
||||||
|
product.familyCode = match.code ?? product.familyCode ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const syncPieceLabels = (pieces?: any[]) => {
|
const syncPieceLabels = (pieces?: any[]) => {
|
||||||
if (!Array.isArray(pieces)) {
|
if (!Array.isArray(pieces)) {
|
||||||
return
|
return
|
||||||
@@ -484,6 +621,15 @@ const syncPieceLabels = (pieces?: any[]) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncProductLabels = (products?: any[]) => {
|
||||||
|
if (!Array.isArray(products)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
products.forEach((product) => {
|
||||||
|
updateProductTypeLabel(product)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleComponentTypeSelect = (component: any) => {
|
const handleComponentTypeSelect = (component: any) => {
|
||||||
syncComponentType(component)
|
syncComponentType(component)
|
||||||
}
|
}
|
||||||
@@ -506,20 +652,116 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
|
|||||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
|
||||||
|
if (!product) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
|
||||||
|
if (!id) {
|
||||||
|
product.typeProductLabel = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const option = productTypeMap.value.get(id)
|
||||||
|
if (!option) {
|
||||||
|
product.typeProductId = ''
|
||||||
|
product.typeProductLabel = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
product.typeProductLabel = formatProductTypeOption(option)
|
||||||
|
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFieldDragState = ref({
|
||||||
|
draggingIndex: null as number | null,
|
||||||
|
dropTargetIndex: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const reindexCustomFields = () => {
|
||||||
|
if (!Array.isArray(props.node.customFields)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.node.customFields.forEach((field: any, index: number) => {
|
||||||
|
if (!field || typeof field !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field.orderIndex = index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCustomFieldDragState = () => {
|
||||||
|
customFieldDragState.value.draggingIndex = null
|
||||||
|
customFieldDragState.value.dropTargetIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
|
||||||
|
customFieldDragState.value.draggingIndex = index
|
||||||
|
customFieldDragState.value.dropTargetIndex = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCustomFieldDragEnter = (index: number) => {
|
||||||
|
if (customFieldDragState.value.draggingIndex === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customFieldDragState.value.dropTargetIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCustomFieldDrop = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.customFields)) {
|
||||||
|
resetCustomFieldDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const from = customFieldDragState.value.draggingIndex
|
||||||
|
const to = index
|
||||||
|
if (from === null || to === null) {
|
||||||
|
resetCustomFieldDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveItemInPlace(props.node.customFields, from, to)
|
||||||
|
reindexCustomFields()
|
||||||
|
resetCustomFieldDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCustomFieldDragEnd = () => {
|
||||||
|
resetCustomFieldDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFieldReorderClass = (index: number) => {
|
||||||
|
if (customFieldDragState.value.draggingIndex === index) {
|
||||||
|
return 'border-dashed border-primary'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
customFieldDragState.value.draggingIndex !== null &&
|
||||||
|
customFieldDragState.value.dropTargetIndex === index &&
|
||||||
|
customFieldDragState.value.draggingIndex !== index
|
||||||
|
) {
|
||||||
|
return 'border-primary border-dashed bg-primary/5'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const addCustomField = () => {
|
const addCustomField = () => {
|
||||||
ensureArray('customFields')
|
ensureArray('customFields')
|
||||||
|
const nextIndex = Array.isArray(props.node.customFields)
|
||||||
|
? props.node.customFields.length
|
||||||
|
: 0
|
||||||
props.node.customFields.push({
|
props.node.customFields.push({
|
||||||
name: '',
|
name: '',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: false,
|
required: false,
|
||||||
optionsText: '',
|
optionsText: '',
|
||||||
options: [],
|
options: [],
|
||||||
|
orderIndex: nextIndex,
|
||||||
})
|
})
|
||||||
|
reindexCustomFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCustomField = (index: number) => {
|
const removeCustomField = (index: number) => {
|
||||||
if (!Array.isArray(props.node.customFields)) return
|
if (!Array.isArray(props.node.customFields)) return
|
||||||
props.node.customFields.splice(index, 1)
|
props.node.customFields.splice(index, 1)
|
||||||
|
reindexCustomFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPiece = () => {
|
const addPiece = () => {
|
||||||
@@ -538,6 +780,20 @@ const removePiece = (index: number) => {
|
|||||||
props.node.pieces.splice(index, 1)
|
props.node.pieces.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addProduct = () => {
|
||||||
|
ensureArray('products')
|
||||||
|
props.node.products.push({
|
||||||
|
typeProductId: '',
|
||||||
|
typeProductLabel: '',
|
||||||
|
familyCode: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProduct = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.products)) return
|
||||||
|
props.node.products.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
const addSubComponent = () => {
|
const addSubComponent = () => {
|
||||||
if (!canManageSubcomponents.value) {
|
if (!canManageSubcomponents.value) {
|
||||||
return
|
return
|
||||||
@@ -560,6 +816,8 @@ const removeSubComponent = (index: number) => {
|
|||||||
|
|
||||||
const draggingPieceIndex = ref<number | null>(null)
|
const draggingPieceIndex = ref<number | null>(null)
|
||||||
const pieceDropTargetIndex = ref<number | null>(null)
|
const pieceDropTargetIndex = ref<number | null>(null)
|
||||||
|
const draggingProductIndex = ref<number | null>(null)
|
||||||
|
const productDropTargetIndex = ref<number | null>(null)
|
||||||
const draggingSubcomponentIndex = ref<number | null>(null)
|
const draggingSubcomponentIndex = ref<number | null>(null)
|
||||||
const subcomponentDropTargetIndex = ref<number | null>(null)
|
const subcomponentDropTargetIndex = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -581,6 +839,11 @@ const resetPieceDragState = () => {
|
|||||||
pieceDropTargetIndex.value = null
|
pieceDropTargetIndex.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetProductDragState = () => {
|
||||||
|
draggingProductIndex.value = null
|
||||||
|
productDropTargetIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
const onPieceDragStart = (index: number, event: DragEvent) => {
|
const onPieceDragStart = (index: number, event: DragEvent) => {
|
||||||
draggingPieceIndex.value = index
|
draggingPieceIndex.value = index
|
||||||
pieceDropTargetIndex.value = index
|
pieceDropTargetIndex.value = index
|
||||||
@@ -633,6 +896,58 @@ const pieceReorderClass = (index: number) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onProductDragStart = (index: number, event: DragEvent) => {
|
||||||
|
draggingProductIndex.value = index
|
||||||
|
productDropTargetIndex.value = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProductDragEnter = (index: number) => {
|
||||||
|
if (draggingProductIndex.value === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productDropTargetIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProductDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProductDrop = (index: number) => {
|
||||||
|
if (!Array.isArray(props.node.products)) {
|
||||||
|
resetProductDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const from = draggingProductIndex.value
|
||||||
|
const to = index
|
||||||
|
if (from === null || to === null) {
|
||||||
|
resetProductDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moveItemInPlace(props.node.products, from, to)
|
||||||
|
resetProductDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProductDragEnd = () => {
|
||||||
|
resetProductDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const productReorderClass = (index: number) => {
|
||||||
|
if (draggingProductIndex.value === index) {
|
||||||
|
return 'border-dashed border-primary'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
draggingProductIndex.value !== null &&
|
||||||
|
productDropTargetIndex.value === index &&
|
||||||
|
draggingProductIndex.value !== index
|
||||||
|
) {
|
||||||
|
return 'border-primary border-dashed bg-primary/5'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const resetSubcomponentDragState = () => {
|
const resetSubcomponentDragState = () => {
|
||||||
draggingSubcomponentIndex.value = null
|
draggingSubcomponentIndex.value = null
|
||||||
subcomponentDropTargetIndex.value = null
|
subcomponentDropTargetIndex.value = null
|
||||||
@@ -723,6 +1038,34 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(productTypes, () => {
|
||||||
|
syncProductLabels(props.node?.products)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node.products,
|
||||||
|
(value) => {
|
||||||
|
syncProductLabels(value)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node.customFields,
|
||||||
|
(value) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value.sort((a: any, b: any) => {
|
||||||
|
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||||
|
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||||
|
return left - right
|
||||||
|
})
|
||||||
|
reindexCustomFields()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.lockedTypeLabel, props.lockType],
|
() => [props.lockedTypeLabel, props.lockType],
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -24,11 +24,26 @@
|
|||||||
<div v-if="expanded" class="space-y-4">
|
<div v-if="expanded" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-for="(field, fieldIndex) in fields"
|
v-for="(field, fieldIndex) in fields"
|
||||||
:key="fieldIndex"
|
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
|
||||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
|
||||||
|
:class="fieldReorderClass(fieldIndex)"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onFieldDragStart(fieldIndex, $event)"
|
||||||
|
@dragenter="onFieldDragEnter(fieldIndex)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onFieldDrop(fieldIndex)"
|
||||||
|
@dragend="onFieldDragEnd"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
|
title="Réorganiser"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs p-1"
|
class="btn btn-ghost btn-xs p-1"
|
||||||
@@ -160,6 +175,7 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|||||||
import IconLucideListChecks from '~icons/lucide/list-checks'
|
import IconLucideListChecks from '~icons/lucide/list-checks'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -183,8 +199,57 @@ const fields = computed({
|
|||||||
set: value => emit('update:modelValue', value)
|
set: value => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const expandedFields = ref([])
|
const expandedFields = ref([])
|
||||||
|
const draggingFieldIndex = ref(null)
|
||||||
|
const fieldDropTargetIndex = ref(null)
|
||||||
|
|
||||||
|
const applyOrderIndex = (list = []) => {
|
||||||
|
if (!Array.isArray(list)) { return [] }
|
||||||
|
list.forEach((field, index) => {
|
||||||
|
if (field && typeof field === 'object') {
|
||||||
|
field.orderIndex = index
|
||||||
|
if (typeof field.__key !== 'string' || !field.__key) {
|
||||||
|
field.__key = createFieldKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyField = () => ({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
required: false,
|
||||||
|
optionsText: '',
|
||||||
|
orderIndex: fields.value.length,
|
||||||
|
__key: createFieldKey()
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
draggingFieldIndex.value = null
|
||||||
|
fieldDropTargetIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderFields = (from, to) => {
|
||||||
|
const list = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [moved] = list.splice(from, 1)
|
||||||
|
list.splice(to, 0, moved)
|
||||||
|
if (Array.isArray(expandedFields.value)) {
|
||||||
|
const expandedCopy = expandedFields.value.slice()
|
||||||
|
const [expandedState] = expandedCopy.splice(from, 1)
|
||||||
|
expandedCopy.splice(to, 0, expandedState)
|
||||||
|
expandedFields.value = expandedCopy
|
||||||
|
}
|
||||||
|
fields.value = applyOrderIndex(list)
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.expandAllTrigger,
|
() => props.expandAllTrigger,
|
||||||
@@ -223,26 +288,25 @@ const toggleField = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
fields.value = [
|
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||||
...fields.value,
|
next.push(createEmptyField())
|
||||||
{
|
fields.value = applyOrderIndex(next)
|
||||||
name: '',
|
|
||||||
type: '',
|
|
||||||
required: false,
|
|
||||||
optionsText: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
expandedFields.value.push(true)
|
expandedFields.value.push(true)
|
||||||
expanded.value = true
|
expanded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeField = (index) => {
|
const removeField = (index) => {
|
||||||
fields.value = fields.value.filter((_, i) => i !== index)
|
const next = Array.isArray(fields.value)
|
||||||
|
? fields.value.filter((_, i) => i !== index)
|
||||||
|
: []
|
||||||
|
fields.value = applyOrderIndex(next)
|
||||||
expandedFields.value.splice(index, 1)
|
expandedFields.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateField = (index, patch) => {
|
const updateField = (index, patch) => {
|
||||||
fields.value = fields.value.map((field, i) => (i === index ? { ...field, ...patch } : field))
|
const next = Array.isArray(fields.value) ? fields.value.slice() : []
|
||||||
|
next[index] = { ...next[index], ...patch }
|
||||||
|
fields.value = applyOrderIndex(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateOptions = (index, value) => {
|
const updateOptions = (index, value) => {
|
||||||
@@ -250,4 +314,43 @@ const updateOptions = (index, value) => {
|
|||||||
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onFieldDragStart = (index, event) => {
|
||||||
|
draggingFieldIndex.value = index
|
||||||
|
fieldDropTargetIndex.value = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFieldDragEnter = (index) => {
|
||||||
|
if (draggingFieldIndex.value === null) { return }
|
||||||
|
fieldDropTargetIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFieldDrop = (index) => {
|
||||||
|
if (draggingFieldIndex.value === null) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reorderFields(draggingFieldIndex.value, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFieldDragEnd = () => {
|
||||||
|
resetDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldReorderClass = (index) => {
|
||||||
|
if (draggingFieldIndex.value === index) {
|
||||||
|
return 'border-dashed border-primary'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
draggingFieldIndex.value !== null &&
|
||||||
|
fieldDropTargetIndex.value === index &&
|
||||||
|
draggingFieldIndex.value !== index
|
||||||
|
) {
|
||||||
|
return 'border-primary border-dashed bg-primary/5'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TypeEditProductRequirementsSection
|
||||||
|
:model-value="formData.productRequirements"
|
||||||
|
@update:model-value="(value) => (formData.productRequirements = value)"
|
||||||
|
/>
|
||||||
|
|
||||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,6 +43,7 @@ import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSectio
|
|||||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
||||||
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
||||||
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
||||||
|
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -54,14 +60,45 @@ const emit = defineEmits(['update:modelValue', 'submit'])
|
|||||||
|
|
||||||
const deepClone = value => JSON.parse(JSON.stringify(value))
|
const deepClone = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||||
|
|
||||||
|
const normalizeCustomField = (field = {}, index = 0) => {
|
||||||
|
const clone = deepClone(field)
|
||||||
|
if (clone.type === 'select') {
|
||||||
|
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
||||||
|
if (Array.isArray(clone.options)) {
|
||||||
|
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
||||||
|
} else {
|
||||||
|
clone.optionsText = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentOrder =
|
||||||
|
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||||
|
clone.orderIndex = currentOrder
|
||||||
|
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||||
|
clone.__key = createFieldKey()
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
const withNormalizedOrder = (items = []) => {
|
||||||
|
if (!Array.isArray(items)) { return [] }
|
||||||
|
return items
|
||||||
|
.map((item, index) => normalizeCustomField(item, index))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((item, index) => ({ ...item, orderIndex: index }))
|
||||||
|
}
|
||||||
|
|
||||||
const createDefaultForm = (source = {}) => ({
|
const createDefaultForm = (source = {}) => ({
|
||||||
name: source.name || '',
|
name: source.name || '',
|
||||||
description: source.description || '',
|
description: source.description || '',
|
||||||
category: source.category || '',
|
category: source.category || '',
|
||||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||||
customFields: deepClone(source.customFields || []),
|
customFields: withNormalizedOrder(source.customFields || []),
|
||||||
componentRequirements: deepClone(source.componentRequirements || []),
|
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
||||||
pieceRequirements: deepClone(source.pieceRequirements || [])
|
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
||||||
|
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = reactive(createDefaultForm(props.modelValue))
|
const formData = reactive(createDefaultForm(props.modelValue))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
|
||||||
@@ -91,5 +91,11 @@ onMounted(async () => {
|
|||||||
if (!pieceTypes.value.length) {
|
if (!pieceTypes.value.length) {
|
||||||
await loadPieceTypes()
|
await loadPieceTypes()
|
||||||
}
|
}
|
||||||
|
console.log('[PieceRequirementsSection] pieceTypes loaded:', pieceTypes.value.map(t => ({ id: t.id, name: t.name })))
|
||||||
|
console.log('[PieceRequirementsSection] requirements on mount:', props.modelValue.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
console.log('[PieceRequirementsSection] requirements updated:', newVal.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||||
|
}, { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
95
app/components/TypeEditProductRequirementsSection.vue
Normal file
95
app/components/TypeEditProductRequirementsSection.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<RequirementListEditor
|
||||||
|
v-model="requirements"
|
||||||
|
:type-options="productTypes"
|
||||||
|
type-field="typeProductId"
|
||||||
|
:labels="labels"
|
||||||
|
:default-requirement="createDefaultRequirement"
|
||||||
|
:required-fallback="false"
|
||||||
|
:min-fallback="0"
|
||||||
|
:type-loading="loadingProductTypes"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
|
||||||
|
type Requirement = Record<string, unknown> & {
|
||||||
|
id?: string | number
|
||||||
|
typeProductId?: string | number | null
|
||||||
|
label?: string
|
||||||
|
minCount?: number | null
|
||||||
|
maxCount?: number | null
|
||||||
|
required?: boolean | null
|
||||||
|
allowNewModels?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Labels = {
|
||||||
|
headerTitle: string
|
||||||
|
addButton: string
|
||||||
|
description: string
|
||||||
|
emptyState: string
|
||||||
|
typeSelectLabel: string
|
||||||
|
typePlaceholder: string
|
||||||
|
labelFieldLabel: string
|
||||||
|
labelFieldHelper: string
|
||||||
|
labelPlaceholder: string
|
||||||
|
minLabel: string
|
||||||
|
maxLabel: string
|
||||||
|
maxHelper: string
|
||||||
|
requiredLabel: string
|
||||||
|
allowNewModelsLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array as () => Requirement[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||||
|
|
||||||
|
const requirements = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createDefaultRequirement = (): Requirement => ({
|
||||||
|
id: undefined,
|
||||||
|
typeProductId: null,
|
||||||
|
label: '',
|
||||||
|
minCount: 0,
|
||||||
|
maxCount: null,
|
||||||
|
required: false,
|
||||||
|
allowNewModels: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels: Labels = {
|
||||||
|
headerTitle: 'Produits requis',
|
||||||
|
addButton: 'Ajouter un produit',
|
||||||
|
description:
|
||||||
|
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
|
||||||
|
emptyState: 'Aucun produit requis configuré pour le moment.',
|
||||||
|
typeSelectLabel: 'Catégorie de produit',
|
||||||
|
typePlaceholder: 'Sélectionner une catégorie',
|
||||||
|
labelFieldLabel: 'Libellé',
|
||||||
|
labelFieldHelper: 'Optionnel',
|
||||||
|
labelPlaceholder: 'Ex : Lubrifiant recommandé',
|
||||||
|
minLabel: 'Minimum requis',
|
||||||
|
maxLabel: 'Maximum autorisé',
|
||||||
|
maxHelper: 'Laisser vide pour illimité',
|
||||||
|
requiredLabel: 'Requis',
|
||||||
|
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!productTypes.value.length) {
|
||||||
|
await loadProductTypes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
|
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
|
||||||
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
|
||||||
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
|
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
|
||||||
|
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
|
||||||
<p v-if="type.description">
|
<p v-if="type.description">
|
||||||
<strong>Description:</strong> {{ type.description }}
|
<strong>Description:</strong> {{ type.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
128
app/components/common/Pagination.vue
Normal file
128
app/components/common/Pagination.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
@click="goToPage(1)"
|
||||||
|
>
|
||||||
|
<IconLucideChevronFirst class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
>
|
||||||
|
<IconLucideChevronLeft class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-for="page in visiblePages" :key="page">
|
||||||
|
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
>
|
||||||
|
<IconLucideChevronRight class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
@click="goToPage(totalPages)"
|
||||||
|
>
|
||||||
|
<IconLucideChevronLast class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
|
||||||
|
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
|
||||||
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
totalPages: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
maxVisiblePages: {
|
||||||
|
type: Number,
|
||||||
|
default: 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage'])
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const pages = []
|
||||||
|
const total = props.totalPages
|
||||||
|
const current = props.currentPage
|
||||||
|
const maxVisible = props.maxVisiblePages
|
||||||
|
|
||||||
|
if (total <= maxVisible + 2) {
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show first page
|
||||||
|
pages.push(1)
|
||||||
|
|
||||||
|
const half = Math.floor(maxVisible / 2)
|
||||||
|
let start = Math.max(2, current - half)
|
||||||
|
let end = Math.min(total - 1, current + half)
|
||||||
|
|
||||||
|
// Adjust if near start
|
||||||
|
if (current <= half + 1) {
|
||||||
|
end = maxVisible
|
||||||
|
}
|
||||||
|
// Adjust if near end
|
||||||
|
if (current >= total - half) {
|
||||||
|
start = total - maxVisible + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 2) {
|
||||||
|
pages.push('ellipsis-start')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < total - 1) {
|
||||||
|
pages.push('ellipsis-end')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page
|
||||||
|
pages.push(total)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||||
|
emit('update:currentPage', page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -122,7 +122,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue', 'search'])
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const openDropdown = ref(false)
|
const openDropdown = ref(false)
|
||||||
@@ -184,11 +184,13 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
baseOptions,
|
baseOptions,
|
||||||
() => {
|
(newOptions) => {
|
||||||
if (!openDropdown.value) {
|
console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
|
||||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value
|
if (!openDropdown.value && selectedOption.value) {
|
||||||
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(openDropdown, (isOpen) => {
|
watch(openDropdown, (isOpen) => {
|
||||||
@@ -265,6 +267,7 @@ function handleInput () {
|
|||||||
if (!openDropdown.value) {
|
if (!openDropdown.value) {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
}
|
}
|
||||||
|
emit('search', searchTerm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDropdown () {
|
function closeDropdown () {
|
||||||
|
|||||||
@@ -29,10 +29,65 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@update:offset="onOffsetChange"
|
@update:offset="onOffsetChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||||
|
<div class="modal-box max-w-3xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
{{ relatedModalTitle }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ relatedModalSubtitle }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||||
|
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Chargement des éléments liés…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
||||||
|
{{ relatedError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="relatedItems.length === 0"
|
||||||
|
class="px-4 py-6 text-sm text-base-content/60"
|
||||||
|
>
|
||||||
|
Aucun élément lié à cette catégorie.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||||
|
<li
|
||||||
|
v-for="entry in relatedItems"
|
||||||
|
:key="entry.id"
|
||||||
|
class="px-2 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
|
@click="openRelatedEdit(entry)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||||
|
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||||
|
Référence: {{ entry.reference }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="closeRelatedModal">
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
|
import { useApi } from "~/composables/useApi";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
@@ -51,7 +107,7 @@ import {
|
|||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION =
|
const DEFAULT_DESCRIPTION =
|
||||||
"Gérez les catégories utilisées pour structurer les catalogues de composants et de pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
const descriptionText = computed(
|
const descriptionText = computed(
|
||||||
@@ -210,8 +267,15 @@ const onOffsetChange = (value: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveCategoryBasePath = (category: ModelCategory) =>
|
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||||
category === "COMPONENT" ? "/component-category" : "/piece-category";
|
if (category === "COMPONENT") {
|
||||||
|
return "/component-category";
|
||||||
|
}
|
||||||
|
if (category === "PIECE") {
|
||||||
|
return "/piece-category";
|
||||||
|
}
|
||||||
|
return "/product-category";
|
||||||
|
};
|
||||||
|
|
||||||
const openCreatePage = () => {
|
const openCreatePage = () => {
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
||||||
@@ -250,6 +314,165 @@ const confirmDelete = async (item: ModelType) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RelatedEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
reference?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const relatedModalOpen = ref(false);
|
||||||
|
const relatedLoading = ref(false);
|
||||||
|
const relatedError = ref<string | null>(null);
|
||||||
|
const relatedItems = ref<RelatedEntry[]>([]);
|
||||||
|
const relatedType = ref<ModelType | null>(null);
|
||||||
|
|
||||||
|
const relatedCategoryLabels: Record<
|
||||||
|
ModelCategory,
|
||||||
|
{ plural: string; singular: string }
|
||||||
|
> = {
|
||||||
|
COMPONENT: { plural: "composants", singular: "composant" },
|
||||||
|
PIECE: { plural: "pièces", singular: "pièce" },
|
||||||
|
PRODUCT: { plural: "produits", singular: "produit" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const relatedModalTitle = computed(() => {
|
||||||
|
const current = relatedType.value;
|
||||||
|
if (!current) {
|
||||||
|
return "Éléments liés";
|
||||||
|
}
|
||||||
|
return `Éléments liés à « ${current.name} »`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const relatedModalSubtitle = computed(() => {
|
||||||
|
const current = relatedType.value;
|
||||||
|
if (!current) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const labels =
|
||||||
|
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||||
|
const count = relatedItems.value.length;
|
||||||
|
if (relatedLoading.value) {
|
||||||
|
return `Chargement des ${labels.plural}…`;
|
||||||
|
}
|
||||||
|
if (count === 0) {
|
||||||
|
return `Aucun ${labels.singular} lié.`;
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return `1 ${labels.singular} lié.`;
|
||||||
|
}
|
||||||
|
return `${count} ${labels.plural} liés.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const 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) => {
|
||||||
|
if (category === "COMPONENT") {
|
||||||
|
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||||
|
}
|
||||||
|
if (category === "PIECE") {
|
||||||
|
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||||
|
}
|
||||||
|
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||||
|
if (category === "COMPONENT") {
|
||||||
|
return "/component";
|
||||||
|
}
|
||||||
|
if (category === "PIECE") {
|
||||||
|
return "/pieces";
|
||||||
|
}
|
||||||
|
return "/product";
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapRelatedEntry = (item: any): RelatedEntry | null => {
|
||||||
|
if (!item || typeof item !== "object" || typeof item.id !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
typeof item.name === "string" && item.name.trim()
|
||||||
|
? item.name
|
||||||
|
: "Sans nom";
|
||||||
|
const reference =
|
||||||
|
typeof item.reference === "string" && item.reference.trim()
|
||||||
|
? item.reference
|
||||||
|
: typeof item.code === "string" && item.code.trim()
|
||||||
|
? item.code
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name,
|
||||||
|
reference,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRelatedItems = async (item: ModelType) => {
|
||||||
|
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("itemsPerPage", "200");
|
||||||
|
params.set(filterKey, buildModelTypeIri(item.id));
|
||||||
|
params.set("order[name]", "asc");
|
||||||
|
|
||||||
|
relatedLoading.value = true;
|
||||||
|
relatedError.value = null;
|
||||||
|
relatedItems.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await get(`${endpoint}?${params.toString()}`);
|
||||||
|
if (!result.success) {
|
||||||
|
relatedError.value =
|
||||||
|
result.error ?? "Impossible de charger les éléments liés.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const collection = extractCollection(result.data);
|
||||||
|
relatedItems.value = collection
|
||||||
|
.map(mapRelatedEntry)
|
||||||
|
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||||
|
} catch (error) {
|
||||||
|
relatedError.value = extractErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
relatedLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRelatedModal = (item: ModelType) => {
|
||||||
|
relatedType.value = item;
|
||||||
|
relatedModalOpen.value = true;
|
||||||
|
void loadRelatedItems(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||||
|
const current = relatedType.value;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const basePath = resolveRelatedEditBasePath(current.category);
|
||||||
|
relatedModalOpen.value = false;
|
||||||
|
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||||
|
showError("Navigation impossible vers la fiche d'édition.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRelatedModal = () => {
|
||||||
|
relatedModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
|
<option value="PRODUCT">Produits</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="form.category === 'PIECE'"
|
||||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
@@ -93,9 +94,29 @@
|
|||||||
</p>
|
</p>
|
||||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Aperçu :
|
||||||
|
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||||
|
</p>
|
||||||
|
<PieceModelStructureEditor v-model="productStructure" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="disableSubmit"
|
||||||
|
class="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span>{{ disableSubmitMessage }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -114,12 +135,16 @@ import ComponentModelStructureEditor from '~/components/ComponentModelStructureE
|
|||||||
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
||||||
import {
|
import {
|
||||||
clonePieceStructure,
|
clonePieceStructure,
|
||||||
|
cloneProductStructure,
|
||||||
cloneStructure,
|
cloneStructure,
|
||||||
defaultPieceStructure,
|
defaultPieceStructure,
|
||||||
|
defaultProductStructure,
|
||||||
defaultStructure,
|
defaultStructure,
|
||||||
formatPieceStructurePreview,
|
formatPieceStructurePreview,
|
||||||
|
formatProductStructurePreview,
|
||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
normalizePieceStructureForSave,
|
normalizePieceStructureForSave,
|
||||||
|
normalizeProductStructureForSave,
|
||||||
normalizeStructureForEditor,
|
normalizeStructureForEditor,
|
||||||
normalizeStructureForSave,
|
normalizeStructureForSave,
|
||||||
} from '~/shared/modelUtils'
|
} from '~/shared/modelUtils'
|
||||||
@@ -134,6 +159,8 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading?: boolean
|
structureLoading?: boolean
|
||||||
allowComponentSubcomponents?: boolean
|
allowComponentSubcomponents?: boolean
|
||||||
componentSubcomponentMaxDepth?: number
|
componentSubcomponentMaxDepth?: number
|
||||||
|
disableSubmit?: boolean
|
||||||
|
disableSubmitMessage?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -141,6 +168,8 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading: false,
|
structureLoading: false,
|
||||||
allowComponentSubcomponents: true,
|
allowComponentSubcomponents: true,
|
||||||
componentSubcomponentMaxDepth: 1,
|
componentSubcomponentMaxDepth: 1,
|
||||||
|
disableSubmit: false,
|
||||||
|
disableSubmitMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -157,6 +186,12 @@ const componentSubcomponentMaxDepth = computed(() =>
|
|||||||
? props.componentSubcomponentMaxDepth
|
? props.componentSubcomponentMaxDepth
|
||||||
: 1,
|
: 1,
|
||||||
)
|
)
|
||||||
|
const disableSubmit = computed(() => props.disableSubmit === true)
|
||||||
|
const disableSubmitMessage = computed(() =>
|
||||||
|
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
|
||||||
|
? props.disableSubmitMessage
|
||||||
|
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||||
|
)
|
||||||
|
|
||||||
const form = reactive<ModelTypePayload>({
|
const form = reactive<ModelTypePayload>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -171,6 +206,7 @@ const nameInput = ref<HTMLInputElement | null>(null)
|
|||||||
|
|
||||||
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
|
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
|
||||||
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
|
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
|
||||||
|
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
|
||||||
|
|
||||||
const generateCodeFromName = (name: string) => {
|
const generateCodeFromName = (name: string) => {
|
||||||
const fallback = 'type'
|
const fallback = 'type'
|
||||||
@@ -196,10 +232,19 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pieceStructure.value = normalizePieceStructureForSave(
|
if (category === 'PIECE') {
|
||||||
incomingStructure && props.initialData?.category === 'PIECE'
|
pieceStructure.value = normalizePieceStructureForSave(
|
||||||
? incomingStructure
|
incomingStructure && props.initialData?.category === 'PIECE'
|
||||||
: defaultPieceStructure(),
|
? incomingStructure
|
||||||
|
: defaultPieceStructure(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
productStructure.value = normalizeProductStructureForSave(
|
||||||
|
incomingStructure && props.initialData?.category === 'PRODUCT'
|
||||||
|
? cloneProductStructure(incomingStructure)
|
||||||
|
: defaultProductStructure(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +267,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
|
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
@@ -263,10 +308,19 @@ const handleSubmit = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.category === 'PIECE') {
|
||||||
|
emit('submit', {
|
||||||
|
...common,
|
||||||
|
category: 'PIECE',
|
||||||
|
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
...common,
|
...common,
|
||||||
category: 'PIECE',
|
category: 'PRODUCT',
|
||||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +365,16 @@ watch(
|
|||||||
if (category === 'PIECE') {
|
if (category === 'PIECE') {
|
||||||
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
|
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (category === 'PRODUCT') {
|
||||||
|
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
|
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
|
||||||
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
|
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
|
||||||
|
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => nameInput.value?.focus())
|
nextTick(() => nameInput.value?.focus())
|
||||||
|
|||||||
@@ -33,20 +33,21 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="text-base-content/70">
|
<tr class="text-base-content/70">
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
<th scope="col">Catégorie</th>
|
|
||||||
<th scope="col">Notes</th>
|
<th scope="col">Notes</th>
|
||||||
<th scope="col" class="w-32 text-right">Actions</th>
|
<th scope="col" class="w-48 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in items" :key="item.id">
|
<tr v-for="item in items" :key="item.id">
|
||||||
<td class="font-medium">{{ item.name }}</td>
|
<td class="font-medium">{{ item.name }}</td>
|
||||||
<td>{{ categoryLabel(item.category) }}</td>
|
|
||||||
<td class="max-w-xs align-middle">
|
<td class="max-w-xs align-middle">
|
||||||
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
|
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
|
||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right space-x-2">
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
|
Liés
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -74,6 +75,9 @@
|
|||||||
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||||
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
|
Liés
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -125,6 +129,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): void;
|
(e: 'update:offset', offset: number): void;
|
||||||
@@ -133,6 +138,7 @@ const emit = defineEmits<{
|
|||||||
const categoryDictionary: Record<ModelCategory, string> = {
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
COMPONENT: 'Composants',
|
COMPONENT: 'Composants',
|
||||||
PIECE: 'Pièces',
|
PIECE: 'Pièces',
|
||||||
|
PRODUCT: 'Produits',
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
|
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
|
||||||
|
|||||||
@@ -78,12 +78,13 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import IconLucidePlus from '~icons/lucide/plus';
|
import IconLucidePlus from '~icons/lucide/plus';
|
||||||
import IconLucideSearch from '~icons/lucide/search';
|
import IconLucideSearch from '~icons/lucide/search';
|
||||||
|
import type { ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
type SortField = 'name' | 'createdAt';
|
type SortField = 'name' | 'createdAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
category: 'COMPONENT' | 'PIECE';
|
category: ModelCategory;
|
||||||
search: string;
|
search: string;
|
||||||
sort: SortField;
|
sort: SortField;
|
||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
@@ -92,16 +93,17 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void;
|
(e: 'update:category', value: ModelCategory): void;
|
||||||
(e: 'update:search', value: string): void;
|
(e: 'update:search', value: string): void;
|
||||||
(e: 'update:sort', value: SortField): void;
|
(e: 'update:sort', value: SortField): void;
|
||||||
(e: 'update:dir', value: SortDirection): void;
|
(e: 'update:dir', value: SortDirection): void;
|
||||||
(e: 'create'): void;
|
(e: 'create'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [
|
const categories: Array<{ label: string; value: ModelCategory }> = [
|
||||||
{ label: 'Composants', value: 'COMPONENT' },
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
{ label: 'Pièces', value: 'PIECE' },
|
{ label: 'Pièces', value: 'PIECE' },
|
||||||
|
{ label: 'Produits', value: 'PRODUCT' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const onSearch = (event: Event) => {
|
const onSearch = (event: Event) => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2 text-gray-600">
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
|
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
|
||||||
<span>{{ site.contactPhone }}</span>
|
<span>{{ formattedContactPhone }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start gap-2 text-gray-600">
|
<div class="flex items-start gap-2 text-gray-600">
|
||||||
@@ -53,6 +53,7 @@ import IconLucideFactory from '~icons/lucide/factory'
|
|||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||||
import IconLucidePhone from '~icons/lucide/phone'
|
import IconLucidePhone from '~icons/lucide/phone'
|
||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
site: {
|
site: {
|
||||||
@@ -64,4 +65,9 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['edit', 'delete'])
|
const emit = defineEmits(['edit', 'delete'])
|
||||||
|
|
||||||
const machineCount = computed(() => props.site?.machines?.length || 0)
|
const machineCount = computed(() => props.site?.machines?.length || 0)
|
||||||
|
const formattedContactPhone = computed(() => {
|
||||||
|
const value = props.site?.contactPhone ?? ''
|
||||||
|
const formatted = formatPhone(value)
|
||||||
|
return formatted || value || '—'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function useApi () {
|
|||||||
const apiCall = async (endpoint, options = {}) => {
|
const apiCall = async (endpoint, options = {}) => {
|
||||||
const url = `${API_BASE_URL}${endpoint}`
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -23,16 +24,37 @@ export function useApi () {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...options,
|
...options,
|
||||||
|
headers: {
|
||||||
|
...defaultOptions.headers,
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
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 }
|
return { success: true, data }
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
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}`
|
const errorMessage = errorData.message || `Erreur ${response.status}: ${response.statusText}`
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
@@ -52,6 +74,9 @@ export function useApi () {
|
|||||||
const post = async (endpoint, data) => {
|
const post = async (endpoint, data) => {
|
||||||
return apiCall(endpoint, {
|
return apiCall(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/ld+json'
|
||||||
|
},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -59,6 +84,9 @@ export function useApi () {
|
|||||||
const patch = async (endpoint, data) => {
|
const patch = async (endpoint, data) => {
|
||||||
return apiCall(endpoint, {
|
return apiCall(endpoint, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/merge-patch+json'
|
||||||
|
},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/composables/useCategoryEditGuard.ts
Normal file
101
app/composables/useCategoryEditGuard.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
type GuardLabels = {
|
||||||
|
singular: string
|
||||||
|
plural: string
|
||||||
|
verifying: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuardConfig = {
|
||||||
|
endpoint: string
|
||||||
|
filterKey: string
|
||||||
|
labels: GuardLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTotal = (payload: any, fallbackLength: number) => {
|
||||||
|
if (typeof payload?.totalItems === 'number') {
|
||||||
|
return payload.totalItems
|
||||||
|
}
|
||||||
|
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||||
|
return payload['hydra:totalItems']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member.length
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member'].length
|
||||||
|
}
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategoryEditGuard (config: GuardConfig) {
|
||||||
|
const { get } = useApi()
|
||||||
|
const { showError } = useToast()
|
||||||
|
|
||||||
|
const linkedCount = ref(0)
|
||||||
|
const linkedLoading = ref(false)
|
||||||
|
|
||||||
|
const loadLinkedCount = async (modelTypeId: string) => {
|
||||||
|
linkedLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', '1')
|
||||||
|
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
|
||||||
|
|
||||||
|
const result = await get(`${config.endpoint}?${params.toString()}`)
|
||||||
|
if (!result.success) {
|
||||||
|
linkedCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackLength = Array.isArray(result.data?.member)
|
||||||
|
? result.data.member.length
|
||||||
|
: Array.isArray(result.data?.['hydra:member'])
|
||||||
|
? result.data['hydra:member'].length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||||
|
} catch (error) {
|
||||||
|
linkedCount.value = 0
|
||||||
|
} finally {
|
||||||
|
linkedLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmitBlocked = computed(
|
||||||
|
() => linkedLoading.value || linkedCount.value > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const submitBlockMessage = computed(() => {
|
||||||
|
if (linkedLoading.value) {
|
||||||
|
return config.labels.verifying
|
||||||
|
}
|
||||||
|
if (linkedCount.value <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (linkedCount.value === 1) {
|
||||||
|
return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
|
||||||
|
}
|
||||||
|
return `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const guardSubmitOrNotify = () => {
|
||||||
|
if (!isSubmitBlocked.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
linkedCount,
|
||||||
|
linkedLoading,
|
||||||
|
isSubmitBlocked,
|
||||||
|
submitBlockMessage,
|
||||||
|
loadLinkedCount,
|
||||||
|
guardSubmitOrNotify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
app/composables/useComponentHistory.ts
Normal file
67
app/composables/useComponentHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
|
export type ComponentHistoryActor = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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,35 +1,145 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import { useConstructeurs } from './useConstructeurs'
|
||||||
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const composants = ref([])
|
const composants = ref([])
|
||||||
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTotal = (payload, fallbackLength) => {
|
||||||
|
if (typeof payload?.totalItems === 'number') {
|
||||||
|
return payload.totalItems
|
||||||
|
}
|
||||||
|
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||||
|
return payload['hydra:totalItems']
|
||||||
|
}
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
export function useComposants () {
|
export function useComposants () {
|
||||||
const { showSuccess, showError, showInfo } = useToast()
|
const { showSuccess, showError, showInfo } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
|
||||||
const loadComposants = async () => {
|
const withResolvedConstructeurs = async (composant) => {
|
||||||
loading.value = true
|
if (!composant || typeof composant !== 'object') {
|
||||||
try {
|
return composant
|
||||||
const result = await get('/composants')
|
}
|
||||||
if (result.success) {
|
if (!composant.typeComposantId) {
|
||||||
composants.value = result.data
|
const typeComposantId = extractRelationId(composant.typeComposant)
|
||||||
showInfo(`Chargement de ${composants.value.length} composant(s) réussi`)
|
if (typeComposantId) {
|
||||||
|
composant.typeComposantId = typeComposantId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!composant.productId) {
|
||||||
|
const productId = extractRelationId(composant.product)
|
||||||
|
if (productId) {
|
||||||
|
composant.productId = productId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = uniqueConstructeurIds(
|
||||||
|
composant.constructeurIds,
|
||||||
|
composant.constructeurs,
|
||||||
|
composant.constructeur,
|
||||||
|
)
|
||||||
|
const hasResolvedConstructeurs =
|
||||||
|
Array.isArray(composant.constructeurs)
|
||||||
|
&& composant.constructeurs.length > 0
|
||||||
|
&& composant.constructeurs.every((item) => item && typeof item === 'object')
|
||||||
|
|
||||||
|
if (ids.length && !hasResolvedConstructeurs) {
|
||||||
|
const resolved = await ensureConstructeurs(ids)
|
||||||
|
if (resolved.length) {
|
||||||
|
composant.constructeurs = resolved
|
||||||
|
composant.constructeurIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return composant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load composants with pagination and search support
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {string} [options.search] - Search term for name/reference
|
||||||
|
* @param {number} [options.page=1] - Current page (1-based)
|
||||||
|
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||||
|
* @param {string} [options.orderBy='name'] - Field to order by
|
||||||
|
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||||
|
*/
|
||||||
|
const loadComposants = async (options = {}) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
|
params.set('page', String(page))
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
params.set('name', search.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
|
const result = await get(`/composants?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection(result.data)
|
||||||
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
|
composants.value = enrichedItems
|
||||||
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: enrichedItems,
|
||||||
|
total: total.value,
|
||||||
|
page,
|
||||||
|
itemsPerPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des composants:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement des composants:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const createComposant = async (composantData) => {
|
const createComposant = async (composantData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/composants', composantData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||||
|
const result = await post('/composants', normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
composants.value.push(result.data)
|
const enriched = await withResolvedConstructeurs(result.data)
|
||||||
|
composants.value.unshift(enriched)
|
||||||
|
total.value += 1
|
||||||
const displayName = result.data?.name
|
const displayName = result.data?.name
|
||||||
|| composantData?.definition?.name
|
|| composantData?.definition?.name
|
||||||
|| composantData?.name
|
|| composantData?.name
|
||||||
@@ -48,9 +158,10 @@ const loadComposants = async () => {
|
|||||||
const updateComposantData = async (id, composantData) => {
|
const updateComposantData = async (id, composantData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/composants/${id}`, composantData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||||
|
const result = await patch(`/composants/${id}`, normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const updated = result.data
|
const updated = await withResolvedConstructeurs(result.data)
|
||||||
const index = composants.value.findIndex(comp => comp.id === id)
|
const index = composants.value.findIndex(comp => comp.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
composants.value[index] = updated
|
composants.value[index] = updated
|
||||||
@@ -73,6 +184,7 @@ const loadComposants = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const deletedComposant = composants.value.find(comp => comp.id === id)
|
const deletedComposant = composants.value.find(comp => comp.id === id)
|
||||||
composants.value = composants.value.filter(comp => comp.id !== id)
|
composants.value = composants.value.filter(comp => comp.id !== id)
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -89,6 +201,7 @@ const loadComposants = async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
composants,
|
composants,
|
||||||
|
total,
|
||||||
loading,
|
loading,
|
||||||
loadComposants,
|
loadComposants,
|
||||||
createComposant,
|
createComposant,
|
||||||
|
|||||||
@@ -5,6 +5,58 @@ import { useToast } from './useToast'
|
|||||||
const constructeurs = ref([])
|
const constructeurs = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const uniqueConstructeurs = (items = []) => {
|
||||||
|
const map = new Map()
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||||
|
map.set(item.id, item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(map.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeIds = (ids = []) => {
|
||||||
|
if (!Array.isArray(ids)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
ids
|
||||||
|
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||||
|
.filter((value) => value.length > 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertConstructeurs = (items = []) => {
|
||||||
|
if (!Array.isArray(items) || !items.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const merged = uniqueConstructeurs([...constructeurs.value, ...items])
|
||||||
|
constructeurs.value = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIndexedConstructeur = (id) =>
|
||||||
|
constructeurs.value.find((item) => item && item.id === id) || null
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingFetches = new Map()
|
||||||
|
|
||||||
export function useConstructeurs () {
|
export function useConstructeurs () {
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
@@ -15,11 +67,12 @@ export function useConstructeurs () {
|
|||||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||||
const result = await get(`/constructeurs${query}`)
|
const result = await get(`/constructeurs${query}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
constructeurs.value = result.data
|
const items = extractCollection(result.data)
|
||||||
|
constructeurs.value = uniqueConstructeurs(items)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des constructeurs:', error)
|
console.error('Erreur lors du chargement des fournisseurs:', error)
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -35,38 +88,88 @@ export function useConstructeurs () {
|
|||||||
try {
|
try {
|
||||||
const result = await post('/constructeurs', data)
|
const result = await post('/constructeurs', data)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
constructeurs.value = [result.data, ...constructeurs.value]
|
upsertConstructeurs([result.data])
|
||||||
showSuccess(`Constructeur "${result.data.name}" créé`)
|
showSuccess(`Fournisseur "${result.data.name}" créé`)
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la création du constructeur:', error)
|
console.error('Erreur lors de la création du fournisseur:', error)
|
||||||
showError('Impossible de créer le constructeur')
|
showError('Impossible de créer le fournisseur')
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureConstructeurs = async (ids = []) => {
|
||||||
|
const normalizedIds = normalizeIds(ids)
|
||||||
|
if (!normalizedIds.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = []
|
||||||
|
const missing = []
|
||||||
|
normalizedIds.forEach((id) => {
|
||||||
|
const existing = getIndexedConstructeur(id)
|
||||||
|
if (existing) {
|
||||||
|
collected.push(existing)
|
||||||
|
} else {
|
||||||
|
missing.push(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
const fetchTasks = missing.map((id) => {
|
||||||
|
const cached = pendingFetches.get(id)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
const task = get(`/constructeurs/${id}`)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Erreur lors du chargement du fournisseur:', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingFetches.delete(id)
|
||||||
|
})
|
||||||
|
pendingFetches.set(id, task)
|
||||||
|
return task
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetched = await Promise.all(fetchTasks)
|
||||||
|
const validFetched = fetched.filter((item) => item && item.id)
|
||||||
|
if (validFetched.length) {
|
||||||
|
upsertConstructeurs(validFetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedIds
|
||||||
|
.map((id) => getIndexedConstructeur(id))
|
||||||
|
.filter((item) => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
const updateConstructeur = async (id, data) => {
|
const updateConstructeur = async (id, data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/constructeurs/${id}`, data)
|
const result = await patch(`/constructeurs/${id}`, data)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const index = constructeurs.value.findIndex(item => item.id === id)
|
upsertConstructeurs([result.data])
|
||||||
if (index !== -1) {
|
showSuccess(`Fournisseur "${result.data.name}" mis à jour`)
|
||||||
constructeurs.value[index] = result.data
|
|
||||||
}
|
|
||||||
showSuccess(`Constructeur "${result.data.name}" mis à jour`)
|
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la mise à jour du constructeur:', error)
|
console.error('Erreur lors de la mise à jour du fournisseur:', error)
|
||||||
showError('Impossible de mettre à jour le constructeur')
|
showError('Impossible de mettre à jour le fournisseur')
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -79,21 +182,21 @@ export function useConstructeurs () {
|
|||||||
const result = await del(`/constructeurs/${id}`)
|
const result = await del(`/constructeurs/${id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
constructeurs.value = constructeurs.value.filter(item => item.id !== id)
|
constructeurs.value = constructeurs.value.filter(item => item.id !== id)
|
||||||
showSuccess('Constructeur supprimé')
|
showSuccess('Fournisseur supprimé')
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la suppression du constructeur:', error)
|
console.error('Erreur lors de la suppression du fournisseur:', error)
|
||||||
showError('Impossible de supprimer le constructeur')
|
showError('Impossible de supprimer le fournisseur')
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConstructeurById = id => constructeurs.value.find(item => item.id === id)
|
const getConstructeurById = (id) => getIndexedConstructeur(id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
constructeurs,
|
constructeurs,
|
||||||
@@ -103,6 +206,7 @@ export function useConstructeurs () {
|
|||||||
createConstructeur,
|
createConstructeur,
|
||||||
updateConstructeur,
|
updateConstructeur,
|
||||||
deleteConstructeur,
|
deleteConstructeur,
|
||||||
getConstructeurById
|
getConstructeurById,
|
||||||
|
ensureConstructeurs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const documents = ref([])
|
const documents = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const fileToBase64 = file =>
|
const fileToBase64 = file =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -22,7 +39,7 @@ export function useDocuments () {
|
|||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const result = await get(endpoint)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = result.data || []
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
documents.value = data
|
documents.value = data
|
||||||
}
|
}
|
||||||
@@ -60,6 +77,11 @@ export function useDocuments () {
|
|||||||
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
|
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadDocumentsByProduct = async (productId, options = {}) => {
|
||||||
|
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
|
||||||
|
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
|
||||||
|
}
|
||||||
|
|
||||||
const loadDocumentsByPiece = async (pieceId, options = {}) => {
|
const loadDocumentsByPiece = async (pieceId, options = {}) => {
|
||||||
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
|
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
|
||||||
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
|
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
|
||||||
@@ -75,14 +97,14 @@ export function useDocuments () {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const dataUrl = await fileToBase64(file)
|
const dataUrl = await fileToBase64(file)
|
||||||
|
|
||||||
const payload = {
|
const payload = normalizeRelationIds({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type || 'application/octet-stream',
|
mimeType: file.type || 'application/octet-stream',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: dataUrl,
|
path: dataUrl,
|
||||||
...context
|
...context
|
||||||
}
|
})
|
||||||
|
|
||||||
const result = await post('/documents', payload)
|
const result = await post('/documents', payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -140,6 +162,7 @@ export function useDocuments () {
|
|||||||
loadDocumentsByMachine,
|
loadDocumentsByMachine,
|
||||||
loadDocumentsByComponent,
|
loadDocumentsByComponent,
|
||||||
loadDocumentsByPiece,
|
loadDocumentsByPiece,
|
||||||
|
loadDocumentsByProduct,
|
||||||
uploadDocuments,
|
uploadDocuments,
|
||||||
deleteDocument
|
deleteDocument
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,80 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const machineTypes = ref([])
|
const machineTypes = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const normalizeRequirementList = (value, relationKey) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return value.map((entry, index) => {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMachineType = (type) => {
|
||||||
|
if (!type || typeof type !== 'object') {
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...type,
|
||||||
|
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
|
||||||
|
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
|
||||||
|
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
export function useMachineTypesApi () {
|
export function useMachineTypesApi () {
|
||||||
const { showSuccess, showError, showInfo } = useToast()
|
const { showSuccess, showError, showInfo } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, put, delete: del } = useApi()
|
||||||
|
|
||||||
const loadMachineTypes = async () => {
|
const loadMachineTypes = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get('/types/machines')
|
const result = await get('/type_machines')
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
machineTypes.value = result.data
|
const items = extractCollection(result.data)
|
||||||
|
machineTypes.value = items.map(normalizeMachineType)
|
||||||
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -27,9 +87,9 @@ export function useMachineTypesApi () {
|
|||||||
const createMachineType = async (typeData) => {
|
const createMachineType = async (typeData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/types/machines', typeData)
|
const result = await post('/type_machines', typeData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
machineTypes.value.push(result.data)
|
machineTypes.value.push(normalizeMachineType(result.data))
|
||||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -44,11 +104,12 @@ export function useMachineTypesApi () {
|
|||||||
const updateMachineType = async (id, typeData) => {
|
const updateMachineType = async (id, typeData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/types/machines/${id}`, typeData)
|
const result = await put(`/type_machines/${id}`, typeData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const normalized = normalizeMachineType(result.data)
|
||||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
machineTypes.value[index] = result.data
|
machineTypes.value[index] = normalized
|
||||||
}
|
}
|
||||||
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
|
||||||
}
|
}
|
||||||
@@ -64,7 +125,7 @@ export function useMachineTypesApi () {
|
|||||||
const deleteMachineType = async (id) => {
|
const deleteMachineType = async (id) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await del(`/types/machines/${id}`)
|
const result = await del(`/type_machines/${id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const deletedType = machineTypes.value.find(type => type.id === id)
|
const deletedType = machineTypes.value.find(type => type.id === id)
|
||||||
machineTypes.value = machineTypes.value.filter(type => type.id !== id)
|
machineTypes.value = machineTypes.value.filter(type => type.id !== id)
|
||||||
@@ -79,19 +140,28 @@ export function useMachineTypesApi () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMachineTypeById = async (id) => {
|
const getMachineTypeById = async (id, forceRefresh = false) => {
|
||||||
// D'abord chercher dans le cache local
|
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
||||||
const localType = machineTypes.value.find(type => type.id === id)
|
if (!forceRefresh) {
|
||||||
if (localType) {
|
const localType = machineTypes.value.find(type => type.id === id)
|
||||||
return { success: true, data: localType }
|
if (localType) {
|
||||||
|
return { success: true, data: localType }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si pas trouvé localement, récupérer depuis l'API
|
// Récupérer depuis l'API
|
||||||
try {
|
try {
|
||||||
const result = await get(`/types/machines/${id}`)
|
const result = await get(`/type_machines/${id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Ajouter au cache local
|
const normalized = normalizeMachineType(result.data)
|
||||||
machineTypes.value.push(result.data)
|
// Mettre à jour le cache local
|
||||||
|
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
machineTypes.value[index] = normalized
|
||||||
|
} else {
|
||||||
|
machineTypes.value.push(normalized)
|
||||||
|
}
|
||||||
|
return { success: true, data: normalized }
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
|
||||||
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const machines = ref([])
|
const machines = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -31,6 +33,20 @@ const normalizeMachineResponse = (payload) => {
|
|||||||
|
|
||||||
const normalized = { ...container }
|
const normalized = { ...container }
|
||||||
|
|
||||||
|
if (!normalized.siteId) {
|
||||||
|
const siteId = extractRelationId(container.site)
|
||||||
|
if (siteId) {
|
||||||
|
normalized.siteId = siteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.typeMachineId) {
|
||||||
|
const typeMachineId = extractRelationId(container.typeMachine)
|
||||||
|
if (typeMachineId) {
|
||||||
|
normalized.typeMachineId = typeMachineId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ??
|
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ??
|
||||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||||
[]
|
[]
|
||||||
@@ -55,11 +71,15 @@ export function useMachines () {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const machineList = Array.isArray(result.data)
|
const machineList = Array.isArray(result.data)
|
||||||
? result.data
|
? result.data
|
||||||
: Array.isArray(result.data?.machines)
|
: Array.isArray(result.data?.member)
|
||||||
? result.data.machines
|
? result.data.member
|
||||||
: Array.isArray(result.data?.data)
|
: Array.isArray(result.data?.['hydra:member'])
|
||||||
? result.data.data
|
? result.data['hydra:member']
|
||||||
: []
|
: Array.isArray(result.data?.machines)
|
||||||
|
? result.data.machines
|
||||||
|
: Array.isArray(result.data?.data)
|
||||||
|
? result.data.data
|
||||||
|
: []
|
||||||
const normalized = machineList
|
const normalized = machineList
|
||||||
.map((item) => normalizeMachineResponse(item))
|
.map((item) => normalizeMachineResponse(item))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -76,7 +96,8 @@ export function useMachines () {
|
|||||||
const createMachine = async (machineData) => {
|
const createMachine = async (machineData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/machines', machineData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||||
|
const result = await post('/machines', normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||||
normalizeMachineResponse(result.data?.machine) ||
|
normalizeMachineResponse(result.data?.machine) ||
|
||||||
@@ -111,7 +132,8 @@ export function useMachines () {
|
|||||||
const updateMachineData = async (id, machineData) => {
|
const updateMachineData = async (id, machineData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/machines/${id}`, machineData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||||
|
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||||
normalizeMachineResponse(result.data?.machine) ||
|
normalizeMachineResponse(result.data?.machine) ||
|
||||||
|
|||||||
53
app/composables/usePersistedSort.ts
Normal file
53
app/composables/usePersistedSort.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useCookie } from '#imports'
|
||||||
|
|
||||||
|
type SortCookie = {
|
||||||
|
field?: string
|
||||||
|
direction?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const readSortCookie = (value: unknown): SortCookie | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value as SortCookie
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as SortCookie
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePersistedSort = <
|
||||||
|
TField extends string,
|
||||||
|
TDirection extends string,
|
||||||
|
>(
|
||||||
|
key: string,
|
||||||
|
defaults: { field: TField; direction: TDirection },
|
||||||
|
) => {
|
||||||
|
const cookie = useCookie<string | null>(`sort:${key}`, {
|
||||||
|
sameSite: 'lax',
|
||||||
|
})
|
||||||
|
const stored = readSortCookie(cookie.value)
|
||||||
|
const sortField = ref<TField>((stored?.field as TField) || defaults.field)
|
||||||
|
const sortDirection = ref<TDirection>(
|
||||||
|
(stored?.direction as TDirection) || defaults.direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch([sortField, sortDirection], () => {
|
||||||
|
cookie.value = JSON.stringify({
|
||||||
|
field: sortField.value,
|
||||||
|
direction: sortDirection.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/composables/usePersistedValue.ts
Normal file
34
app/composables/usePersistedValue.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useCookie } from '#imports'
|
||||||
|
|
||||||
|
const parseValue = <T>(value: unknown, fallback: T): T => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T
|
||||||
|
} catch {
|
||||||
|
return value as unknown as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePersistedValue = <T>(key: string, fallback: T) => {
|
||||||
|
const cookie = useCookie<string | null>(`pref:${key}`, {
|
||||||
|
sameSite: 'lax',
|
||||||
|
})
|
||||||
|
const initial = parseValue(cookie.value, fallback)
|
||||||
|
const state = ref<T>(initial)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
state,
|
||||||
|
(value) => {
|
||||||
|
cookie.value = JSON.stringify(value)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
67
app/composables/usePieceHistory.ts
Normal file
67
app/composables/usePieceHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
|
export type PieceHistoryActor = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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,24 +1,142 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import { useConstructeurs } from './useConstructeurs'
|
||||||
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const pieces = ref([])
|
const pieces = ref([])
|
||||||
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTotal = (payload, fallbackLength) => {
|
||||||
|
if (typeof payload?.totalItems === 'number') {
|
||||||
|
return payload.totalItems
|
||||||
|
}
|
||||||
|
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||||
|
return payload['hydra:totalItems']
|
||||||
|
}
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
export function usePieces () {
|
export function usePieces () {
|
||||||
const { showSuccess, showError, showInfo } = useToast()
|
const { showSuccess, showError, showInfo } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
|
||||||
const loadPieces = async () => {
|
const withResolvedConstructeurs = async (piece) => {
|
||||||
|
if (!piece || typeof piece !== 'object') {
|
||||||
|
return piece
|
||||||
|
}
|
||||||
|
if (!piece.typePieceId) {
|
||||||
|
const typePieceId = extractRelationId(piece.typePiece)
|
||||||
|
if (typePieceId) {
|
||||||
|
piece.typePieceId = typePieceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!piece.productId) {
|
||||||
|
const productId = extractRelationId(piece.product)
|
||||||
|
if (productId) {
|
||||||
|
piece.productId = productId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
|
||||||
|
if (productIds.length === 0 && piece.productId) {
|
||||||
|
piece.productIds = [piece.productId]
|
||||||
|
} else if (productIds.length > 0) {
|
||||||
|
piece.productIds = productIds.map((id) => String(id))
|
||||||
|
if (!piece.productId) {
|
||||||
|
piece.productId = piece.productIds[0] || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = uniqueConstructeurIds(
|
||||||
|
piece.constructeurIds,
|
||||||
|
piece.constructeurs,
|
||||||
|
piece.constructeur,
|
||||||
|
)
|
||||||
|
const hasResolvedConstructeurs =
|
||||||
|
Array.isArray(piece.constructeurs)
|
||||||
|
&& piece.constructeurs.length > 0
|
||||||
|
&& piece.constructeurs.every((item) => item && typeof item === 'object')
|
||||||
|
|
||||||
|
if (ids.length && !hasResolvedConstructeurs) {
|
||||||
|
const resolved = await ensureConstructeurs(ids)
|
||||||
|
if (resolved.length) {
|
||||||
|
piece.constructeurs = resolved
|
||||||
|
piece.constructeurIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return piece
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load pieces with pagination and search support
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {string} [options.search] - Search term for name/reference
|
||||||
|
* @param {number} [options.page=1] - Current page (1-based)
|
||||||
|
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||||
|
* @param {string} [options.orderBy='name'] - Field to order by
|
||||||
|
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||||
|
*/
|
||||||
|
const loadPieces = async (options = {}) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get('/pieces')
|
const {
|
||||||
if (result.success) {
|
search = '',
|
||||||
pieces.value = result.data
|
page = 1,
|
||||||
showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`)
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
|
params.set('page', String(page))
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
// API Platform uses property filters
|
||||||
|
params.set('name', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API Platform OrderFilter syntax: order[field]=direction
|
||||||
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection(result.data)
|
||||||
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
|
pieces.value = enrichedItems
|
||||||
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: enrichedItems,
|
||||||
|
total: total.value,
|
||||||
|
page,
|
||||||
|
itemsPerPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des pièces:', error)
|
console.error('Erreur lors du chargement des pièces:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -27,9 +145,12 @@ export function usePieces () {
|
|||||||
const createPiece = async (pieceData) => {
|
const createPiece = async (pieceData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/pieces', pieceData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||||
|
const result = await post('/pieces', normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
pieces.value.push(result.data)
|
const enriched = await withResolvedConstructeurs(result.data)
|
||||||
|
pieces.value.unshift(enriched)
|
||||||
|
total.value += 1
|
||||||
const displayName = result.data?.name
|
const displayName = result.data?.name
|
||||||
|| pieceData?.definition?.name
|
|| pieceData?.definition?.name
|
||||||
|| pieceData?.name
|
|| pieceData?.name
|
||||||
@@ -48,9 +169,10 @@ export function usePieces () {
|
|||||||
const updatePieceData = async (id, pieceData) => {
|
const updatePieceData = async (id, pieceData) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/pieces/${id}`, pieceData)
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||||
|
const result = await patch(`/pieces/${id}`, normalizedPayload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const updated = result.data
|
const updated = await withResolvedConstructeurs(result.data)
|
||||||
const index = pieces.value.findIndex(piece => piece.id === id)
|
const index = pieces.value.findIndex(piece => piece.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
pieces.value[index] = updated
|
pieces.value[index] = updated
|
||||||
@@ -73,6 +195,7 @@ export function usePieces () {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const deletedPiece = pieces.value.find(piece => piece.id === id)
|
const deletedPiece = pieces.value.find(piece => piece.id === id)
|
||||||
pieces.value = pieces.value.filter(piece => piece.id !== id)
|
pieces.value = pieces.value.filter(piece => piece.id !== id)
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -89,6 +212,7 @@ export function usePieces () {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pieces,
|
pieces,
|
||||||
|
total,
|
||||||
loading,
|
loading,
|
||||||
loadPieces,
|
loadPieces,
|
||||||
createPiece,
|
createPiece,
|
||||||
|
|||||||
67
app/composables/useProductHistory.ts
Normal file
67
app/composables/useProductHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
|
export type ProductHistoryActor = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
132
app/composables/useProductTypes.js
Normal file
132
app/composables/useProductTypes.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
|
const productTypes = ref([])
|
||||||
|
const loadingProductTypes = ref(false)
|
||||||
|
|
||||||
|
export function useProductTypes () {
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const generateCodeFromName = (name) => {
|
||||||
|
return (name || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036F]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-') || 'type'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProductTypes = async () => {
|
||||||
|
loadingProductTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await listModelTypes({
|
||||||
|
category: 'PRODUCT',
|
||||||
|
sort: 'name',
|
||||||
|
dir: 'asc',
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
productTypes.value = data.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
description: item.description ?? item.notes ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { success: true, data: productTypes.value }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || 'Erreur inconnue'
|
||||||
|
showError(`Impossible de charger les types de produit: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loadingProductTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProductType = async (payload) => {
|
||||||
|
loadingProductTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await createModelType({
|
||||||
|
name: payload.name,
|
||||||
|
code: payload.code || generateCodeFromName(payload.name),
|
||||||
|
category: 'PRODUCT',
|
||||||
|
notes: payload.description ?? payload.notes,
|
||||||
|
description: payload.description ?? null,
|
||||||
|
structure: payload.structure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
productTypes.value.push(normalized)
|
||||||
|
showSuccess(`Type de produit "${data.name}" créé`)
|
||||||
|
|
||||||
|
return { success: true, data: normalized }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
|
showError(`Erreur lors de la création du type de produit: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loadingProductTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProductType = async (id, payload) => {
|
||||||
|
loadingProductTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await updateModelType(id, {
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
notes: payload.notes,
|
||||||
|
code: payload.code,
|
||||||
|
structure: payload.structure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = productTypes.value.findIndex(type => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
productTypes.value[index] = normalized
|
||||||
|
}
|
||||||
|
showSuccess(`Type de produit "${data.name}" mis à jour`)
|
||||||
|
|
||||||
|
return { success: true, data: normalized }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
|
showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loadingProductTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProductType = async (id) => {
|
||||||
|
loadingProductTypes.value = true
|
||||||
|
try {
|
||||||
|
await deleteModelType(id)
|
||||||
|
productTypes.value = productTypes.value.filter(type => type.id !== id)
|
||||||
|
showSuccess('Type de produit supprimé')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
|
showError(`Erreur lors de la suppression du type de produit: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loadingProductTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
productTypes,
|
||||||
|
loadingProductTypes,
|
||||||
|
loadProductTypes,
|
||||||
|
createProductType,
|
||||||
|
updateProductType,
|
||||||
|
deleteProductType,
|
||||||
|
}
|
||||||
|
}
|
||||||
284
app/composables/useProducts.js
Normal file
284
app/composables/useProducts.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import { useConstructeurs } from './useConstructeurs'
|
||||||
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const products = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const replaceInCache = (item) => {
|
||||||
|
if (!item?.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const index = products.value.findIndex((product) => product.id === item.id)
|
||||||
|
if (index === -1) {
|
||||||
|
products.value.unshift(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const clone = products.value.slice()
|
||||||
|
clone[index] = item
|
||||||
|
products.value = clone
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTotal = (payload, fallbackLength) => {
|
||||||
|
if (typeof payload?.totalItems === 'number') {
|
||||||
|
return payload.totalItems
|
||||||
|
}
|
||||||
|
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||||
|
return payload['hydra:totalItems']
|
||||||
|
}
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProducts () {
|
||||||
|
const { showError } = useToast()
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
|
||||||
|
const withResolvedConstructeurs = async (product) => {
|
||||||
|
if (!product || typeof product !== 'object') {
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
if (!product.typeProductId) {
|
||||||
|
const typeProductId = extractRelationId(product.typeProduct)
|
||||||
|
if (typeProductId) {
|
||||||
|
product.typeProductId = typeProductId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = uniqueConstructeurIds(
|
||||||
|
product.constructeurIds,
|
||||||
|
product.constructeurs,
|
||||||
|
product.constructeur,
|
||||||
|
)
|
||||||
|
const hasResolvedConstructeurs =
|
||||||
|
Array.isArray(product.constructeurs)
|
||||||
|
&& product.constructeurs.length > 0
|
||||||
|
&& product.constructeurs.every((item) => item && typeof item === 'object')
|
||||||
|
|
||||||
|
if (ids.length && !hasResolvedConstructeurs) {
|
||||||
|
const resolved = await ensureConstructeurs(ids)
|
||||||
|
if (resolved.length) {
|
||||||
|
product.constructeurs = resolved
|
||||||
|
product.constructeurIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load products with pagination and search support
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {string} [options.search] - Search term for name/reference
|
||||||
|
* @param {number} [options.page=1] - Current page (1-based)
|
||||||
|
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||||
|
* @param {string} [options.orderBy='name'] - Field to order by
|
||||||
|
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||||
|
* @param {boolean} [options.force=false] - Force reload even if already loaded
|
||||||
|
*/
|
||||||
|
const loadProducts = async (options = {}) => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
|
params.set('page', String(page))
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
params.set('name', search.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
|
const result = await get(`/products?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection(result.data)
|
||||||
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
|
products.value = enrichedItems
|
||||||
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: enrichedItems,
|
||||||
|
total: total.value,
|
||||||
|
page,
|
||||||
|
itemsPerPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
error.value = result.error
|
||||||
|
showError(`Impossible de charger les produits: ${result.error}`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors du chargement des produits:', err)
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
error.value = message
|
||||||
|
showError(`Impossible de charger les produits: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProduct = async (payload) => {
|
||||||
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await post('/products', normalizedPayload)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const enriched = await withResolvedConstructeurs(result.data)
|
||||||
|
const added = replaceInCache(enriched)
|
||||||
|
if (added) {
|
||||||
|
total.value += 1
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
error.value = result.error
|
||||||
|
showError(result.error)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors de la création du produit:', err)
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
error.value = message
|
||||||
|
showError(message)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProduct = async (id, payload) => {
|
||||||
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await patch(`/products/${id}`, normalizedPayload)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const enriched = await withResolvedConstructeurs(result.data)
|
||||||
|
replaceInCache(enriched)
|
||||||
|
} else if (result.error) {
|
||||||
|
error.value = result.error
|
||||||
|
showError(result.error)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
error.value = message
|
||||||
|
showError(message)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProduct = async (id) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await del(`/products/${id}`)
|
||||||
|
if (result.success) {
|
||||||
|
const removed = products.value.find((product) => product.id === id)
|
||||||
|
products.value = products.value.filter((product) => product.id !== id)
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
|
} else if (result.error) {
|
||||||
|
error.value = result.error
|
||||||
|
showError(result.error)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors de la suppression du produit:', err)
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
error.value = message
|
||||||
|
showError(message)
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProduct = async (id, options = {}) => {
|
||||||
|
const shouldForce = !!options.force
|
||||||
|
if (!shouldForce) {
|
||||||
|
const cached = products.value.find((product) => product.id === id)
|
||||||
|
if (cached && Array.isArray(cached.constructeurs) && cached.constructeurs.length > 0) {
|
||||||
|
return { success: true, data: cached }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await get(`/products/${id}`)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const enriched = await withResolvedConstructeurs(result.data)
|
||||||
|
replaceInCache(enriched)
|
||||||
|
return { success: true, data: enriched }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors du chargement du produit:', err)
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearProductsCache = () => {
|
||||||
|
products.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
products,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
loadProducts,
|
||||||
|
createProduct,
|
||||||
|
updateProduct,
|
||||||
|
deleteProduct,
|
||||||
|
getProduct,
|
||||||
|
clearProductsCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
|||||||
|
|
||||||
const buildUrl = (path) => {
|
const buildUrl = (path) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || ''
|
const baseUrl = process.server
|
||||||
|
? (config.apiBaseUrl || config.public.apiBaseUrl || '')
|
||||||
|
: (config.public.apiBaseUrl || '')
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
return `${base}${path}`
|
return `${base}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useProfiles () {
|
|||||||
const fetchProfiles = async () => {
|
const fetchProfiles = async () => {
|
||||||
loadingProfiles.value = true
|
loadingProfiles.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await $fetch(buildUrl('/profiles'), {
|
profiles.value = await $fetch(buildUrl('/session/profiles'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders()
|
headers: getSessionHeaders()
|
||||||
@@ -37,7 +37,7 @@ export function useProfiles () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createProfile = async ({ firstName, lastName }) => {
|
const createProfile = async ({ firstName, lastName }) => {
|
||||||
const profile = await $fetch(buildUrl('/profiles'), {
|
const profile = await $fetch(buildUrl('/session/profiles'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: { firstName, lastName },
|
body: { firstName, lastName },
|
||||||
@@ -48,7 +48,7 @@ export function useProfiles () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteProfile = async (profileId) => {
|
const deleteProfile = async (profileId) => {
|
||||||
await $fetch(buildUrl(`/profiles/${profileId}`), {
|
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders()
|
headers: getSessionHeaders()
|
||||||
|
|||||||
@@ -13,9 +13,20 @@ export function useSites () {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get('/sites')
|
const result = await get('/sites')
|
||||||
|
console.log('sites api result', result)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
sites.value = result.data
|
const collection = Array.isArray(result.data)
|
||||||
showInfo(`Chargement de ${sites.value.length} site(s) réussi`)
|
? result.data
|
||||||
|
: Array.isArray(result.data?.member)
|
||||||
|
? result.data.member
|
||||||
|
: Array.isArray(result.data?.['hydra:member'])
|
||||||
|
? result.data['hydra:member']
|
||||||
|
: Array.isArray(result.data?.data)
|
||||||
|
? result.data.data
|
||||||
|
: []
|
||||||
|
sites.value = collection
|
||||||
|
showInfo(`Chargement de ${collection.length} site(s) réussi`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des sites:', error)
|
console.error('Erreur lors du chargement des sites:', error)
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom, référence ou catégorie…"
|
placeholder="Nom ou référence…"
|
||||||
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
id="component-catalog-sort"
|
id="component-catalog-sort"
|
||||||
v-model="sortField"
|
v-model="sortField"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleSortChange"
|
||||||
>
|
>
|
||||||
<option value="name">Nom</option>
|
<option value="name">Nom</option>
|
||||||
<option value="createdAt">Date de création</option>
|
<option value="createdAt">Date de création</option>
|
||||||
@@ -64,14 +66,33 @@
|
|||||||
id="component-catalog-dir"
|
id="component-catalog-dir"
|
||||||
v-model="sortDirection"
|
v-model="sortDirection"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleSortChange"
|
||||||
>
|
>
|
||||||
<option value="asc">Ascendant</option>
|
<option value="asc">Ascendant</option>
|
||||||
<option value="desc">Descendant</option>
|
<option value="desc">Descendant</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="component-catalog-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="component-catalog-per-page"
|
||||||
|
v-model.number="itemsPerPage"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handlePerPageChange"
|
||||||
|
>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
<p class="text-xs text-base-content/50 lg:text-right">
|
||||||
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }}
|
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,54 +104,62 @@
|
|||||||
Aucun composant n'a encore été créé.
|
Aucun composant n'a encore été créé.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70">
|
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
||||||
Aucun composant ne correspond à votre recherche.
|
Aucun composant ne correspond à votre recherche.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<template v-else>
|
||||||
<table class="table table-sm md:table-md">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="table table-sm md:table-md">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="w-24">Aperçu</th>
|
<tr>
|
||||||
<th>Nom</th>
|
<th class="w-24">Aperçu</th>
|
||||||
<th>Catégorie</th>
|
<th>Nom</th>
|
||||||
<th>Référence</th>
|
<th>Référence</th>
|
||||||
<th>Actions</th>
|
<th>Type de composant</th>
|
||||||
</tr>
|
<th>Actions</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="component in visibleComposants" :key="component.id">
|
<tbody>
|
||||||
<td class="align-middle">
|
<tr v-for="component in composantsList" :key="component.id">
|
||||||
<DocumentThumbnail
|
<td class="align-middle">
|
||||||
:document="resolvePrimaryDocument(component)"
|
<DocumentThumbnail
|
||||||
:alt="resolvePreviewAlt(component)"
|
:document="resolvePrimaryDocument(component)"
|
||||||
/>
|
:alt="resolvePreviewAlt(component)"
|
||||||
</td>
|
/>
|
||||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
</td>
|
||||||
<td>{{ component.typeComposant?.name || '—' }}</td>
|
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||||
<td>{{ component.reference || '—' }}</td>
|
<td>{{ component.reference || '—' }}</td>
|
||||||
<td>
|
<td>{{ resolveComponentType(component) }}</td>
|
||||||
<div class="flex items-center gap-2">
|
<td>
|
||||||
<NuxtLink
|
<div class="flex items-center gap-2">
|
||||||
:to="`/component/${component.id}/edit`"
|
<NuxtLink
|
||||||
class="btn btn-ghost btn-xs"
|
:to="`/component/${component.id}/edit`"
|
||||||
>
|
class="btn btn-ghost btn-xs"
|
||||||
Modifier
|
>
|
||||||
</NuxtLink>
|
Modifier
|
||||||
<button
|
</NuxtLink>
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-error btn-xs"
|
type="button"
|
||||||
:disabled="loadingComposants"
|
class="btn btn-error btn-xs"
|
||||||
@click="handleDeleteComponent(component)"
|
:disabled="loadingComposants"
|
||||||
>
|
@click="handleDeleteComponent(component)"
|
||||||
Supprimer
|
>
|
||||||
</button>
|
Supprimer
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -140,19 +169,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||||
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
const composantsList = computed(() => composants.value || [])
|
|
||||||
const composantsTotal = computed(() => composantsList.value.length)
|
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(30)
|
||||||
|
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('')
|
const searchTerm = ref('')
|
||||||
const sortField = ref<'name' | 'createdAt'>('name')
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
||||||
|
const debouncedSearch = () => {
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchComposants()
|
||||||
|
}, 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) => {
|
||||||
|
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||||
|
return {
|
||||||
|
...composant,
|
||||||
|
typeComposant: typeComposant || composant.typeComposant || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchComposants = async () => {
|
||||||
|
await loadComposants({
|
||||||
|
search: searchTerm.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
orderBy: sortField.value,
|
||||||
|
orderDir: sortDirection.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchComposants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchComposants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchComposants()
|
||||||
|
}
|
||||||
|
|
||||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||||
@@ -180,6 +270,17 @@ const resolvePreviewAlt = (component: Record<string, any>) => {
|
|||||||
return 'Aperçu du document'
|
return 'Aperçu du document'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveComponentType = (component: Record<string, any>) => {
|
||||||
|
const type = component?.typeComposant
|
||||||
|
if (type?.name) {
|
||||||
|
return type.name
|
||||||
|
}
|
||||||
|
if (component?.typeComposantLabel) {
|
||||||
|
return component.typeComposantLabel
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||||
const blockingReasons: string[] = []
|
const blockingReasons: string[] = []
|
||||||
const machineLinks = Array.isArray(component?.machineLinks)
|
const machineLinks = Array.isArray(component?.machineLinks)
|
||||||
@@ -204,60 +305,6 @@ const resolveDeleteGuard = (component: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveComparableName = (component: Record<string, any>) => {
|
|
||||||
const toComparable = (value?: string | null) =>
|
|
||||||
(value ?? '').toString().trim().toLowerCase()
|
|
||||||
|
|
||||||
return (
|
|
||||||
toComparable(component?.name) ||
|
|
||||||
toComparable(component?.reference) ||
|
|
||||||
toComparable(component?.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveComparableDate = (component: Record<string, any>) => {
|
|
||||||
const raw = component?.createdAt ?? component?.created_at ?? null
|
|
||||||
if (!raw) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
const parsed = new Date(raw).getTime()
|
|
||||||
return Number.isNaN(parsed) ? 0 : parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleComposants = computed(() => {
|
|
||||||
const term = searchTerm.value.trim().toLowerCase()
|
|
||||||
const source = composantsList.value || []
|
|
||||||
|
|
||||||
const filtered = term
|
|
||||||
? source.filter((component) => {
|
|
||||||
const name = (component?.name || '').toLowerCase()
|
|
||||||
const reference = (component?.reference || '').toLowerCase()
|
|
||||||
const category = (component?.typeComposant?.name || '').toLowerCase()
|
|
||||||
return (
|
|
||||||
name.includes(term) ||
|
|
||||||
reference.includes(term) ||
|
|
||||||
category.includes(term)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: [...source]
|
|
||||||
|
|
||||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
if (sortField.value === 'name') {
|
|
||||||
return (
|
|
||||||
resolveComparableName(a).localeCompare(
|
|
||||||
resolveComparableName(b),
|
|
||||||
'fr',
|
|
||||||
{ sensitivity: 'base' }
|
|
||||||
) * direction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||||
|
|
||||||
@@ -286,9 +333,14 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await deleteComposant(component.id)
|
await deleteComposant(component.id)
|
||||||
|
// Reload current page after deletion
|
||||||
|
fetchComposants()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadComposants()
|
await Promise.all([
|
||||||
|
fetchComposants(),
|
||||||
|
loadComponentTypes()
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSubmitBlocked,
|
||||||
|
submitBlockMessage,
|
||||||
|
loadLinkedCount,
|
||||||
|
guardSubmitOrNotify,
|
||||||
|
} = useCategoryEditGuard({
|
||||||
|
endpoint: '/composants',
|
||||||
|
filterKey: 'typeComposant',
|
||||||
|
labels: {
|
||||||
|
singular: 'composant',
|
||||||
|
plural: 'composants',
|
||||||
|
verifying: 'Vérification des composants liés en cours…',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const title = computed(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name
|
initialData.value?.name
|
||||||
? `Modifier « ${initialData.value.name} »`
|
? `Modifier « ${initialData.value.name} »`
|
||||||
@@ -88,6 +106,8 @@ const loadCategory = async () => {
|
|||||||
notes: response.notes ?? response.description ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -101,6 +121,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -89,19 +89,20 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
placeholder="Référence interne ou constructeur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Constructeur</span>
|
<span class="label-text">Fournisseur</span>
|
||||||
</label>
|
</label>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurId"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
placeholder="Rechercher un constructeur..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="component?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +176,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
|
||||||
|
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||||
|
>
|
||||||
|
{{ resolveProductLabel(product) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
@@ -188,7 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
||||||
class="text-xs text-gray-500"
|
class="text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||||
@@ -197,6 +210,50 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="structureSelections.hasAny"
|
||||||
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div v-if="structureSelections.pieces.length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
|
||||||
|
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||||
|
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="structureSelections.products.length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
|
||||||
|
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||||
|
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="structureSelections.components.length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
|
||||||
|
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||||
|
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<header class="space-y-1">
|
<header class="space-y-1">
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
@@ -377,6 +434,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Qui a changé quoi, et quand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||||
|
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||||
|
Chargement de l’historique…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="historyError" class="alert alert-warning">
|
||||||
|
<span>{{ historyError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||||
|
Aucun changement enregistré pour le moment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
<li
|
||||||
|
v-for="entry in historyEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||||
|
<span class="font-medium text-base-content">
|
||||||
|
{{ historyActionLabel(entry.action) }}
|
||||||
|
</span>
|
||||||
|
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="historyDiffEntries(entry).length"
|
||||||
|
class="mt-2 space-y-1 text-xs"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="diffEntry in historyDiffEntries(entry)"
|
||||||
|
:key="`${entry.id}-${diffEntry.field}`"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||||
|
<span class="text-base-content/60">
|
||||||
|
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="entry.snapshot?.name"
|
||||||
|
class="mt-2 text-xs text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ entry.snapshot.name }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -399,11 +524,19 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
|
|||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
@@ -423,16 +556,28 @@ interface CustomFieldInput {
|
|||||||
value: string
|
value: string
|
||||||
customFieldId: string | null
|
customFieldId: string | null
|
||||||
customFieldValueId: string | null
|
customFieldValueId: string | null
|
||||||
|
orderIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const { updateComposant } = useComposants()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||||
|
const { pieces, loadPieces } = usePieces()
|
||||||
|
const { products, loadProducts } = useProducts()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useComponentHistory()
|
||||||
|
|
||||||
const component = ref<any | null>(null)
|
const component = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -444,11 +589,92 @@ const componentDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
|
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
||||||
|
|
||||||
|
const historyFieldLabels: Record<string, string> = {
|
||||||
|
name: 'Nom',
|
||||||
|
reference: 'Référence',
|
||||||
|
prix: 'Prix',
|
||||||
|
structure: 'Structure',
|
||||||
|
typeComposant: 'Catégorie',
|
||||||
|
product: 'Produit lié',
|
||||||
|
constructeurIds: 'Fournisseurs',
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyActionLabel = (action: string) => {
|
||||||
|
if (action === 'create') {
|
||||||
|
return 'Création'
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
return 'Suppression'
|
||||||
|
}
|
||||||
|
return 'Modification'
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatHistoryDate = (value: string) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return historyDateFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatHistoryValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const maybeRecord = value as Record<string, unknown>
|
||||||
|
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||||
|
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||||
|
if (name && id) {
|
||||||
|
return `${name} (#${id})`
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
return `#${id}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
|
||||||
|
const diff = entry.diff ?? {}
|
||||||
|
return Object.entries(diff).map(([field, change]) => {
|
||||||
|
const label = historyFieldLabels[field] ?? field
|
||||||
|
const fromLabel = formatHistoryValue(change?.from)
|
||||||
|
const toLabel = formatHistoryValue(change?.to)
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
fromLabel,
|
||||||
|
toLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurId: null as string | null,
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -495,6 +721,46 @@ const documentPreviewSrc = (document: any) => {
|
|||||||
}
|
}
|
||||||
return document.path
|
return document.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||||
|
const pieceTypeLabelMap = computed(() => ({
|
||||||
|
...Object.fromEntries(
|
||||||
|
(pieceTypes.value || [])
|
||||||
|
.filter((type: any) => type?.id)
|
||||||
|
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||||
|
),
|
||||||
|
...fetchedPieceTypeMap.value,
|
||||||
|
}))
|
||||||
|
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||||
|
const productTypeLabelMap = computed(() => ({
|
||||||
|
...Object.fromEntries(
|
||||||
|
(productTypes.value || [])
|
||||||
|
.filter((type: any) => type?.id)
|
||||||
|
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||||
|
),
|
||||||
|
...fetchedProductTypeMap.value,
|
||||||
|
}))
|
||||||
|
const pieceCatalogMap = computed(() =>
|
||||||
|
new Map(
|
||||||
|
(pieces.value || [])
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => [String(item.id), item]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const productCatalogMap = computed(() =>
|
||||||
|
new Map(
|
||||||
|
(products.value || [])
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => [String(item.id), item]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const componentCatalogMap = computed(() =>
|
||||||
|
new Map(
|
||||||
|
(componentCatalogRef.value || [])
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => [String(item.id), item]),
|
||||||
|
),
|
||||||
|
)
|
||||||
const documentThumbnailClass = (document: any) => {
|
const documentThumbnailClass = (document: any) => {
|
||||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||||
return 'h-24 w-20'
|
return 'h-24 w-20'
|
||||||
@@ -589,6 +855,15 @@ const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
|||||||
return structure ? normalizeStructureForEditor(structure) : null
|
return structure ? normalizeStructureForEditor(structure) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshCustomFieldInputs = (
|
||||||
|
structureOverride?: ComponentModelStructure | null,
|
||||||
|
valuesOverride?: any[] | null,
|
||||||
|
) => {
|
||||||
|
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||||
|
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||||
|
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||||
|
}
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
customFieldInputs.value.every((field) => {
|
customFieldInputs.value.every((field) => {
|
||||||
if (!field.required) {
|
if (!field.required) {
|
||||||
@@ -632,6 +907,13 @@ const fetchComponent = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
component.value = result.data
|
component.value = result.data
|
||||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
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)
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
@@ -647,17 +929,27 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedTypeId.value = currentComponent.typeComposantId || ''
|
const resolvedTypeId = currentComponent.typeComposantId
|
||||||
|
|| extractRelationId(currentComponent.typeComposant)
|
||||||
|
|| ''
|
||||||
|
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
||||||
|
currentComponent.typeComposantId = resolvedTypeId
|
||||||
|
}
|
||||||
|
selectedTypeId.value = resolvedTypeId
|
||||||
|
|
||||||
editionForm.name = currentComponent.name || ''
|
editionForm.name = currentComponent.name || ''
|
||||||
editionForm.reference = currentComponent.reference || ''
|
editionForm.reference = currentComponent.reference || ''
|
||||||
editionForm.constructeurId = currentComponent.constructeur?.id || currentComponent.constructeurId || null
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
currentComponent,
|
||||||
|
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
|
||||||
customFieldInputs.value = buildCustomFieldInputs(
|
currentComponent.constructeur ? [currentComponent.constructeur] : [],
|
||||||
currentStructure,
|
|
||||||
currentComponent.customFieldValues,
|
|
||||||
)
|
)
|
||||||
|
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||||
|
if (editionForm.constructeurIds.length) {
|
||||||
|
void ensureConstructeurs(editionForm.constructeurIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -668,10 +960,7 @@ watch(selectedTypeStructure, (currentStructure) => {
|
|||||||
if (!component.value) {
|
if (!component.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
customFieldInputs.value = buildCustomFieldInputs(
|
refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
|
||||||
currentStructure,
|
|
||||||
component.value.customFieldValues,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
@@ -691,12 +980,12 @@ const submitEdition = async () => {
|
|||||||
|
|
||||||
const reference = editionForm.reference.trim()
|
const reference = editionForm.reference.trim()
|
||||||
payload.reference = reference ? reference : null
|
payload.reference = reference ? reference : null
|
||||||
payload.constructeurId = editionForm.constructeurId || null
|
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||||
|
|
||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
payload.prix = parsed
|
payload.prix = String(parsed)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
payload.prix = null
|
payload.prix = null
|
||||||
@@ -751,6 +1040,7 @@ const buildCustomFieldInputs = (
|
|||||||
...definition,
|
...definition,
|
||||||
customFieldId: definition.customFieldId || definition.id,
|
customFieldId: definition.customFieldId || definition.id,
|
||||||
customFieldValueId: null,
|
customFieldValueId: null,
|
||||||
|
orderIndex: definition.orderIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,8 +1050,14 @@ const buildCustomFieldInputs = (
|
|||||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||||
customFieldValueId: matched.id ?? null,
|
customFieldValueId: matched.id ?? null,
|
||||||
value: formatDefaultValue(definition.type, resolvedValue),
|
value: formatDefaultValue(definition.type, resolvedValue),
|
||||||
|
orderIndex: Math.min(
|
||||||
|
definition.orderIndex ?? 0,
|
||||||
|
typeof matched.customField?.orderIndex === 'number'
|
||||||
|
? matched.customField.orderIndex
|
||||||
|
: definition.orderIndex ?? 0,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
})
|
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -775,11 +1071,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
|
|||||||
}
|
}
|
||||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||||
return fields
|
return fields
|
||||||
.map((field) => normalizeCustomField(field))
|
.map((field, index) => normalizeCustomField(field, index))
|
||||||
.filter((field): field is CustomFieldInput => field !== null)
|
.filter((field): field is CustomFieldInput => field !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||||
if (!rawField || typeof rawField !== 'object') {
|
if (!rawField || typeof rawField !== 'object') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -797,7 +1094,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
|||||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||||
? rawField.customFieldValueId
|
? rawField.customFieldValueId
|
||||||
: null
|
: null
|
||||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId }
|
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||||
|
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveFieldName = (field: any): string => {
|
const resolveFieldName = (field: any): string => {
|
||||||
@@ -970,6 +1268,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
|||||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||||
|
return Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
}
|
||||||
|
|
||||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||||
if (Array.isArray(structure?.subcomponents)) {
|
if (Array.isArray(structure?.subcomponents)) {
|
||||||
return structure.subcomponents
|
return structure.subcomponents
|
||||||
@@ -978,6 +1280,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
|
|||||||
return Array.isArray(legacy) ? legacy : []
|
return Array.isArray(legacy) ? legacy : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNonEmptyString = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0
|
||||||
|
|
||||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (piece.role) {
|
if (piece.role) {
|
||||||
@@ -987,6 +1292,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
|||||||
parts.push(piece.typePiece.name)
|
parts.push(piece.typePiece.name)
|
||||||
} else if (piece.typePieceLabel) {
|
} else if (piece.typePieceLabel) {
|
||||||
parts.push(piece.typePieceLabel)
|
parts.push(piece.typePieceLabel)
|
||||||
|
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||||
|
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
|
||||||
} else if (piece.typePiece?.code) {
|
} else if (piece.typePiece?.code) {
|
||||||
parts.push(`Famille ${piece.typePiece.code}`)
|
parts.push(`Famille ${piece.typePiece.code}`)
|
||||||
} else if (piece.familyCode) {
|
} else if (piece.familyCode) {
|
||||||
@@ -997,6 +1304,91 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||||
|
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||||
|
if (!missing.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
missing.map((id) => get(`/model_types/${id}`)),
|
||||||
|
)
|
||||||
|
const next = { ...fetchedPieceTypeMap.value }
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = result.value?.data
|
||||||
|
const name = data?.name || data?.code
|
||||||
|
if (name) {
|
||||||
|
next[missing[index]] = name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fetchedPieceTypeMap.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveProductLabel = (product: Record<string, any>) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (product.role) {
|
||||||
|
parts.push(product.role)
|
||||||
|
}
|
||||||
|
if (product.typeProduct?.name) {
|
||||||
|
parts.push(product.typeProduct.name)
|
||||||
|
} else if (product.typeProductLabel) {
|
||||||
|
parts.push(product.typeProductLabel)
|
||||||
|
} else if (product.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
|
||||||
|
parts.push(productTypeLabelMap.value[product.typeProductId])
|
||||||
|
} else if (product.typeProduct?.code) {
|
||||||
|
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||||
|
} else if (product.familyCode) {
|
||||||
|
parts.push(`Catégorie ${product.familyCode}`)
|
||||||
|
} else if (product.typeProductId) {
|
||||||
|
parts.push(`#${product.typeProductId}`)
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : 'Produit'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProductTypeNames = async (ids: string[]) => {
|
||||||
|
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
|
||||||
|
if (!missing.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
missing.map((id) => get(`/model_types/${id}`)),
|
||||||
|
)
|
||||||
|
const next = { ...fetchedProductTypeMap.value }
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = result.value?.data
|
||||||
|
const name = data?.name || data?.code
|
||||||
|
if (name) {
|
||||||
|
next[missing[index]] = name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fetchedProductTypeMap.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedTypeStructure,
|
||||||
|
(structure) => {
|
||||||
|
const pieceIds = getStructurePieces(structure)
|
||||||
|
.map((piece: any) => piece?.typePieceId)
|
||||||
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
|
if (pieceIds.length) {
|
||||||
|
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = getStructureProducts(structure)
|
||||||
|
.map((product: any) => product?.typeProductId)
|
||||||
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
|
if (productIds.length) {
|
||||||
|
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (node.alias) {
|
if (node.alias) {
|
||||||
@@ -1023,6 +1415,104 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SelectionEntry = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
requirementLabel: string
|
||||||
|
resolvedName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectStructureSelections = (root: any): {
|
||||||
|
pieces: SelectionEntry[]
|
||||||
|
products: SelectionEntry[]
|
||||||
|
components: SelectionEntry[]
|
||||||
|
} => {
|
||||||
|
const piecesSelected: SelectionEntry[] = []
|
||||||
|
const productsSelected: SelectionEntry[] = []
|
||||||
|
const componentsSelected: SelectionEntry[] = []
|
||||||
|
|
||||||
|
if (!root || typeof root !== 'object') {
|
||||||
|
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitNode = (node: any, fallbackPath = 'racine') => {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
|
||||||
|
|
||||||
|
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
|
||||||
|
nodePieces.forEach((entry: any, index: number) => {
|
||||||
|
const selectedId = entry?.selectedPieceId
|
||||||
|
if (!isNonEmptyString(selectedId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const definition = entry?.definition ?? entry
|
||||||
|
const catalogPiece = pieceCatalogMap.value.get(selectedId)
|
||||||
|
piecesSelected.push({
|
||||||
|
id: selectedId,
|
||||||
|
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
|
||||||
|
requirementLabel: resolvePieceLabel(definition),
|
||||||
|
resolvedName: catalogPiece?.name || selectedId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeProducts = Array.isArray(node.products) ? node.products : []
|
||||||
|
nodeProducts.forEach((entry: any, index: number) => {
|
||||||
|
const selectedId = entry?.selectedProductId
|
||||||
|
if (!isNonEmptyString(selectedId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const definition = entry?.definition ?? entry
|
||||||
|
const catalogProduct = productCatalogMap.value.get(selectedId)
|
||||||
|
productsSelected.push({
|
||||||
|
id: selectedId,
|
||||||
|
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
|
||||||
|
requirementLabel: resolveProductLabel(definition),
|
||||||
|
resolvedName: catalogProduct?.name || selectedId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeChildren = Array.isArray(node.subcomponents)
|
||||||
|
? node.subcomponents
|
||||||
|
: Array.isArray(node.subComponents)
|
||||||
|
? node.subComponents
|
||||||
|
: []
|
||||||
|
|
||||||
|
nodeChildren.forEach((child: any, index: number) => {
|
||||||
|
const selectedId = child?.selectedComponentId
|
||||||
|
if (isNonEmptyString(selectedId)) {
|
||||||
|
const definition = child?.definition ?? child
|
||||||
|
const catalogComponent = componentCatalogMap.value.get(selectedId)
|
||||||
|
componentsSelected.push({
|
||||||
|
id: selectedId,
|
||||||
|
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
|
||||||
|
requirementLabel: resolveSubcomponentLabel(definition),
|
||||||
|
resolvedName: catalogComponent?.name || selectedId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
|
||||||
|
|
||||||
|
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureSelections = computed(() => {
|
||||||
|
const selections = collectStructureSelections(component.value?.structure)
|
||||||
|
const total =
|
||||||
|
selections.pieces.length + selections.products.length + selections.components.length
|
||||||
|
return {
|
||||||
|
...selections,
|
||||||
|
total,
|
||||||
|
hasAny: total > 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||||
customFieldName: field.name,
|
customFieldName: field.name,
|
||||||
customFieldType: field.type,
|
customFieldType: field.type,
|
||||||
@@ -1122,7 +1612,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadComponentTypes(), fetchComponent()])
|
await Promise.allSettled([
|
||||||
|
loadComponentTypes(),
|
||||||
|
loadPieceTypes(),
|
||||||
|
loadProductTypes(),
|
||||||
|
loadPieces({ itemsPerPage: 500 }),
|
||||||
|
loadProducts({ itemsPerPage: 500, force: true }),
|
||||||
|
loadComposants({ itemsPerPage: 500 }),
|
||||||
|
fetchComponent(),
|
||||||
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (component.value?.id) {
|
if (component.value?.id) {
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
|
|||||||
@@ -62,19 +62,19 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="submitting || !selectedType"
|
||||||
placeholder="Référence interne ou constructeur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Constructeur</span>
|
<span class="label-text">Fournisseur</span>
|
||||||
</label>
|
</label>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurId"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="submitting || !selectedType"
|
||||||
placeholder="Rechercher un constructeur..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +148,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
|
||||||
|
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||||
|
>
|
||||||
|
{{ resolveProductLabel(product) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
@@ -189,15 +201,20 @@
|
|||||||
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
||||||
>
|
>
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
Chargement du catalogue de pièces et de composants…
|
Chargement du catalogue de pièces, produits et composants…
|
||||||
</div>
|
</div>
|
||||||
<ComponentStructureAssignmentNode
|
<ComponentStructureAssignmentNode
|
||||||
v-else-if="structureAssignments"
|
v-else-if="structureAssignments"
|
||||||
:assignment="structureAssignments"
|
:assignment="structureAssignments"
|
||||||
:pieces="availablePieces"
|
:pieces="availablePieces"
|
||||||
|
:products="availableProducts"
|
||||||
:components="availableComponents"
|
:components="availableComponents"
|
||||||
:pieces-loading="piecesLoading"
|
:pieces-loading="piecesLoading"
|
||||||
|
:products-loading="productsLoading"
|
||||||
:components-loading="componentsLoading"
|
:components-loading="componentsLoading"
|
||||||
|
:piece-type-label-map="pieceTypeLabelMap"
|
||||||
|
:product-type-label-map="productTypeLabelMap"
|
||||||
|
:component-type-label-map="componentTypeLabelMap"
|
||||||
/>
|
/>
|
||||||
<p v-else class="text-xs text-error">
|
<p v-else class="text-xs text-error">
|
||||||
Impossible de générer les emplacements définis par le squelette.
|
Impossible de générer les emplacements définis par le squelette.
|
||||||
@@ -335,12 +352,18 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
|||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
|
ComponentModelProduct,
|
||||||
ComponentModelStructure,
|
ComponentModelStructure,
|
||||||
ComponentModelStructureNode,
|
ComponentModelStructureNode,
|
||||||
} from '~/shared/types/inventory'
|
} from '~/shared/types/inventory'
|
||||||
@@ -353,19 +376,24 @@ interface ComponentCatalogType extends ModelType {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||||
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const {
|
const {
|
||||||
createComposant,
|
createComposant,
|
||||||
composants: componentCatalogRef,
|
composants: componentCatalogRef,
|
||||||
loadComposants,
|
|
||||||
loading: componentsLoading,
|
loading: componentsLoading,
|
||||||
} = useComposants()
|
} = useComposants()
|
||||||
const {
|
const {
|
||||||
pieces: pieceCatalogRef,
|
pieces: pieceCatalogRef,
|
||||||
loadPieces,
|
|
||||||
loading: piecesLoading,
|
loading: piecesLoading,
|
||||||
} = usePieces()
|
} = usePieces()
|
||||||
|
const {
|
||||||
|
products: productCatalogRef,
|
||||||
|
loading: productsLoading,
|
||||||
|
} = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
@@ -376,7 +404,7 @@ const submitting = ref(false)
|
|||||||
const creationForm = reactive({
|
const creationForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurId: null as string | null,
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
})
|
})
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
@@ -386,9 +414,34 @@ const selectedDocuments = ref<File[]>([])
|
|||||||
const uploadingDocuments = ref(false)
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||||
|
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||||
const structureDataLoading = computed(
|
const structureDataLoading = computed(
|
||||||
() => piecesLoading.value || componentsLoading.value,
|
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||||
|
const pieceTypeLabelMap = computed(() => ({
|
||||||
|
...Object.fromEntries(
|
||||||
|
(pieceTypes.value || [])
|
||||||
|
.filter((type: any) => type?.id)
|
||||||
|
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||||
|
),
|
||||||
|
...fetchedPieceTypeMap.value,
|
||||||
|
}))
|
||||||
|
const productTypeLabelMap = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
(productTypes.value || [])
|
||||||
|
.filter((type: any) => type?.id)
|
||||||
|
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const componentTypeLabelMap = computed(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
(componentTypes.value || [])
|
||||||
|
.filter((type: any) => type?.id)
|
||||||
|
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -485,6 +538,21 @@ const extractPiecesFromNode = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractProductsFromNode = (
|
||||||
|
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
|
||||||
|
): ComponentModelProduct[] => {
|
||||||
|
if (!definition || typeof definition !== 'object') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const raw = Array.isArray((definition as any).products)
|
||||||
|
? (definition as any).products
|
||||||
|
: []
|
||||||
|
return raw.filter(
|
||||||
|
(item: unknown): item is ComponentModelProduct =>
|
||||||
|
!!item && typeof item === 'object',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const buildAssignmentNode = (
|
const buildAssignmentNode = (
|
||||||
definition: ComponentModelStructureNode | ComponentModelStructure,
|
definition: ComponentModelStructureNode | ComponentModelStructure,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -495,6 +563,12 @@ const buildAssignmentNode = (
|
|||||||
selectedPieceId: '',
|
selectedPieceId: '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const products = extractProductsFromNode(definition).map((product, index) => ({
|
||||||
|
path: `${path}:product-${index}`,
|
||||||
|
definition: product,
|
||||||
|
selectedProductId: '',
|
||||||
|
}))
|
||||||
|
|
||||||
const subcomponents = extractSubcomponents(definition).map(
|
const subcomponents = extractSubcomponents(definition).map(
|
||||||
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
|
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
|
||||||
)
|
)
|
||||||
@@ -504,6 +578,7 @@ const buildAssignmentNode = (
|
|||||||
definition,
|
definition,
|
||||||
selectedComponentId: '',
|
selectedComponentId: '',
|
||||||
pieces,
|
pieces,
|
||||||
|
products,
|
||||||
subcomponents,
|
subcomponents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,7 +596,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (node.pieces.length > 0 || node.subcomponents.length > 0) {
|
if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return node.subcomponents.some((child) => hasAssignments(child))
|
return node.subcomponents.some((child) => hasAssignments(child))
|
||||||
@@ -538,13 +613,21 @@ const isAssignmentNodeComplete = (
|
|||||||
const piecesComplete = node.pieces.every(
|
const piecesComplete = node.pieces.every(
|
||||||
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
|
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
|
||||||
)
|
)
|
||||||
|
const productsComplete = node.products.every(
|
||||||
|
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
|
||||||
|
)
|
||||||
const subcomponentsComplete = node.subcomponents.every(
|
const subcomponentsComplete = node.subcomponents.every(
|
||||||
(child) =>
|
(child) =>
|
||||||
!!child.selectedComponentId &&
|
!!child.selectedComponentId &&
|
||||||
child.selectedComponentId.length > 0 &&
|
child.selectedComponentId.length > 0 &&
|
||||||
isAssignmentNodeComplete(child, false),
|
isAssignmentNodeComplete(child, false),
|
||||||
)
|
)
|
||||||
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId)
|
return (
|
||||||
|
piecesComplete &&
|
||||||
|
productsComplete &&
|
||||||
|
subcomponentsComplete &&
|
||||||
|
(isRootNode || !!node.selectedComponentId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const structureSelectionsComplete = computed(() => {
|
const structureSelectionsComplete = computed(() => {
|
||||||
@@ -587,6 +670,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
|
|||||||
familyCode: (definition as any).familyCode ?? null,
|
familyCode: (definition as any).familyCode ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sanitizeProductDefinition = (definition: ComponentModelProduct) =>
|
||||||
|
stripNullish({
|
||||||
|
role: (definition as any).role ?? null,
|
||||||
|
typeProductId: definition.typeProductId ?? null,
|
||||||
|
typeProductLabel: (definition as any).typeProductLabel ?? null,
|
||||||
|
reference: (definition as any).reference ?? null,
|
||||||
|
familyCode: (definition as any).familyCode ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
const serializeStructureAssignments = (
|
const serializeStructureAssignments = (
|
||||||
root: StructureAssignmentNode | null,
|
root: StructureAssignmentNode | null,
|
||||||
) => {
|
) => {
|
||||||
@@ -608,6 +700,16 @@ const serializeStructureAssignments = (
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const serializedProducts = assignment.products
|
||||||
|
.filter((product) => !!product.selectedProductId)
|
||||||
|
.map((product) =>
|
||||||
|
stripNullish({
|
||||||
|
path: product.path,
|
||||||
|
definition: sanitizeProductDefinition(product.definition),
|
||||||
|
selectedProductId: product.selectedProductId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const serializedSubcomponents = assignment.subcomponents
|
const serializedSubcomponents = assignment.subcomponents
|
||||||
.map((child) => serializeNode(child, false))
|
.map((child) => serializeNode(child, false))
|
||||||
.filter((child) => Object.keys(child).length > 0)
|
.filter((child) => Object.keys(child).length > 0)
|
||||||
@@ -623,6 +725,9 @@ const serializeStructureAssignments = (
|
|||||||
if (serializedPieces.length) {
|
if (serializedPieces.length) {
|
||||||
base.pieces = serializedPieces
|
base.pieces = serializedPieces
|
||||||
}
|
}
|
||||||
|
if (serializedProducts.length) {
|
||||||
|
base.products = serializedProducts
|
||||||
|
}
|
||||||
if (serializedSubcomponents.length) {
|
if (serializedSubcomponents.length) {
|
||||||
base.subcomponents = serializedSubcomponents
|
base.subcomponents = serializedSubcomponents
|
||||||
}
|
}
|
||||||
@@ -633,6 +738,7 @@ const serializeStructureAssignments = (
|
|||||||
const serializedRoot = serializeNode(root, true)
|
const serializedRoot = serializeNode(root, true)
|
||||||
if (
|
if (
|
||||||
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
|
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
|
||||||
|
(!serializedRoot.products || serializedRoot.products.length === 0) &&
|
||||||
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
|
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
@@ -681,6 +787,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
|||||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||||
|
return Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
}
|
||||||
|
|
||||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||||
if (Array.isArray(structure?.subcomponents)) {
|
if (Array.isArray(structure?.subcomponents)) {
|
||||||
return structure.subcomponents
|
return structure.subcomponents
|
||||||
@@ -698,6 +808,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
|||||||
parts.push(piece.typePiece.name)
|
parts.push(piece.typePiece.name)
|
||||||
} else if (piece.typePieceLabel) {
|
} else if (piece.typePieceLabel) {
|
||||||
parts.push(piece.typePieceLabel)
|
parts.push(piece.typePieceLabel)
|
||||||
|
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||||
|
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
|
||||||
} else if (piece.typePiece?.code) {
|
} else if (piece.typePiece?.code) {
|
||||||
parts.push(`Famille ${piece.typePiece.code}`)
|
parts.push(`Famille ${piece.typePiece.code}`)
|
||||||
} else if (piece.familyCode) {
|
} else if (piece.familyCode) {
|
||||||
@@ -708,6 +820,61 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||||
|
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||||
|
if (!missing.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
missing.map((id) => get(`/model_types/${id}`)),
|
||||||
|
)
|
||||||
|
const next = { ...fetchedPieceTypeMap.value }
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = result.value?.data
|
||||||
|
const name = data?.name || data?.code
|
||||||
|
if (name) {
|
||||||
|
next[missing[index]] = name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fetchedPieceTypeMap.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedTypeStructure,
|
||||||
|
(structure) => {
|
||||||
|
const ids = getStructurePieces(structure)
|
||||||
|
.map((piece: any) => piece?.typePieceId)
|
||||||
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
|
if (!ids.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveProductLabel = (product: Record<string, any>) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (product.role) {
|
||||||
|
parts.push(product.role)
|
||||||
|
}
|
||||||
|
if (product.typeProduct?.name) {
|
||||||
|
parts.push(product.typeProduct.name)
|
||||||
|
} else if (product.typeProductLabel) {
|
||||||
|
parts.push(product.typeProductLabel)
|
||||||
|
} else if (product.typeProduct?.code) {
|
||||||
|
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||||
|
} else if (product.familyCode) {
|
||||||
|
parts.push(`Catégorie ${product.familyCode}`)
|
||||||
|
} else if (product.typeProductId) {
|
||||||
|
parts.push(`#${product.typeProductId}`)
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' • ') : 'Produit'
|
||||||
|
}
|
||||||
|
|
||||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (node.alias) {
|
if (node.alias) {
|
||||||
@@ -737,7 +904,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
|||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurId = null
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
lastSuggestedName.value = ''
|
lastSuggestedName.value = ''
|
||||||
structureAssignments.value = null
|
structureAssignments.value = null
|
||||||
@@ -758,8 +925,8 @@ const submitCreation = async () => {
|
|||||||
payload.reference = reference
|
payload.reference = reference
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creationForm.constructeurId) {
|
if (creationForm.constructeurIds.length) {
|
||||||
payload.constructeurId = creationForm.constructeurId
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPrice = typeof creationForm.prix === 'string'
|
const rawPrice = typeof creationForm.prix === 'string'
|
||||||
@@ -771,12 +938,21 @@ const submitCreation = async () => {
|
|||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
payload.prix = parsed
|
payload.prix = String(parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootProductSelection =
|
||||||
|
structureAssignments.value?.products?.find(
|
||||||
|
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
|
||||||
|
) ?? null
|
||||||
|
|
||||||
|
if (rootProductSelection?.selectedProductId) {
|
||||||
|
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||||
|
}
|
||||||
|
|
||||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||||||
toast.showError('Complétez la sélection des pièces et sous-composants.')
|
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,8 +1002,8 @@ const submitCreation = async () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
loadComponentTypes(),
|
loadComponentTypes(),
|
||||||
loadPieces(),
|
loadPieceTypes(),
|
||||||
loadComposants(),
|
loadProductTypes(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -840,6 +1016,7 @@ interface CustomFieldInput {
|
|||||||
value: string
|
value: string
|
||||||
customFieldId: string | null
|
customFieldId: string | null
|
||||||
customFieldValueId: string | null
|
customFieldValueId: string | null
|
||||||
|
orderIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||||
@@ -851,11 +1028,12 @@ const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null):
|
|||||||
}
|
}
|
||||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||||
return fields
|
return fields
|
||||||
.map((field) => normalizeCustomField(field))
|
.map((field, index) => normalizeCustomField(field, index))
|
||||||
.filter((field): field is CustomFieldInput => field !== null)
|
.filter((field): field is CustomFieldInput => field !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||||
if (!rawField || typeof rawField !== 'object') {
|
if (!rawField || typeof rawField !== 'object') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -873,7 +1051,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
|||||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||||
? rawField.customFieldValueId
|
? rawField.customFieldValueId
|
||||||
: null
|
: null
|
||||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId }
|
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||||
|
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveFieldName = (field: any): string => {
|
const resolveFieldName = (field: any): string => {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold">
|
<h1 class="text-3xl font-bold">
|
||||||
Constructeurs
|
Fournisseurs
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Gérez les constructeurs et leurs coordonnées.
|
Gérez les fournisseurs et leurs coordonnées.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">
|
<button class="btn btn-primary" @click="openCreateModal">
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
Nouveau constructeur
|
Nouveau fournisseur
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
|
|
||||||
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
|
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
|
||||||
<span class="loading loading-spinner loading-lg mb-2" />
|
<span class="loading loading-spinner loading-lg mb-2" />
|
||||||
Chargement des constructeurs...
|
Chargement des fournisseurs...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
|
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
|
||||||
Aucun constructeur trouvé.
|
Aucun fournisseur trouvé.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
|
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
|
||||||
<td>{{ constructeur.name }}</td>
|
<td>{{ constructeur.name }}</td>
|
||||||
<td>{{ constructeur.email || '—' }}</td>
|
<td>{{ constructeur.email || '—' }}</td>
|
||||||
<td>{{ constructeur.phone || '—' }}</td>
|
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} constructeur
|
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -122,13 +122,15 @@ import FieldEmail from '~/components/form/FieldEmail.vue'
|
|||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const sortKey = ref('name')
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingConstructeur = ref(null)
|
const editingConstructeur = ref(null)
|
||||||
@@ -150,6 +152,14 @@ const debouncedSearch = debounce(async () => {
|
|||||||
await searchConstructeurs(searchTerm.value)
|
await searchConstructeurs(searchTerm.value)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
|
const formatPhoneDisplay = (value) => {
|
||||||
|
const formatted = formatPhone(value)
|
||||||
|
if (formatted) {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
return value || '—'
|
||||||
|
}
|
||||||
|
|
||||||
function debounce (fn, delay) {
|
function debounce (fn, delay) {
|
||||||
let timeout
|
let timeout
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
@@ -202,7 +212,7 @@ const saveConstructeur = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = async (constructeur) => {
|
const confirmDelete = async (constructeur) => {
|
||||||
if (!confirm(`Supprimer le constructeur "${constructeur.name}" ?`)) { return }
|
if (!confirm(`Supprimer le fournisseur "${constructeur.name}" ?`)) { return }
|
||||||
const result = await deleteConstructeur(constructeur.id)
|
const result = await deleteConstructeur(constructeur.id)
|
||||||
if (!result.success && result.error) {
|
if (!result.success && result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
class="w-4 h-4 text-secondary"
|
class="w-4 h-4 text-secondary"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span>{{ site.contactPhone }}</span>
|
<span>{{ formatPhoneDisplay(site.contactPhone) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<IconLucideMapPinned
|
<IconLucideMapPinned
|
||||||
@@ -423,6 +423,12 @@
|
|||||||
selectedMachineType.pieceRequirements?.length || 0
|
selectedMachineType.pieceRequirements?.length || 0
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">Produits requis :</span>
|
||||||
|
<span class="badge badge-sm">{{
|
||||||
|
selectedMachineType.productRequirements?.length || 0
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">Catégorie :</span>
|
<span class="font-medium">Catégorie :</span>
|
||||||
<span class="badge badge-outline badge-sm">{{
|
<span class="badge badge-outline badge-sm">{{
|
||||||
@@ -465,10 +471,12 @@ import IconLucideMapPinned from '~icons/lucide/map-pinned'
|
|||||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
import IconLucideTag from '~icons/lucide/tag'
|
||||||
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const { sites, loading, loadSites, createSite } = useSites()
|
const { sites, loading, loadSites, createSite } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||||
const { createMachineFromType, deleteMachine } = useMachines()
|
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const showAddSiteModal = ref(false)
|
const showAddSiteModal = ref(false)
|
||||||
@@ -510,14 +518,64 @@ const categories = computed(() => {
|
|||||||
return Array.from(cats)
|
return Array.from(cats)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const machinesWithType = computed(() => {
|
||||||
|
return machines.value.map((machine) => {
|
||||||
|
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
|
||||||
|
const resolvedTypeMachine = resolvedTypeMachineId
|
||||||
|
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...machine,
|
||||||
|
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
|
||||||
|
typeMachine:
|
||||||
|
machine.typeMachine && typeof machine.typeMachine === 'object'
|
||||||
|
? machine.typeMachine
|
||||||
|
: resolvedTypeMachine
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const machinesBySiteId = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
machinesWithType.value.forEach((machine) => {
|
||||||
|
const siteId = machine.siteId || extractRelationId(machine.site)
|
||||||
|
if (!siteId) { return }
|
||||||
|
|
||||||
|
if (!map.has(siteId)) {
|
||||||
|
map.set(siteId, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
map.get(siteId).push(machine)
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const sitesWithMachines = computed(() => {
|
||||||
|
return sites.value.map((site) => ({
|
||||||
|
...site,
|
||||||
|
machines: machinesBySiteId.value.get(site.id) || []
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
const totalMachines = computed(() => {
|
const totalMachines = computed(() => {
|
||||||
return sites.value.reduce((total, site) => {
|
return sitesWithMachines.value.reduce((total, site) => {
|
||||||
return total + (site.machines?.length || 0)
|
return total + (site.machines?.length || 0)
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formatPhoneDisplay = (value) => {
|
||||||
|
const formatted = formatPhone(value)
|
||||||
|
if (formatted) {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
return value || '—'
|
||||||
|
}
|
||||||
|
|
||||||
const filteredSites = computed(() => {
|
const filteredSites = computed(() => {
|
||||||
let filtered = sites.value
|
let filtered = sitesWithMachines.value
|
||||||
|
|
||||||
// Filtrer par terme de recherche
|
// Filtrer par terme de recherche
|
||||||
if (searchTerm.value) {
|
if (searchTerm.value) {
|
||||||
@@ -536,9 +594,11 @@ const filteredSites = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const machineMatches = site.machines?.some(
|
const machineMatches = site.machines?.some(
|
||||||
machine =>
|
machine => {
|
||||||
machine.name.toLowerCase().includes(lowerTerm) ||
|
const name = (machine.name || '').toLowerCase()
|
||||||
machine.reference?.toLowerCase().includes(lowerTerm)
|
const reference = (machine.reference || '').toLowerCase()
|
||||||
|
return name.includes(lowerTerm) || reference.includes(lowerTerm)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return siteMatches || machineMatches
|
return siteMatches || machineMatches
|
||||||
@@ -622,6 +682,7 @@ const handleCreateMachine = async () => {
|
|||||||
newMachine.typeMachineId = ''
|
newMachine.typeMachineId = ''
|
||||||
newMachine.reference = ''
|
newMachine.reference = ''
|
||||||
showAddMachineModal.value = false
|
showAddMachineModal.value = false
|
||||||
|
await loadMachines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +717,7 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
const result = await deleteMachine(machine.id)
|
const result = await deleteMachine(machine.id)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||||
|
await loadMachines()
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||||
}
|
}
|
||||||
@@ -683,6 +745,6 @@ const getCategoryBadgeClass = (category) => {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadSites(), loadMachineTypes()])
|
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -58,6 +58,13 @@
|
|||||||
pièces</span
|
pièces</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span
|
||||||
|
>{{ type.productRequirements?.length || 0 }} produit(s)
|
||||||
|
requis</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
@@ -99,6 +106,7 @@ import { useToast } from "~/composables/useToast";
|
|||||||
import IconLucidePlus from "~icons/lucide/plus";
|
import IconLucidePlus from "~icons/lucide/plus";
|
||||||
import IconLucidePackage from "~icons/lucide/package";
|
import IconLucidePackage from "~icons/lucide/package";
|
||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||||
|
import IconLucideBox from "~icons/lucide/box";
|
||||||
|
|
||||||
const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
|
const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
|
||||||
useMachineTypesApi();
|
useMachineTypesApi();
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
||||||
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
||||||
</span>
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
|
||||||
|
{{ type.productRequirements?.length || 0 }} produit(s)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -82,9 +86,11 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||||
import IconLucideList from '~icons/lucide/list'
|
import IconLucideList from '~icons/lucide/list'
|
||||||
|
import IconLucideBox from '~icons/lucide/box'
|
||||||
|
|
||||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
@@ -100,7 +106,8 @@ const createEmptyType = () => ({
|
|||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
componentRequirements: [],
|
componentRequirements: [],
|
||||||
pieceRequirements: []
|
pieceRequirements: [],
|
||||||
|
productRequirements: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftType = ref(createEmptyType())
|
const draftType = ref(createEmptyType())
|
||||||
@@ -136,15 +143,32 @@ const parseOptions = (field = {}) => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toModelTypeIri = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const relationId = extractRelationId(value)
|
||||||
|
if (relationId) {
|
||||||
|
return `/api/model_types/${relationId}`
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeCustomFields = (fields = []) =>
|
const normalizeCustomFields = (fields = []) =>
|
||||||
fields
|
fields
|
||||||
.filter(field => field?.name && field.name.trim() !== '')
|
.filter(field => field?.name && field.name.trim() !== '')
|
||||||
.map(field => ({
|
.map((field, index) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type || '',
|
type: field.type || '',
|
||||||
required: !!field.required,
|
required: !!field.required,
|
||||||
options: parseOptions(field)
|
options: parseOptions(field),
|
||||||
|
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
|
||||||
}))
|
}))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||||
|
|
||||||
const toIntegerOrNull = (value, fallback = null) => {
|
const toIntegerOrNull = (value, fallback = null) => {
|
||||||
if (value === '' || value === undefined || value === null) {
|
if (value === '' || value === undefined || value === null) {
|
||||||
@@ -156,9 +180,9 @@ const toIntegerOrNull = (value, fallback = null) => {
|
|||||||
|
|
||||||
const normalizeComponentRequirements = (requirements = []) =>
|
const normalizeComponentRequirements = (requirements = []) =>
|
||||||
requirements
|
requirements
|
||||||
.filter(req => req?.typeComposantId)
|
.filter(req => req?.typeComposantId || req?.typeComposant)
|
||||||
.map((req, index) => ({
|
.map((req, index) => ({
|
||||||
typeComposantId: req.typeComposantId,
|
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
minCount: toIntegerOrNull(req.minCount, 1),
|
minCount: toIntegerOrNull(req.minCount, 1),
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
@@ -171,9 +195,24 @@ const normalizeComponentRequirements = (requirements = []) =>
|
|||||||
|
|
||||||
const normalizePieceRequirements = (requirements = []) =>
|
const normalizePieceRequirements = (requirements = []) =>
|
||||||
requirements
|
requirements
|
||||||
.filter(req => req?.typePieceId)
|
.filter(req => req?.typePieceId || req?.typePiece)
|
||||||
.map((req, index) => ({
|
.map((req, index) => ({
|
||||||
typePieceId: req.typePieceId,
|
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
||||||
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
|
required: req.required ?? false,
|
||||||
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
|
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
||||||
|
|
||||||
|
const normalizeProductRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typeProductId || req?.typeProduct)
|
||||||
|
.map((req, index) => ({
|
||||||
|
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
minCount: toIntegerOrNull(req.minCount, 0),
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
@@ -191,7 +230,8 @@ const buildPayload = typeData => ({
|
|||||||
maintenanceFrequency: typeData.maintenanceFrequency,
|
maintenanceFrequency: typeData.maintenanceFrequency,
|
||||||
customFields: normalizeCustomFields(typeData.customFields),
|
customFields: normalizeCustomFields(typeData.customFields),
|
||||||
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
||||||
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
|
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
|
||||||
|
productRequirements: normalizeProductRequirements(typeData.productRequirements)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -163,8 +163,21 @@ const categories = computed(() => {
|
|||||||
return Array.from(cats)
|
return Array.from(cats)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Enrichir les machines avec les objets site et typeMachine complets
|
||||||
|
const enrichedMachines = computed(() => {
|
||||||
|
return machines.value.map((machine) => {
|
||||||
|
const site = sites.value.find(s => s.id === machine.siteId)
|
||||||
|
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
|
||||||
|
return {
|
||||||
|
...machine,
|
||||||
|
site: site || null,
|
||||||
|
typeMachine: typeMachine || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const filteredMachines = computed(() => {
|
const filteredMachines = computed(() => {
|
||||||
let filtered = machines.value
|
let filtered = enrichedMachines.value
|
||||||
|
|
||||||
if (selectedSite.value) {
|
if (selectedSite.value) {
|
||||||
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
||||||
|
|||||||
@@ -90,13 +90,17 @@
|
|||||||
<span class="font-medium">Groupes de pièces :</span>
|
<span class="font-medium">Groupes de pièces :</span>
|
||||||
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span>
|
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<span class="font-medium">Produits requis :</span>
|
||||||
|
<span class="badge badge-sm">{{ selectedMachineType.productRequirements?.length || 0 }}</span>
|
||||||
|
</span>
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
<span class="font-medium">Catégorie :</span>
|
<span class="font-medium">Catégorie :</span>
|
||||||
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
|
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0"
|
v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0 && (selectedMachineType.productRequirements?.length || 0) === 0"
|
||||||
class="text-xs text-gray-500"
|
class="text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
|
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
|
||||||
@@ -196,7 +200,7 @@
|
|||||||
Référence : {{ findComponentById(entry.composantId)?.reference || "—" }}
|
Référence : {{ findComponentById(entry.composantId)?.reference || "—" }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Constructeur :
|
Fournisseur :
|
||||||
{{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }}
|
{{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -269,18 +273,19 @@
|
|||||||
</label>
|
</label>
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
:model-value="entry.pieceId || ''"
|
:model-value="entry.pieceId || ''"
|
||||||
:options="getPieceOptions(requirement, entry)"
|
:options="getPieceOptions(requirement, entry, entryIndex)"
|
||||||
:loading="piecesLoading"
|
:loading="piecesLoading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="Rechercher une pièce…"
|
placeholder="Rechercher une pièce…"
|
||||||
empty-text="Aucune pièce disponible"
|
empty-text="Aucune pièce disponible"
|
||||||
:option-label="pieceOptionLabel"
|
:option-label="pieceOptionLabel"
|
||||||
:option-description="pieceOptionDescription"
|
:option-description="pieceOptionDescription"
|
||||||
|
@search="(term) => fetchPieceOptions(requirement, entryIndex, term)"
|
||||||
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
|
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="getPieceOptions(requirement, entry).length === 0"
|
v-if="getPieceOptions(requirement, entry, entryIndex).length === 0"
|
||||||
class="text-xs text-error"
|
class="text-xs text-error"
|
||||||
>
|
>
|
||||||
Aucune pièce disponible pour cette famille.
|
Aucune pièce disponible pour cette famille.
|
||||||
@@ -298,16 +303,136 @@
|
|||||||
Référence : {{ findPieceById(entry.pieceId)?.reference || "—" }}
|
Référence : {{ findPieceById(entry.pieceId)?.reference || "—" }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Constructeur :
|
Fournisseur :
|
||||||
{{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }}
|
{{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMachineType?.productRequirements?.length" class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
Produits catalogue requis
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="requirement in selectedMachineType.productRequirements"
|
||||||
|
: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 && getProductRequirementEntries(requirement.id).length >= requirement.maxCount"
|
||||||
|
@click="addProductSelectionEntry(requirement)"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getProductRequirementEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||||
|
Aucun produit sélectionné pour ce groupe.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(entry, entryIndex) in getProductRequirementEntries(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="removeProductSelectionEntry(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="setProductRequirementProduct(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">
|
||||||
|
{{ findProductById(entry.productId)?.name || 'Produit' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Référence : {{ findProductById(entry.productId)?.reference || "—" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Prix indicatif :
|
||||||
|
<span
|
||||||
|
v-if="findProductById(entry.productId)?.supplierPrice !== undefined && findProductById(entry.productId)?.supplierPrice !== null"
|
||||||
|
>
|
||||||
|
{{ Number(findProductById(entry.productId)?.supplierPrice).toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Fournisseurs :
|
||||||
|
<span v-if="findProductById(entry.productId)?.constructeurs?.length">
|
||||||
|
{{ findProductById(entry.productId)?.constructeurs.map(constructeur => constructeur?.name).filter(Boolean).join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="machinePreview" class="space-y-4">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="machinePreview" class="space-y-4">
|
||||||
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
@@ -486,6 +611,73 @@
|
|||||||
Aucun groupe de pièces à configurer pour ce type.
|
Aucun groupe de pièces à configurer pour ce type.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="machinePreview.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 machinePreview.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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
|
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
|
||||||
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
||||||
@@ -551,9 +743,12 @@ import { useSites } from '~/composables/useSites'
|
|||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
@@ -561,11 +756,13 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
|||||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||||
import IconLucideCircle from '~icons/lucide/circle'
|
import IconLucideCircle from '~icons/lucide/circle'
|
||||||
|
|
||||||
const { createMachine, createMachineFromType } = useMachines()
|
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||||
|
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||||
|
const { get } = useApi()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -579,6 +776,7 @@ const newMachine = reactive({
|
|||||||
|
|
||||||
const componentRequirementSelections = reactive({})
|
const componentRequirementSelections = reactive({})
|
||||||
const pieceRequirementSelections = reactive({})
|
const pieceRequirementSelections = reactive({})
|
||||||
|
const productRequirementSelections = reactive({})
|
||||||
|
|
||||||
const selectedMachineType = computed(() => {
|
const selectedMachineType = computed(() => {
|
||||||
if (!newMachine.typeMachineId) {
|
if (!newMachine.typeMachineId) {
|
||||||
@@ -604,7 +802,12 @@ const machineTypeDescription = (type) => {
|
|||||||
}
|
}
|
||||||
const componentCount = type.componentRequirements?.length ?? 0
|
const componentCount = type.componentRequirements?.length ?? 0
|
||||||
const pieceCount = type.pieceRequirements?.length ?? 0
|
const pieceCount = type.pieceRequirements?.length ?? 0
|
||||||
parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`)
|
const productCount = type.productRequirements?.length ?? 0
|
||||||
|
parts.push(
|
||||||
|
`${componentCount} composant(s)`,
|
||||||
|
`${pieceCount} pièce(s)`,
|
||||||
|
`${productCount} produit(s)`
|
||||||
|
)
|
||||||
return parts.join(' • ')
|
return parts.join(' • ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +833,96 @@ const pieceById = computed(() => {
|
|||||||
|
|
||||||
const componentInventory = computed(() => composants.value || [])
|
const componentInventory = computed(() => composants.value || [])
|
||||||
const pieceInventory = computed(() => pieces.value || [])
|
const pieceInventory = computed(() => pieces.value || [])
|
||||||
|
const productInventory = computed(() => products.value || [])
|
||||||
|
|
||||||
|
const productById = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
;(productInventory.value || []).forEach((product) => {
|
||||||
|
if (product?.id) {
|
||||||
|
map.set(product.id, product)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceOptionsByKey = ref({})
|
||||||
|
const pieceLoadingByKey = ref({})
|
||||||
|
|
||||||
|
const extractCollection = (payload) => {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.member)) {
|
||||||
|
return payload.member
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.['hydra:member'])) {
|
||||||
|
return payload['hydra:member']
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload?.data)) {
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceKey = (requirement, entryIndex) => `${requirement?.id || 'req'}:${entryIndex}`
|
||||||
|
|
||||||
|
const findPieceInCachedOptions = (id) => {
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (!Array.isArray(bucket)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const found = bucket.find((piece) => piece?.id === id)
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachePieceIfMissing = (piece) => {
|
||||||
|
if (!piece?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pieceById.value.has(piece.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||||
|
pieces.value = [...current, piece]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPieceOptions = async (requirement, entryIndex, term = '') => {
|
||||||
|
const key = getPieceKey(requirement, entryIndex)
|
||||||
|
if (pieceLoadingByKey.value[key]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', '50')
|
||||||
|
if (term && term.trim()) {
|
||||||
|
params.set('name', term.trim())
|
||||||
|
}
|
||||||
|
if (requirementTypeId) {
|
||||||
|
params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||||
|
try {
|
||||||
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
pieceOptionsByKey.value = {
|
||||||
|
...pieceOptionsByKey.value,
|
||||||
|
[key]: extractCollection(result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
@@ -866,7 +1159,12 @@ const getComponentOptions = (requirement, currentEntry) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPieceOptions = (requirement, currentEntry) => {
|
const getPieceOptions = (requirement, currentEntry, entryIndex) => {
|
||||||
|
const key = getPieceKey(requirement, entryIndex)
|
||||||
|
const cached = pieceOptionsByKey.value[key]
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||||
const usedIds = new Set(
|
const usedIds = new Set(
|
||||||
selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
||||||
@@ -904,6 +1202,11 @@ const componentOptionDescription = (component) => {
|
|||||||
if (machineAssignments.length) {
|
if (machineAssignments.length) {
|
||||||
parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
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(' • ')
|
return parts.join(' • ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,9 +1232,83 @@ const pieceOptionDescription = (piece) => {
|
|||||||
if (componentAssignments.length) {
|
if (componentAssignments.length) {
|
||||||
parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
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(' • ')
|
return parts.join(' • ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getProductOptions = (requirement) => {
|
||||||
|
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
|
||||||
|
return productInventory.value.filter((product) => {
|
||||||
|
if (!product?.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!requirementTypeId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const productTypeId =
|
||||||
|
product.typeProductId ||
|
||||||
|
product.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
return productTypeId === requirementTypeId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const productOptionLabel = (product) => product?.name || product?.reference || 'Produit'
|
||||||
|
|
||||||
|
const productOptionDescription = (product) => {
|
||||||
|
if (!product) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const parts = []
|
||||||
|
if (product.reference) {
|
||||||
|
parts.push(`Réf. ${product.reference}`)
|
||||||
|
}
|
||||||
|
if (product.constructeurs?.length) {
|
||||||
|
const label = product.constructeurs
|
||||||
|
.map((constructeur) => constructeur?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
if (label) {
|
||||||
|
parts.push(`Fournisseurs: ${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (product.supplierPrice !== undefined && product.supplierPrice !== null) {
|
||||||
|
const price = Number(product.supplierPrice)
|
||||||
|
if (!Number.isNaN(price)) {
|
||||||
|
parts.push(`${price.toFixed(2)} €`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductTypeIdFromComponent = (component) => {
|
||||||
|
if (!component || typeof component !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
component.product?.typeProductId ||
|
||||||
|
component.product?.typeProduct?.id ||
|
||||||
|
component.productTypeId ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductTypeIdFromPiece = (piece) => {
|
||||||
|
if (!piece || typeof piece !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
piece.product?.typeProductId ||
|
||||||
|
piece.product?.typeProduct?.id ||
|
||||||
|
piece.productTypeId ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const setComponentRequirementComponent = (requirement, index, componentId) => {
|
const setComponentRequirementComponent = (requirement, index, componentId) => {
|
||||||
const entries = getComponentRequirementEntries(requirement.id)
|
const entries = getComponentRequirementEntries(requirement.id)
|
||||||
const entry = entries[index]
|
const entry = entries[index]
|
||||||
@@ -951,8 +1328,11 @@ const setPieceRequirementPiece = (requirement, index, pieceId) => {
|
|||||||
if (!entry) return
|
if (!entry) return
|
||||||
entry.pieceId = pieceId || null
|
entry.pieceId = pieceId || null
|
||||||
if (pieceId) {
|
if (pieceId) {
|
||||||
const piece = findPieceById(pieceId)
|
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||||
|
if (piece) {
|
||||||
|
cachePieceIfMissing(piece)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
entry.typePieceId = requirement?.typePieceId || null
|
entry.typePieceId = requirement?.typePieceId || null
|
||||||
}
|
}
|
||||||
@@ -969,7 +1349,14 @@ const findPieceById = (id) => {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return pieceById.value.get(id) || null
|
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findProductById = (id) => {
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return productById.value.get(id) || null
|
||||||
}
|
}
|
||||||
const getStatusBadgeClass = (status) => {
|
const getStatusBadgeClass = (status) => {
|
||||||
if (status === 'ready') {
|
if (status === 'ready') {
|
||||||
@@ -1003,6 +1390,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
|
|||||||
|
|
||||||
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
|
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
|
||||||
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
|
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
|
||||||
|
const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
const createComponentSelectionEntry = (requirement, source = null) => ({
|
const createComponentSelectionEntry = (requirement, source = null) => ({
|
||||||
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
|
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
|
||||||
@@ -1016,6 +1404,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
|
|||||||
definition: {},
|
definition: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createProductSelectionEntry = (requirement, source = null) => ({
|
||||||
|
typeProductId:
|
||||||
|
source?.typeProductId ||
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
requirement?.typeProduct?.id ||
|
||||||
|
null,
|
||||||
|
productId: source?.productId || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const computeProductUsageFromSelections = (type) => {
|
||||||
|
const usage = new Map()
|
||||||
|
|
||||||
|
const increment = (typeProductId) => {
|
||||||
|
if (!typeProductId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of type.componentRequirements || []) {
|
||||||
|
const entries = getComponentRequirementEntries(requirement.id)
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry?.composantId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const component = findComponentById(entry.composantId)
|
||||||
|
const typeProductId = getProductTypeIdFromComponent(component)
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of type.pieceRequirements || []) {
|
||||||
|
const entries = getPieceRequirementEntries(requirement.id)
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry?.pieceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const piece = findPieceById(entry.pieceId)
|
||||||
|
const typeProductId = getProductTypeIdFromPiece(piece)
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of type.productRequirements || []) {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id)
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry?.productId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const product = findProductById(entry.productId)
|
||||||
|
const typeProductId =
|
||||||
|
product?.typeProductId ||
|
||||||
|
product?.typeProduct?.id ||
|
||||||
|
entry?.typeProductId ||
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
requirement?.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildProductRequirementStats = (type) => {
|
||||||
|
const usage = computeProductUsageFromSelections(type)
|
||||||
|
|
||||||
|
const stats = (type.productRequirements || []).map((requirement) => {
|
||||||
|
const typeProductId =
|
||||||
|
requirement.typeProductId ||
|
||||||
|
requirement.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
|
||||||
|
const label =
|
||||||
|
requirement.label?.trim() ||
|
||||||
|
requirement.typeProduct?.name ||
|
||||||
|
requirement.typeProduct?.code ||
|
||||||
|
'Produit requis'
|
||||||
|
|
||||||
|
const typeName = requirement.typeProduct?.name || 'Non défini'
|
||||||
|
|
||||||
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
|
const max = requirement.maxCount ?? null
|
||||||
|
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
|
||||||
|
const rawEntries = getProductRequirementEntries(requirement.id)
|
||||||
|
const normalizedEntries = rawEntries.map((entry, index) => {
|
||||||
|
const product = entry?.productId ? findProductById(entry.productId) : null
|
||||||
|
const subtitleParts = []
|
||||||
|
if (product?.reference) {
|
||||||
|
subtitleParts.push(`Réf. ${product.reference}`)
|
||||||
|
}
|
||||||
|
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
|
||||||
|
const price = Number(product.supplierPrice)
|
||||||
|
if (!Number.isNaN(price)) {
|
||||||
|
subtitleParts.push(`${price.toFixed(2)} €`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(product?.constructeurs) && product.constructeurs.length) {
|
||||||
|
const label = product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ')
|
||||||
|
if (label) {
|
||||||
|
subtitleParts.push(`Fournisseurs: ${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `${requirement.id}-${index}`,
|
||||||
|
status: product ? 'complete' : 'pending',
|
||||||
|
title: product?.name || product?.reference || `Sélection #${index + 1}`,
|
||||||
|
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const issues = []
|
||||||
|
if (count < min) {
|
||||||
|
issues.push({
|
||||||
|
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
|
||||||
|
kind: 'error',
|
||||||
|
anchor: `product-group-${requirement.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (max !== null && count > max) {
|
||||||
|
issues.push({
|
||||||
|
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
|
||||||
|
kind: 'error',
|
||||||
|
anchor: `product-group-${requirement.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEntries.length > 0 && normalizedEntries.some((entry) => entry.status !== 'complete')) {
|
||||||
|
issues.push({
|
||||||
|
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
|
||||||
|
kind: 'error',
|
||||||
|
anchor: `product-group-${requirement.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = normalizedEntries.filter((entry) => entry.status === 'complete').length
|
||||||
|
const total = normalizedEntries.length
|
||||||
|
|
||||||
|
const status = issues.some((issue) => issue.kind === 'error')
|
||||||
|
? 'error'
|
||||||
|
: issues.some((issue) => issue.kind === 'warning')
|
||||||
|
? 'warning'
|
||||||
|
: 'ready'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: requirement.id,
|
||||||
|
requirement,
|
||||||
|
label,
|
||||||
|
typeName,
|
||||||
|
count,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
entries: normalizedEntries,
|
||||||
|
issues,
|
||||||
|
allowNewModels: requirement.allowNewModels ?? true,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { stats, usage }
|
||||||
|
}
|
||||||
|
|
||||||
const clearRequirementSelections = () => {
|
const clearRequirementSelections = () => {
|
||||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||||
delete componentRequirementSelections[key]
|
delete componentRequirementSelections[key]
|
||||||
@@ -1023,6 +1575,9 @@ const clearRequirementSelections = () => {
|
|||||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||||
delete pieceRequirementSelections[key]
|
delete pieceRequirementSelections[key]
|
||||||
})
|
})
|
||||||
|
Object.keys(productRequirementSelections).forEach((key) => {
|
||||||
|
delete productRequirementSelections[key]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addComponentSelectionEntry = (requirement) => {
|
const addComponentSelectionEntry = (requirement) => {
|
||||||
@@ -1054,6 +1609,7 @@ const addPieceSelectionEntry = (requirement) => {
|
|||||||
...entries,
|
...entries,
|
||||||
createPieceSelectionEntry(requirement),
|
createPieceSelectionEntry(requirement),
|
||||||
]
|
]
|
||||||
|
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const removePieceSelectionEntry = (requirementId, index) => {
|
const removePieceSelectionEntry = (requirementId, index) => {
|
||||||
@@ -1061,6 +1617,51 @@ const removePieceSelectionEntry = (requirementId, index) => {
|
|||||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addProductSelectionEntry = (requirement) => {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id)
|
||||||
|
const max = requirement.maxCount ?? null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productRequirementSelections[requirement.id] = [
|
||||||
|
...entries,
|
||||||
|
createProductSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProductSelectionEntry = (requirementId, index) => {
|
||||||
|
const entries = getProductRequirementEntries(requirementId)
|
||||||
|
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProductRequirementProduct = (requirement, index, productId) => {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id)
|
||||||
|
const entry = entries[index]
|
||||||
|
if (!entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProductId = productId || null
|
||||||
|
entry.productId = normalizedProductId
|
||||||
|
|
||||||
|
if (normalizedProductId) {
|
||||||
|
const product = findProductById(normalizedProductId)
|
||||||
|
entry.typeProductId =
|
||||||
|
product?.typeProductId ||
|
||||||
|
product?.typeProduct?.id ||
|
||||||
|
entry.typeProductId ||
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
requirement?.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
entry.typeProductId =
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
requirement?.typeProduct?.id ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extractParentIdentifiers = (source) => {
|
const extractParentIdentifiers = (source) => {
|
||||||
if (!isPlainObject(source)) {
|
if (!isPlainObject(source)) {
|
||||||
return {}
|
return {}
|
||||||
@@ -1113,6 +1714,7 @@ const validateRequirementSelections = (type) => {
|
|||||||
const errors = []
|
const errors = []
|
||||||
const componentLinksPayload = []
|
const componentLinksPayload = []
|
||||||
const pieceLinksPayload = []
|
const pieceLinksPayload = []
|
||||||
|
const productLinksPayload = []
|
||||||
|
|
||||||
for (const requirement of type.componentRequirements || []) {
|
for (const requirement of type.componentRequirements || []) {
|
||||||
const entries = getComponentRequirementEntries(requirement.id)
|
const entries = getComponentRequirementEntries(requirement.id)
|
||||||
@@ -1216,6 +1818,58 @@ const validateRequirementSelections = (type) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { stats: productStats } = buildProductRequirementStats(type)
|
||||||
|
for (const requirement of type.productRequirements || []) {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id)
|
||||||
|
const max = requirement.maxCount ?? null
|
||||||
|
|
||||||
|
if (max !== null && entries.length > max) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.productId) {
|
||||||
|
errors.push(`Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = findProductById(entry.productId)
|
||||||
|
if (!product) {
|
||||||
|
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null
|
||||||
|
const productTypeId =
|
||||||
|
product.typeProductId ||
|
||||||
|
product.typeProduct?.id ||
|
||||||
|
entry.typeProductId ||
|
||||||
|
null
|
||||||
|
|
||||||
|
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
|
||||||
|
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
requirementId: requirement.id,
|
||||||
|
productId: entry.productId,
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry))
|
||||||
|
|
||||||
|
productLinksPayload.push(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
productStats.forEach((stat) => {
|
||||||
|
stat.issues
|
||||||
|
.filter((issue) => issue.kind === 'error')
|
||||||
|
.forEach((issue) => {
|
||||||
|
errors.push(issue.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return { valid: false, error: errors[0] }
|
return { valid: false, error: errors[0] }
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1878,7 @@ const validateRequirementSelections = (type) => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
componentLinks: componentLinksPayload,
|
componentLinks: componentLinksPayload,
|
||||||
pieceLinks: pieceLinksPayload,
|
pieceLinks: pieceLinksPayload,
|
||||||
|
productLinks: productLinksPayload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,20 +2080,24 @@ const machinePreview = computed(() => {
|
|||||||
issues,
|
issues,
|
||||||
completed,
|
completed,
|
||||||
total: entries.length,
|
total: entries.length,
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { stats: productGroups } = buildProductRequirementStats(type)
|
||||||
|
|
||||||
const aggregatedIssues = [
|
const aggregatedIssues = [
|
||||||
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
|
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
|
||||||
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||||
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label })))
|
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||||
|
...productGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||||
]
|
]
|
||||||
|
|
||||||
const statuses = [
|
const statuses = [
|
||||||
baseStatus,
|
baseStatus,
|
||||||
...componentGroups.map(group => group.status),
|
...componentGroups.map(group => group.status),
|
||||||
...pieceGroups.map(group => group.status)
|
...pieceGroups.map(group => group.status),
|
||||||
|
...productGroups.map(group => group.status),
|
||||||
]
|
]
|
||||||
|
|
||||||
const overallStatus = statuses.includes('error')
|
const overallStatus = statuses.includes('error')
|
||||||
@@ -1455,11 +2114,14 @@ const machinePreview = computed(() => {
|
|||||||
},
|
},
|
||||||
componentGroups,
|
componentGroups,
|
||||||
pieceGroups,
|
pieceGroups,
|
||||||
|
productGroups,
|
||||||
type: {
|
type: {
|
||||||
name: type.name,
|
name: type.name,
|
||||||
category: type.category || null,
|
category: type.category || null,
|
||||||
hasStructuredDefinition:
|
hasStructuredDefinition:
|
||||||
(type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0
|
(type.componentRequirements?.length || 0) > 0 ||
|
||||||
|
(type.pieceRequirements?.length || 0) > 0 ||
|
||||||
|
(type.productRequirements?.length || 0) > 0
|
||||||
},
|
},
|
||||||
status: overallStatus,
|
status: overallStatus,
|
||||||
ready: overallStatus === 'ready',
|
ready: overallStatus === 'ready',
|
||||||
@@ -1508,6 +2170,7 @@ const handleIssueClick = (issue) => {
|
|||||||
const initializeRequirementSelections = (type) => {
|
const initializeRequirementSelections = (type) => {
|
||||||
const componentRequirements = type.componentRequirements || []
|
const componentRequirements = type.componentRequirements || []
|
||||||
const pieceRequirements = type.pieceRequirements || []
|
const pieceRequirements = type.pieceRequirements || []
|
||||||
|
const productRequirements = type.productRequirements || []
|
||||||
|
|
||||||
componentRequirements.forEach((requirement) => {
|
componentRequirements.forEach((requirement) => {
|
||||||
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
@@ -1524,10 +2187,26 @@ const initializeRequirementSelections = (type) => {
|
|||||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
if (initialCount > 0) {
|
if (initialCount > 0) {
|
||||||
pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement))
|
pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement))
|
||||||
|
pieceRequirementSelections[requirement.id].forEach((_, index) => {
|
||||||
|
fetchPieceOptions(requirement, index).catch(() => {})
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
pieceRequirementSelections[requirement.id] = []
|
pieceRequirementSelections[requirement.id] = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
productRequirements.forEach((requirement) => {
|
||||||
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
|
if (initialCount > 0) {
|
||||||
|
productRequirementSelections[requirement.id] = Array.from(
|
||||||
|
{ length: initialCount },
|
||||||
|
() => createProductSelectionEntry(requirement),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
productRequirementSelections[requirement.id] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalizeMachineCreation = async () => {
|
const finalizeMachineCreation = async () => {
|
||||||
@@ -1553,10 +2232,14 @@ const finalizeMachineCreation = async () => {
|
|||||||
typeMachineId: type.id
|
typeMachineId: type.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRequirements = (type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0
|
const hasRequirements =
|
||||||
|
(type.componentRequirements?.length || 0) > 0 ||
|
||||||
|
(type.pieceRequirements?.length || 0) > 0 ||
|
||||||
|
(type.productRequirements?.length || 0) > 0
|
||||||
|
|
||||||
let componentLinks = []
|
let componentLinks = []
|
||||||
let pieceLinks = []
|
let pieceLinks = []
|
||||||
|
let productLinks = []
|
||||||
|
|
||||||
if (hasRequirements) {
|
if (hasRequirements) {
|
||||||
const validationResult = validateRequirementSelections(type)
|
const validationResult = validateRequirementSelections(type)
|
||||||
@@ -1566,23 +2249,25 @@ const finalizeMachineCreation = async () => {
|
|||||||
}
|
}
|
||||||
componentLinks = validationResult.componentLinks
|
componentLinks = validationResult.componentLinks
|
||||||
pieceLinks = validationResult.pieceLinks
|
pieceLinks = validationResult.pieceLinks
|
||||||
}
|
productLinks = validationResult.productLinks
|
||||||
|
|
||||||
const payload = {
|
|
||||||
...baseMachineData,
|
|
||||||
...(hasRequirements
|
|
||||||
? {
|
|
||||||
componentLinks,
|
|
||||||
pieceLinks
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = hasRequirements
|
const result = hasRequirements
|
||||||
? await createMachine(payload)
|
? await createMachine(baseMachineData)
|
||||||
: await createMachineFromType(baseMachineData, type)
|
: await createMachineFromType(baseMachineData, type)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (hasRequirements && result.data?.id) {
|
||||||
|
const skeletonResult = await reconfigureSkeleton(result.data.id, {
|
||||||
|
componentLinks,
|
||||||
|
pieceLinks,
|
||||||
|
productLinks,
|
||||||
|
})
|
||||||
|
if (!skeletonResult.success) {
|
||||||
|
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
newMachine.name = ''
|
newMachine.name = ''
|
||||||
newMachine.siteId = ''
|
newMachine.siteId = ''
|
||||||
newMachine.typeMachineId = ''
|
newMachine.typeMachineId = ''
|
||||||
@@ -1621,7 +2306,8 @@ onMounted(async () => {
|
|||||||
loadSites(),
|
loadSites(),
|
||||||
loadMachineTypes(),
|
loadMachineTypes(),
|
||||||
loadComposants(),
|
loadComposants(),
|
||||||
loadPieces()
|
loadPieces(),
|
||||||
|
loadProducts()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSubmitBlocked,
|
||||||
|
submitBlockMessage,
|
||||||
|
loadLinkedCount,
|
||||||
|
guardSubmitOrNotify,
|
||||||
|
} = useCategoryEditGuard({
|
||||||
|
endpoint: '/pieces',
|
||||||
|
filterKey: 'typePiece',
|
||||||
|
labels: {
|
||||||
|
singular: 'pièce',
|
||||||
|
plural: 'pièces',
|
||||||
|
verifying: 'Vérification des pièces liées en cours…',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const title = computed(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
||||||
)
|
)
|
||||||
@@ -86,6 +104,8 @@ const loadCategory = async () => {
|
|||||||
notes: response.notes ?? response.description ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -99,6 +119,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom, référence ou catégorie…"
|
placeholder="Nom ou référence…"
|
||||||
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
id="piece-catalog-sort"
|
id="piece-catalog-sort"
|
||||||
v-model="sortField"
|
v-model="sortField"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleSortChange"
|
||||||
>
|
>
|
||||||
<option value="name">Nom</option>
|
<option value="name">Nom</option>
|
||||||
<option value="createdAt">Date de création</option>
|
<option value="createdAt">Date de création</option>
|
||||||
@@ -63,14 +65,33 @@
|
|||||||
id="piece-catalog-dir"
|
id="piece-catalog-dir"
|
||||||
v-model="sortDirection"
|
v-model="sortDirection"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleSortChange"
|
||||||
>
|
>
|
||||||
<option value="asc">Ascendant</option>
|
<option value="asc">Ascendant</option>
|
||||||
<option value="desc">Descendant</option>
|
<option value="desc">Descendant</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="piece-catalog-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="piece-catalog-per-page"
|
||||||
|
v-model.number="itemsPerPage"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handlePerPageChange"
|
||||||
|
>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/50 lg:text-right">
|
<p class="text-xs text-base-content/50 lg:text-right">
|
||||||
{{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }}
|
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,54 +103,85 @@
|
|||||||
Aucune pièce n'a encore été créée.
|
Aucune pièce n'a encore été créée.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-else-if="!visiblePieces.length" class="text-sm text-base-content/70">
|
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
|
||||||
Aucune pièce ne correspond à votre recherche.
|
Aucune pièce ne correspond à votre recherche.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<template v-else>
|
||||||
<table class="table table-sm md:table-md">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="table table-sm md:table-md">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="w-24">Aperçu</th>
|
<tr>
|
||||||
<th>Nom</th>
|
<th class="w-24">Aperçu</th>
|
||||||
<th>Catégorie</th>
|
<th>Nom</th>
|
||||||
<th>Référence</th>
|
<th>Référence</th>
|
||||||
<th>Actions</th>
|
<th>Fournisseurs</th>
|
||||||
</tr>
|
<th>Type de pièce</th>
|
||||||
</thead>
|
<th>Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr v-for="piece in visiblePieces" :key="piece.id">
|
</thead>
|
||||||
<td class="align-middle">
|
<tbody>
|
||||||
<DocumentThumbnail
|
<tr v-for="row in pieceRows" :key="row.piece.id">
|
||||||
:document="resolvePrimaryDocument(piece)"
|
<td class="align-middle">
|
||||||
:alt="resolvePreviewAlt(piece)"
|
<DocumentThumbnail
|
||||||
/>
|
:document="resolvePrimaryDocument(row.piece)"
|
||||||
</td>
|
:alt="resolvePreviewAlt(row.piece)"
|
||||||
<td>{{ piece.name || 'Pièce sans nom' }}</td>
|
/>
|
||||||
<td>{{ piece.typePiece?.name || '—' }}</td>
|
</td>
|
||||||
<td>{{ piece.reference || '—' }}</td>
|
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
||||||
<td>
|
<td>{{ row.piece.reference || '—' }}</td>
|
||||||
<div class="flex items-center gap-2">
|
<td>
|
||||||
<NuxtLink
|
<div
|
||||||
:to="`/pieces/${piece.id}/edit`"
|
v-if="row.suppliers.visible.length"
|
||||||
class="btn btn-ghost btn-xs"
|
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||||
|
:title="row.suppliers.tooltip"
|
||||||
>
|
>
|
||||||
Modifier
|
<span
|
||||||
</NuxtLink>
|
v-for="supplier in row.suppliers.visible"
|
||||||
<button
|
:key="supplier"
|
||||||
type="button"
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
class="btn btn-error btn-xs"
|
>
|
||||||
:disabled="loadingPieces"
|
{{ supplier }}
|
||||||
@click="handleDeletePiece(piece)"
|
</span>
|
||||||
>
|
<span
|
||||||
Supprimer
|
v-if="row.suppliers.overflow"
|
||||||
</button>
|
class="badge badge-outline badge-sm"
|
||||||
</div>
|
>
|
||||||
</td>
|
+{{ row.suppliers.overflow }}
|
||||||
</tr>
|
</span>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
<span v-else>—</span>
|
||||||
</div>
|
</td>
|
||||||
|
<td>{{ resolvePieceType(row.piece) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/pieces/${row.piece.id}/edit`"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs"
|
||||||
|
:disabled="loadingPieces"
|
||||||
|
@click="handleDeletePiece(row.piece)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -137,21 +189,82 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||||
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
const piecesList = computed(() => pieces.value || [])
|
|
||||||
const piecesTotal = computed(() => piecesList.value.length)
|
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(30)
|
||||||
|
const piecesTotal = computed(() => total.value)
|
||||||
|
const piecesOnPage = computed(() => pieces.value.length)
|
||||||
|
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
|
// Search state with debounce
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const sortField = ref<'name' | 'createdAt'>('name')
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
||||||
|
const debouncedSearch = () => {
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchPieces()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||||
|
'pieces-catalog',
|
||||||
|
{ field: 'name', direction: 'asc' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enrichir les pièces avec les types de pièces complets
|
||||||
|
const piecesList = computed(() => {
|
||||||
|
return (pieces.value || []).map((piece) => {
|
||||||
|
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
||||||
|
return {
|
||||||
|
...piece,
|
||||||
|
typePiece: typePiece || piece.typePiece || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchPieces = async () => {
|
||||||
|
await loadPieces({
|
||||||
|
search: searchTerm.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
orderBy: sortField.value,
|
||||||
|
orderDir: sortDirection.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchPieces()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchPieces()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerPageChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchPieces()
|
||||||
|
}
|
||||||
|
|
||||||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||||||
@@ -182,6 +295,99 @@ const resolvePreviewAlt = (piece: Record<string, any>) => {
|
|||||||
return 'Aperçu du document'
|
return 'Aperçu du document'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvePieceType = (piece: Record<string, any>) => {
|
||||||
|
const type = piece?.typePiece
|
||||||
|
if (type?.name) {
|
||||||
|
return type.name
|
||||||
|
}
|
||||||
|
if (piece?.typePieceLabel) {
|
||||||
|
return piece.typePieceLabel
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE_SUPPLIERS = 3
|
||||||
|
|
||||||
|
const resolvePieceSuppliers = (piece: Record<string, any>) => {
|
||||||
|
const names: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
const pushName = (maybeName: unknown) => {
|
||||||
|
if (typeof maybeName !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||||
|
if (!normalized.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = normalized.toLowerCase()
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
names.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectConstructeurs = (value: unknown): void => {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(collectConstructeurs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
pushName(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const record = value as Record<string, any>
|
||||||
|
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||||
|
if (record?.constructeur) {
|
||||||
|
collectConstructeurs(record.constructeur)
|
||||||
|
}
|
||||||
|
if (Array.isArray(record?.constructeurs)) {
|
||||||
|
collectConstructeurs(record.constructeurs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectFromLabel = (value: unknown): void => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.split(/[,;\\/•·|]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach(pushName)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectConstructeurs(piece?.constructeurs)
|
||||||
|
collectConstructeurs(piece?.constructeur)
|
||||||
|
collectConstructeurs(piece?.product?.constructeurs)
|
||||||
|
collectConstructeurs(piece?.product?.constructeur)
|
||||||
|
|
||||||
|
collectFromLabel(piece?.constructeursLabel)
|
||||||
|
collectFromLabel(piece?.supplierLabel)
|
||||||
|
collectFromLabel(piece?.product?.constructeursLabel)
|
||||||
|
collectFromLabel(piece?.product?.supplierLabel)
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
|
||||||
|
const suppliers = resolvePieceSuppliers(piece)
|
||||||
|
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||||
|
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||||
|
return {
|
||||||
|
suppliers,
|
||||||
|
visible,
|
||||||
|
overflow,
|
||||||
|
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (piece: Record<string, any>) => {
|
const resolveDeleteGuard = (piece: Record<string, any>) => {
|
||||||
const blockingReasons: string[] = []
|
const blockingReasons: string[] = []
|
||||||
const machineLinks = Array.isArray(piece?.machineLinks)
|
const machineLinks = Array.isArray(piece?.machineLinks)
|
||||||
@@ -206,59 +412,12 @@ const resolveDeleteGuard = (piece: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveComparableName = (piece: Record<string, any>) => {
|
const pieceRows = computed(() =>
|
||||||
const normalise = (value?: string | null) =>
|
piecesList.value.map((piece) => ({
|
||||||
(value ?? '').toString().trim().toLowerCase()
|
piece,
|
||||||
|
suppliers: buildPieceSuppliersDisplay(piece),
|
||||||
return (
|
})),
|
||||||
normalise(piece?.name) ||
|
)
|
||||||
normalise(piece?.reference) ||
|
|
||||||
normalise(piece?.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveComparableDate = (piece: Record<string, any>) => {
|
|
||||||
const raw = piece?.createdAt ?? piece?.created_at ?? null
|
|
||||||
if (!raw) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
const timestamp = new Date(raw).getTime()
|
|
||||||
return Number.isNaN(timestamp) ? 0 : timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
const visiblePieces = computed(() => {
|
|
||||||
const term = searchTerm.value.trim().toLowerCase()
|
|
||||||
const source = piecesList.value || []
|
|
||||||
|
|
||||||
const filtered = term
|
|
||||||
? source.filter((piece) => {
|
|
||||||
const name = (piece?.name || '').toLowerCase()
|
|
||||||
const reference = (piece?.reference || '').toLowerCase()
|
|
||||||
const category = (piece?.typePiece?.name || '').toLowerCase()
|
|
||||||
return (
|
|
||||||
name.includes(term) ||
|
|
||||||
reference.includes(term) ||
|
|
||||||
category.includes(term)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: [...source]
|
|
||||||
|
|
||||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
if (sortField.value === 'name') {
|
|
||||||
return (
|
|
||||||
resolveComparableName(a).localeCompare(
|
|
||||||
resolveComparableName(b),
|
|
||||||
'fr',
|
|
||||||
{ sensitivity: 'base' }
|
|
||||||
) * direction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
|
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
|
||||||
@@ -289,9 +448,14 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await deletePiece(piece.id)
|
await deletePiece(piece.id)
|
||||||
|
// Reload current page after deletion
|
||||||
|
fetchPieces()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadPieces()
|
await Promise.all([
|
||||||
|
fetchPieces(),
|
||||||
|
loadPieceTypes()
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -89,19 +89,20 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
placeholder="Référence interne ou constructeur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Constructeur</span>
|
<span class="label-text">Fournisseur</span>
|
||||||
</label>
|
</label>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurId"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
placeholder="Rechercher un constructeur..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="piece?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,26 +124,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div
|
||||||
|
v-if="structureProducts.length"
|
||||||
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">
|
||||||
|
Produit requis par le squelette
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ul class="space-y-2 text-sm text-base-content/80">
|
||||||
|
<li
|
||||||
|
v-for="(description, index) in productRequirementDescriptions"
|
||||||
|
:key="`edit-requirement-${index}`"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
||||||
|
<span>{{ description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="entry in productRequirementEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium">
|
||||||
|
{{ entry.label }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="productSelections[entry.index] || null"
|
||||||
|
:disabled="saving"
|
||||||
|
:type-product-id="entry.typeProductId"
|
||||||
|
helper-text="Un produit valide est requis pour cette pièce."
|
||||||
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedType || resolvedStructure" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||||
<p class="text-xs text-base-content/70">
|
<p class="text-xs text-base-content/70">
|
||||||
{{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
{{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-outline">{{ formatPieceStructurePreview(selectedType.structure) }}</span>
|
<span class="badge badge-outline">{{ formatPieceStructurePreview(resolvedStructure) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details v-if="selectedType.structure" class="collapse collapse-arrow bg-base-100">
|
<details v-if="resolvedStructure" class="collapse collapse-arrow bg-base-100">
|
||||||
<summary class="collapse-title text-sm font-medium">
|
<summary class="collapse-title text-sm font-medium">
|
||||||
Consulter le détail du squelette
|
Consulter le détail du squelette
|
||||||
</summary>
|
</summary>
|
||||||
<div class="collapse-content space-y-2 text-sm text-base-content/80">
|
<div class="collapse-content space-y-2 text-sm text-base-content/80">
|
||||||
<div v-if="getStructureCustomFields(selectedType.structure).length" class="space-y-1">
|
<div v-if="getStructureCustomFields(resolvedStructure).length" class="space-y-1">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li v-for="field in getStructureCustomFields(selectedType.structure)" :key="field.name">
|
<li v-for="field in getStructureCustomFields(resolvedStructure)" :key="field.name">
|
||||||
<span class="font-medium">{{ field.name }}</span>
|
<span class="font-medium">{{ field.name }}</span>
|
||||||
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -336,6 +381,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Qui a changé quoi, et quand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||||
|
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||||
|
Chargement de l’historique…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="historyError" class="alert alert-warning">
|
||||||
|
<span>{{ historyError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||||
|
Aucun changement enregistré pour le moment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
<li
|
||||||
|
v-for="entry in historyEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||||
|
<span class="font-medium text-base-content">
|
||||||
|
{{ historyActionLabel(entry.action) }}
|
||||||
|
</span>
|
||||||
|
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="historyDiffEntries(entry).length"
|
||||||
|
class="mt-2 space-y-1 text-xs"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="diffEntry in historyDiffEntries(entry)"
|
||||||
|
:key="`${entry.id}-${diffEntry.field}`"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||||
|
<span class="text-base-content/60">
|
||||||
|
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="entry.snapshot?.name"
|
||||||
|
class="mt-2 text-xs text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ entry.snapshot.name }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -356,17 +469,23 @@ import { useRoute, useRouter } from '#imports'
|
|||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
|
import { getModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
interface PieceCatalogType extends ModelType {
|
interface PieceCatalogType extends ModelType {
|
||||||
structure: PieceModelStructure | null
|
structure: PieceModelStructure | null
|
||||||
@@ -382,6 +501,7 @@ interface CustomFieldInput {
|
|||||||
value: string
|
value: string
|
||||||
customFieldId: string | null
|
customFieldId: string | null
|
||||||
customFieldValueId: string | null
|
customFieldValueId: string | null
|
||||||
|
orderIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -389,9 +509,16 @@ const router = useRouter()
|
|||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = usePieceHistory()
|
||||||
|
|
||||||
const piece = ref<any | null>(null)
|
const piece = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -403,17 +530,113 @@ const pieceDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
|
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
||||||
|
|
||||||
|
const historyFieldLabels: Record<string, string> = {
|
||||||
|
name: 'Nom',
|
||||||
|
reference: 'Référence',
|
||||||
|
prix: 'Prix',
|
||||||
|
typePiece: 'Catégorie',
|
||||||
|
product: 'Produit lié',
|
||||||
|
productIds: 'Produits liés',
|
||||||
|
constructeurIds: 'Fournisseurs',
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyActionLabel = (action: string) => {
|
||||||
|
if (action === 'create') {
|
||||||
|
return 'Création'
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
return 'Suppression'
|
||||||
|
}
|
||||||
|
return 'Modification'
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatHistoryDate = (value: string) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return historyDateFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatHistoryValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const maybeRecord = value as Record<string, unknown>
|
||||||
|
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||||
|
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||||
|
if (name && id) {
|
||||||
|
return `${name} (#${id})`
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
return `#${id}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDiffEntries = (entry: PieceHistoryEntry) => {
|
||||||
|
const diff = entry.diff ?? {}
|
||||||
|
return Object.entries(diff).map(([field, change]) => {
|
||||||
|
const label = historyFieldLabels[field] ?? field
|
||||||
|
const fromLabel = formatHistoryValue(change?.from)
|
||||||
|
const toLabel = formatHistoryValue(change?.to)
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
fromLabel,
|
||||||
|
toLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurId: null as string | null,
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
})
|
})
|
||||||
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
const documentIcon = (doc: any) =>
|
const documentIcon = (doc: any) =>
|
||||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||||
|
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||||
|
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshCustomFieldInputs = (
|
||||||
|
structureOverride?: PieceModelStructure | null,
|
||||||
|
valuesOverride?: any[] | null,
|
||||||
|
) => {
|
||||||
|
const structure = structureOverride ?? resolvedStructure.value ?? null
|
||||||
|
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||||
|
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||||
|
}
|
||||||
const formatSize = (size: number | null | undefined) => {
|
const formatSize = (size: number | null | undefined) => {
|
||||||
if (size === null || size === undefined) {
|
if (size === null || size === undefined) {
|
||||||
return '—'
|
return '—'
|
||||||
@@ -540,6 +763,90 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
|
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||||
|
|
||||||
|
const structureProducts = computed(() =>
|
||||||
|
getStructureProducts(resolvedStructure.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
|
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||||
|
if (!requirement) {
|
||||||
|
return `Produit ${index + 1}`
|
||||||
|
}
|
||||||
|
const parts: string[] = []
|
||||||
|
if (requirement.role) {
|
||||||
|
parts.push(requirement.role)
|
||||||
|
}
|
||||||
|
if (requirement.typeProductLabel) {
|
||||||
|
parts.push(requirement.typeProductLabel)
|
||||||
|
} else if (requirement.typeProductId) {
|
||||||
|
parts.push(`Catégorie #${requirement.typeProductId}`)
|
||||||
|
}
|
||||||
|
if (requirement.familyCode) {
|
||||||
|
parts.push(`Famille ${requirement.familyCode}`)
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push(`Produit ${index + 1}`)
|
||||||
|
}
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const productRequirementDescriptions = computed(() =>
|
||||||
|
structureProducts.value.map((requirement, index) =>
|
||||||
|
describeProductRequirement(requirement, index),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ensureProductSelections = (count: number) => {
|
||||||
|
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||||
|
productSelections.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingProductIds: string[] = []
|
||||||
|
|
||||||
|
const productRequirementEntries = computed(() =>
|
||||||
|
structureProducts.value.map((requirement, index) => ({
|
||||||
|
index,
|
||||||
|
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||||
|
label: describeProductRequirement(requirement, index),
|
||||||
|
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const productSelectionsFilled = computed(() =>
|
||||||
|
!requiresProductSelection.value ||
|
||||||
|
productRequirementEntries.value.every((entry) => {
|
||||||
|
const value = productSelections.value[entry.index]
|
||||||
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
|
const normalized = typeof value === 'string' ? value : null
|
||||||
|
const next = [...productSelections.value]
|
||||||
|
next[index] = normalized
|
||||||
|
productSelections.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(structureProducts, (products) => {
|
||||||
|
ensureProductSelections(products.length)
|
||||||
|
if (!pendingProductIds.length || products.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = Array.from(
|
||||||
|
{ length: products.length },
|
||||||
|
(_, index) => pendingProductIds[index] ?? null,
|
||||||
|
)
|
||||||
|
productSelections.value = next
|
||||||
|
pendingProductIds = []
|
||||||
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
customFieldInputs.value.every((field) => {
|
customFieldInputs.value.every((field) => {
|
||||||
if (!field.required) {
|
if (!field.required) {
|
||||||
@@ -552,12 +859,15 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() =>
|
||||||
piece.value &&
|
Boolean(
|
||||||
editionForm.name &&
|
piece.value &&
|
||||||
requiredCustomFieldsFilled.value &&
|
editionForm.name &&
|
||||||
!saving.value,
|
requiredCustomFieldsFilled.value &&
|
||||||
))
|
productSelectionsFilled.value &&
|
||||||
|
!saving.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const toFieldString = (value: unknown): string => {
|
const toFieldString = (value: unknown): string => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -583,12 +893,38 @@ const fetchPiece = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
piece.value = result.data
|
piece.value = result.data
|
||||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
||||||
|
if (customValues.success && Array.isArray(customValues.data)) {
|
||||||
|
piece.value.customFieldValues = customValues.data
|
||||||
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
|
}
|
||||||
|
await loadPieceTypeDetails(result.data)
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||||
|
const typeId = currentPiece?.typePieceId
|
||||||
|
|| extractRelationId(currentPiece?.typePiece)
|
||||||
|
|| ''
|
||||||
|
if (!typeId) {
|
||||||
|
pieceTypeDetails.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const type = await getModelType(typeId)
|
||||||
|
if (type && typeof type === 'object') {
|
||||||
|
pieceTypeDetails.value = type
|
||||||
|
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
pieceTypeDetails.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -598,17 +934,43 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedTypeId.value = currentPiece.typePieceId || ''
|
const resolvedTypeId = currentPiece.typePieceId
|
||||||
|
|| extractRelationId(currentPiece.typePiece)
|
||||||
|
|| ''
|
||||||
|
if (resolvedTypeId && !currentPiece.typePieceId) {
|
||||||
|
currentPiece.typePieceId = resolvedTypeId
|
||||||
|
}
|
||||||
|
selectedTypeId.value = resolvedTypeId
|
||||||
|
|
||||||
editionForm.name = currentPiece.name || ''
|
editionForm.name = currentPiece.name || ''
|
||||||
editionForm.reference = currentPiece.reference || ''
|
editionForm.reference = currentPiece.reference || ''
|
||||||
editionForm.constructeurId = currentPiece.constructeur?.id || currentPiece.constructeurId || null
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
currentPiece,
|
||||||
|
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
|
||||||
customFieldInputs.value = buildCustomFieldInputs(
|
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||||
currentType?.structure ?? null,
|
|
||||||
currentPiece.customFieldValues,
|
|
||||||
)
|
)
|
||||||
|
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||||
|
if (editionForm.constructeurIds.length) {
|
||||||
|
void ensureConstructeurs(editionForm.constructeurIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||||
|
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||||
|
: currentPiece.product?.id || currentPiece.productId
|
||||||
|
? [String(currentPiece.product?.id || currentPiece.productId)]
|
||||||
|
: []
|
||||||
|
pendingProductIds = existingProductIds
|
||||||
|
ensureProductSelections(structureProducts.value.length)
|
||||||
|
if (existingProductIds.length && structureProducts.value.length) {
|
||||||
|
const next = Array.from(
|
||||||
|
{ length: structureProducts.value.length },
|
||||||
|
(_, index) => existingProductIds[index] ?? null,
|
||||||
|
)
|
||||||
|
productSelections.value = next
|
||||||
|
pendingProductIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -619,10 +981,17 @@ watch(selectedType, (currentType) => {
|
|||||||
if (!piece.value || !currentType) {
|
if (!piece.value || !currentType) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
customFieldInputs.value = buildCustomFieldInputs(
|
if (!pieceTypeDetails.value) {
|
||||||
currentType.structure,
|
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||||
piece.value.customFieldValues,
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
|
watch(resolvedStructure, (currentStructure) => {
|
||||||
|
if (!piece.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureProductSelections(structureProducts.value.length)
|
||||||
|
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
@@ -630,24 +999,39 @@ const submitEdition = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!productSelectionsFilled.value) {
|
||||||
|
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rawPrice = typeof editionForm.prix === 'string'
|
const rawPrice = typeof editionForm.prix === 'string'
|
||||||
? editionForm.prix.trim()
|
? editionForm.prix.trim()
|
||||||
: editionForm.prix === null || editionForm.prix === undefined
|
: editionForm.prix === null || editionForm.prix === undefined
|
||||||
? ''
|
? ''
|
||||||
: String(editionForm.prix).trim()
|
: String(editionForm.prix).trim()
|
||||||
|
|
||||||
|
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: editionForm.name.trim(),
|
name: editionForm.name.trim(),
|
||||||
|
constructeurIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = editionForm.reference.trim()
|
const reference = editionForm.reference.trim()
|
||||||
payload.reference = reference ? reference : null
|
payload.reference = reference ? reference : null
|
||||||
payload.constructeurId = editionForm.constructeurId || null
|
|
||||||
|
const normalizedProductIds = productRequirementEntries.value
|
||||||
|
.map((entry) => productSelections.value[entry.index])
|
||||||
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
|
||||||
|
payload.productIds = normalizedProductIds
|
||||||
|
payload.productId = normalizedProductIds[0] || null
|
||||||
|
|
||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
payload.prix = parsed
|
payload.prix = String(parsed)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
payload.prix = null
|
payload.prix = null
|
||||||
@@ -701,6 +1085,7 @@ const buildCustomFieldInputs = (
|
|||||||
...definition,
|
...definition,
|
||||||
customFieldId: definition.customFieldId || definition.id,
|
customFieldId: definition.customFieldId || definition.id,
|
||||||
customFieldValueId: null,
|
customFieldValueId: null,
|
||||||
|
orderIndex: definition.orderIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,8 +1095,14 @@ const buildCustomFieldInputs = (
|
|||||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||||
customFieldValueId: matched.id ?? null,
|
customFieldValueId: matched.id ?? null,
|
||||||
value: formatDefaultValue(definition.type, resolvedValue),
|
value: formatDefaultValue(definition.type, resolvedValue),
|
||||||
|
orderIndex: Math.min(
|
||||||
|
definition.orderIndex ?? 0,
|
||||||
|
typeof matched.customField?.orderIndex === 'number'
|
||||||
|
? matched.customField.orderIndex
|
||||||
|
: definition.orderIndex ?? 0,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
})
|
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -725,11 +1116,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
|
|||||||
}
|
}
|
||||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||||
return fields
|
return fields
|
||||||
.map((field) => normalizeCustomField(field))
|
.map((field, index) => normalizeCustomField(field, index))
|
||||||
.filter((field): field is CustomFieldInput => field !== null)
|
.filter((field): field is CustomFieldInput => field !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||||
if (!rawField || typeof rawField !== 'object') {
|
if (!rawField || typeof rawField !== 'object') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -750,7 +1142,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
|||||||
? rawField.customFieldValueId
|
? rawField.customFieldValueId
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId }
|
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||||
|
|
||||||
|
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveFieldName = (field: any): string => {
|
const resolveFieldName = (field: any): string => {
|
||||||
@@ -825,8 +1219,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
|
|||||||
return String(defaultValue)
|
return String(defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
|
|
||||||
|
|
||||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||||
customFieldName: field.name,
|
customFieldName: field.name,
|
||||||
customFieldType: field.type,
|
customFieldType: field.type,
|
||||||
|
|||||||
@@ -62,19 +62,19 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="submitting || !selectedType"
|
||||||
placeholder="Référence interne ou constructeur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Constructeur</span>
|
<span class="label-text">Fournisseur</span>
|
||||||
</label>
|
</label>
|
||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurId"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="submitting || !selectedType"
|
||||||
placeholder="Rechercher un constructeur..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="structureProducts.length"
|
||||||
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">
|
||||||
|
Produit requis par le squelette
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ul class="space-y-2 text-sm text-base-content/80">
|
||||||
|
<li
|
||||||
|
v-for="(description, index) in productRequirementDescriptions"
|
||||||
|
:key="`requirement-${index}`"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
||||||
|
<span>{{ description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="entry in productRequirementEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium">
|
||||||
|
{{ entry.label }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="productSelections[entry.index] || null"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
:type-product-id="entry.typeProductId"
|
||||||
|
helper-text="Un produit est requis pour cette pièce."
|
||||||
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -253,6 +297,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from '#imports'
|
import { useRoute, useRouter } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
@@ -260,7 +305,8 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
interface PieceCatalogType extends ModelType {
|
interface PieceCatalogType extends ModelType {
|
||||||
@@ -283,9 +329,10 @@ const submitting = ref(false)
|
|||||||
const creationForm = reactive({
|
const creationForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurId: null as string | null,
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
})
|
})
|
||||||
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
@@ -331,6 +378,79 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
|
const structureProducts = computed(() =>
|
||||||
|
getStructureProducts(selectedType.value?.structure ?? null),
|
||||||
|
)
|
||||||
|
|
||||||
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
|
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||||
|
if (!requirement) {
|
||||||
|
return `Produit ${index + 1}`
|
||||||
|
}
|
||||||
|
const parts: string[] = []
|
||||||
|
if (requirement.role) {
|
||||||
|
parts.push(requirement.role)
|
||||||
|
}
|
||||||
|
if (requirement.typeProductLabel) {
|
||||||
|
parts.push(requirement.typeProductLabel)
|
||||||
|
} else if (requirement.typeProductId) {
|
||||||
|
parts.push(`Catégorie #${requirement.typeProductId}`)
|
||||||
|
}
|
||||||
|
if (requirement.familyCode) {
|
||||||
|
parts.push(`Famille ${requirement.familyCode}`)
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push(`Produit ${index + 1}`)
|
||||||
|
}
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const productRequirementDescriptions = computed(() =>
|
||||||
|
structureProducts.value.map((requirement, index) =>
|
||||||
|
describeProductRequirement(requirement, index),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ensureProductSelections = (count: number) => {
|
||||||
|
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||||
|
productSelections.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const productRequirementEntries = computed(() =>
|
||||||
|
structureProducts.value.map((requirement, index) => ({
|
||||||
|
index,
|
||||||
|
key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||||
|
label: describeProductRequirement(requirement, index),
|
||||||
|
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const productSelectionsFilled = computed(() =>
|
||||||
|
!requiresProductSelection.value ||
|
||||||
|
productRequirementEntries.value.every((entry) => {
|
||||||
|
const value = productSelections.value[entry.index]
|
||||||
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
|
const normalized = typeof value === 'string' ? value : null
|
||||||
|
const next = [...productSelections.value]
|
||||||
|
next[index] = normalized
|
||||||
|
productSelections.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(structureProducts, (products) => {
|
||||||
|
ensureProductSelections(products.length)
|
||||||
|
})
|
||||||
|
|
||||||
watch(selectedType, (type) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearCreationForm()
|
clearCreationForm()
|
||||||
@@ -342,6 +462,7 @@ watch(selectedType, (type) => {
|
|||||||
}
|
}
|
||||||
lastSuggestedName.value = creationForm.name
|
lastSuggestedName.value = creationForm.name
|
||||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||||
|
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
@@ -356,12 +477,15 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() =>
|
||||||
selectedType.value &&
|
Boolean(
|
||||||
creationForm.name &&
|
selectedType.value &&
|
||||||
requiredCustomFieldsFilled.value &&
|
creationForm.name &&
|
||||||
!submitting.value,
|
requiredCustomFieldsFilled.value &&
|
||||||
))
|
productSelectionsFilled.value &&
|
||||||
|
!submitting.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const toFieldString = (value: unknown): string => {
|
const toFieldString = (value: unknown): string => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -376,13 +500,12 @@ const toFieldString = (value: unknown): string => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
|
|
||||||
|
|
||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurId = null
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
|
productSelections.value = []
|
||||||
lastSuggestedName.value = ''
|
lastSuggestedName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +514,12 @@ const submitCreation = async () => {
|
|||||||
toast.showError('Sélectionnez une catégorie de pièce.')
|
toast.showError('Sélectionnez une catégorie de pièce.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!productSelectionsFilled.value) {
|
||||||
|
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: creationForm.name.trim(),
|
name: creationForm.name.trim(),
|
||||||
typePieceId: selectedType.value.id,
|
typePieceId: selectedType.value.id,
|
||||||
@@ -401,8 +530,15 @@ const submitCreation = async () => {
|
|||||||
payload.reference = reference
|
payload.reference = reference
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creationForm.constructeurId) {
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
payload.constructeurId = creationForm.constructeurId
|
|
||||||
|
const normalizedProductIds = productRequirementEntries.value
|
||||||
|
.map((entry) => productSelections.value[entry.index])
|
||||||
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
if (normalizedProductIds.length) {
|
||||||
|
payload.productIds = normalizedProductIds
|
||||||
|
payload.productId = normalizedProductIds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPrice = typeof creationForm.prix === 'string'
|
const rawPrice = typeof creationForm.prix === 'string'
|
||||||
@@ -414,7 +550,7 @@ const submitCreation = async () => {
|
|||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
payload.prix = parsed
|
payload.prix = String(parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +602,7 @@ interface CustomFieldInput {
|
|||||||
value: string
|
value: string
|
||||||
customFieldId: string | null
|
customFieldId: string | null
|
||||||
customFieldValueId: string | null
|
customFieldValueId: string | null
|
||||||
|
orderIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||||
@@ -477,11 +614,12 @@ const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): Cust
|
|||||||
}
|
}
|
||||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||||
return fields
|
return fields
|
||||||
.map((field) => normalizeCustomField(field))
|
.map((field, index) => normalizeCustomField(field, index))
|
||||||
.filter((field): field is CustomFieldInput => field !== null)
|
.filter((field): field is CustomFieldInput => field !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||||
if (!rawField || typeof rawField !== 'object') {
|
if (!rawField || typeof rawField !== 'object') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -502,7 +640,9 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => {
|
|||||||
? rawField.customFieldValueId
|
? rawField.customFieldValueId
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId }
|
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||||
|
|
||||||
|
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveFieldName = (field: any): string => {
|
const resolveFieldName = (field: any): string => {
|
||||||
|
|||||||
405
app/pages/product-catalog.vue
Normal file
405
app/pages/product-catalog.vue
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
|
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
|
||||||
|
Ajouter un produit
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
|
||||||
|
Gérer les catégories
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="searchTerm"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
|
||||||
|
<select
|
||||||
|
id="product-sort"
|
||||||
|
v-model="sortField"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
>
|
||||||
|
<option value="name">Nom</option>
|
||||||
|
<option value="createdAt">Date de création</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
|
||||||
|
<select
|
||||||
|
id="product-dir"
|
||||||
|
v-model="sortDirection"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
>
|
||||||
|
<option value="asc">Ascendant</option>
|
||||||
|
<option value="desc">Descendant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/60 lg:text-right">
|
||||||
|
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<span class="loading loading-spinner" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="alert alert-error"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold">Impossible de charger les produits</span>
|
||||||
|
<span class="text-sm">{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
|
||||||
|
Chargement du catalogue…
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
|
||||||
|
Aucun produit n'a encore été enregistré.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
|
||||||
|
Aucun produit ne correspond à votre recherche.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="table table-sm md:table-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-16">Aperçu</th>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Référence</th>
|
||||||
|
<th>Type de produit</th>
|
||||||
|
<th>Fournisseurs</th>
|
||||||
|
<th class="text-right">Prix indicatif</th>
|
||||||
|
<th class="w-32 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in productRows" :key="row.product.id">
|
||||||
|
<td class="align-middle">
|
||||||
|
<DocumentThumbnail
|
||||||
|
:document="resolvePrimaryDocument(row.product)"
|
||||||
|
:alt="resolvePreviewAlt(row.product)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="font-medium">{{ row.product.name }}</td>
|
||||||
|
<td>{{ row.product.reference || '—' }}</td>
|
||||||
|
<td>{{ row.product.typeProduct?.name || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
v-if="row.suppliers.visible.length"
|
||||||
|
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||||
|
:title="row.suppliers.tooltip"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="supplier in row.suppliers.visible"
|
||||||
|
:key="supplier"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ supplier }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="row.suppliers.overflow"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
+{{ row.suppliers.overflow }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-sm text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{{ formatPrice(row.product.supplierPrice) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/product/${row.product.id}/edit`"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="confirmDelete(row.product)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useHead } from '#imports'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||||
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: 'Catalogue des produits',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
loadProducts,
|
||||||
|
deleteProduct,
|
||||||
|
} = useProducts()
|
||||||
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const searchTerm = ref('')
|
||||||
|
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||||
|
'product-catalog',
|
||||||
|
{ field: 'name', direction: 'asc' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enrichir les produits avec les types de produits complets
|
||||||
|
const normalizedProducts = computed(() => {
|
||||||
|
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
||||||
|
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
typeProduct: typeProduct || product.typeProduct || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const hasLoaded = computed(() => loaded.value)
|
||||||
|
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||||
|
|
||||||
|
const filteredProducts = computed(() => {
|
||||||
|
const term = searchTerm.value.trim().toLowerCase()
|
||||||
|
const items = normalizedProducts.value.slice()
|
||||||
|
|
||||||
|
const filtered = term
|
||||||
|
? items.filter((product) => {
|
||||||
|
const name = (product?.name || '').toLowerCase()
|
||||||
|
const reference = (product?.reference || '').toLowerCase()
|
||||||
|
const typeName = (product?.typeProduct?.name || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
name.includes(term) ||
|
||||||
|
reference.includes(term) ||
|
||||||
|
typeName.includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: items
|
||||||
|
|
||||||
|
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
if (sortField.value === 'name') {
|
||||||
|
return (
|
||||||
|
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
|
||||||
|
) * direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||||
|
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||||
|
return (dateA - dateB) * direction
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredCount = computed(() => filteredProducts.value.length)
|
||||||
|
const totalCount = computed(() => {
|
||||||
|
const reported = Number(total.value)
|
||||||
|
if (!Number.isFinite(reported) || reported < 0) {
|
||||||
|
return normalizedProducts.value.length
|
||||||
|
}
|
||||||
|
return reported
|
||||||
|
})
|
||||||
|
|
||||||
|
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
currencyDisplay: 'narrowSymbol',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPrice = (value: any) => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
const number = Number(value)
|
||||||
|
if (Number.isNaN(number)) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return priceFormatter.format(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE_SUPPLIERS = 3
|
||||||
|
|
||||||
|
const resolveProductSuppliers = (product: Record<string, any>) => {
|
||||||
|
const names: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
const pushName = (maybeName: unknown) => {
|
||||||
|
if (typeof maybeName !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||||
|
if (!normalized.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = normalized.toLowerCase()
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
names.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectConstructeurs = (value: unknown): void => {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(collectConstructeurs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
pushName(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const record = value as Record<string, any>
|
||||||
|
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||||
|
if (record?.constructeur) {
|
||||||
|
collectConstructeurs(record.constructeur)
|
||||||
|
}
|
||||||
|
if (Array.isArray(record?.constructeurs)) {
|
||||||
|
collectConstructeurs(record.constructeurs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectFromLabel = (value: unknown): void => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.split(/[,;\\/•·|]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach(pushName)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectConstructeurs(product?.constructeurs)
|
||||||
|
collectConstructeurs(product?.constructeur)
|
||||||
|
|
||||||
|
collectFromLabel(product?.constructeursLabel)
|
||||||
|
collectFromLabel(product?.supplierLabel)
|
||||||
|
collectFromLabel(product?.suppliers)
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSuppliersDisplay = (product: Record<string, any>) => {
|
||||||
|
const suppliers = resolveProductSuppliers(product)
|
||||||
|
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||||
|
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||||
|
return {
|
||||||
|
suppliers,
|
||||||
|
visible,
|
||||||
|
overflow,
|
||||||
|
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productRows = computed(() =>
|
||||||
|
filteredProducts.value.map((product) => ({
|
||||||
|
product,
|
||||||
|
suppliers: buildSuppliersDisplay(product),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||||
|
const documents = Array.isArray(product?.documents) ? product.documents : []
|
||||||
|
if (!documents.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||||
|
const withPath = normalized.filter((doc) => doc?.path)
|
||||||
|
if (!withPath.length) {
|
||||||
|
return normalized[0] ?? null
|
||||||
|
}
|
||||||
|
const images = withPath.filter((doc) => isImageDocument(doc))
|
||||||
|
if (images.length) {
|
||||||
|
return images[0]
|
||||||
|
}
|
||||||
|
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||||
|
if (pdf) {
|
||||||
|
return pdf
|
||||||
|
}
|
||||||
|
return withPath[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||||
|
const parts = [product?.name, product?.reference].filter(Boolean)
|
||||||
|
if (parts.length) {
|
||||||
|
return `Aperçu du document de ${parts.join(' – ')}`
|
||||||
|
}
|
||||||
|
return 'Aperçu du document'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
await loadProducts({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (product: Record<string, any>) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteProduct(product.id)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess(`Produit "${product.name}" supprimé`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadProducts(),
|
||||||
|
loadProductTypes()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
145
app/pages/product-category/[id]/edit.vue
Normal file
145
app/pages/product-category/[id]/edit.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
|
||||||
|
<p class="text-base text-base-content/70">
|
||||||
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink class="btn btn-ghost" to="/product-category">
|
||||||
|
Retour au catalogue
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie…</span>
|
||||||
|
</div>
|
||||||
|
<ModelTypeForm
|
||||||
|
v-else
|
||||||
|
mode="edit"
|
||||||
|
initial-category="PRODUCT"
|
||||||
|
:initial-data="initialData"
|
||||||
|
:lock-category="true"
|
||||||
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSubmitBlocked,
|
||||||
|
submitBlockMessage,
|
||||||
|
loadLinkedCount,
|
||||||
|
guardSubmitOrNotify,
|
||||||
|
} = useCategoryEditGuard({
|
||||||
|
endpoint: '/products',
|
||||||
|
filterKey: 'typeProduct',
|
||||||
|
labels: {
|
||||||
|
singular: 'produit',
|
||||||
|
plural: 'produits',
|
||||||
|
verifying: 'Vérification des produits liés en cours…',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
|
||||||
|
)
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: title.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const navigateBackToList = async () => {
|
||||||
|
await router.push('/product-category').catch(() => {
|
||||||
|
showError("Navigation impossible vers la liste des catégories.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeError = (error: any) => {
|
||||||
|
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
|
||||||
|
return Array.isArray(message) ? message[0] : message
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCategory = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const id = String(route.params.id)
|
||||||
|
const response = await getModelType(id)
|
||||||
|
|
||||||
|
if (response.category !== 'PRODUCT') {
|
||||||
|
showError("Cette catégorie n'est pas un produit.")
|
||||||
|
await navigateBackToList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initialData.value = {
|
||||||
|
name: response.name,
|
||||||
|
code: response.code,
|
||||||
|
category: response.category,
|
||||||
|
notes: response.notes ?? response.description ?? '',
|
||||||
|
structure: response.structure ?? undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
|
} catch (error) {
|
||||||
|
showError(normalizeError(error))
|
||||||
|
await navigateBackToList()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigateBackToList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = String(route.params.id)
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const enrichedPayload = {
|
||||||
|
...payload,
|
||||||
|
description: payload?.notes ?? null,
|
||||||
|
}
|
||||||
|
await updateModelType(id, enrichedPayload)
|
||||||
|
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||||
|
await navigateBackToList()
|
||||||
|
} catch (error) {
|
||||||
|
showError(normalizeError(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCategory()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
11
app/pages/product-category/index.vue
Normal file
11
app/pages/product-category/index.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<ManagementView
|
||||||
|
category="PRODUCT"
|
||||||
|
heading="Catégories de produit"
|
||||||
|
description="Gérez les catégories de produits et leurs champs personnalisés communs."
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
</script>
|
||||||
68
app/pages/product-category/new.vue
Normal file
68
app/pages/product-category/new.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de produit</h1>
|
||||||
|
<p class="text-base text-base-content/70">
|
||||||
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink class="btn btn-ghost" to="/product-category">
|
||||||
|
Retour au catalogue
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
|
||||||
|
<ModelTypeForm
|
||||||
|
mode="create"
|
||||||
|
initial-category="PRODUCT"
|
||||||
|
:lock-category="true"
|
||||||
|
:saving="saving"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useHead, useRouter } from '#imports'
|
||||||
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
|
import { createModelType } from '~/services/modelTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: 'Nouvelle catégorie de produit',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { showError, showSuccess } = useToast()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push('/product-category').catch(() => {
|
||||||
|
showError("Navigation impossible vers la liste des catégories.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const enrichedPayload = {
|
||||||
|
...payload,
|
||||||
|
description: payload.notes ?? null,
|
||||||
|
}
|
||||||
|
await createModelType(enrichedPayload)
|
||||||
|
showSuccess('Catégorie de produit créée avec succès.')
|
||||||
|
await router.push('/product-category')
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
|
||||||
|
showError(Array.isArray(message) ? message[0] : message)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
936
app/pages/product/[id]/edit.vue
Normal file
936
app/pages/product/[id]/edit.vue
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
<template>
|
||||||
|
<DocumentPreviewModal
|
||||||
|
:document="previewDocument"
|
||||||
|
:visible="previewVisible"
|
||||||
|
@close="closePreview"
|
||||||
|
/>
|
||||||
|
<main class="container mx-auto px-6 py-10">
|
||||||
|
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
|
||||||
|
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||||
|
<p class="text-sm text-base-content/70">Chargement du produit…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!product" class="max-w-xl mx-auto">
|
||||||
|
<div class="alert alert-error shadow-lg">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-lg">Produit introuvable</h2>
|
||||||
|
<p class="text-sm text-base-content/80">
|
||||||
|
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
|
||||||
|
Retour au catalogue
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
||||||
|
Retour au catalogue
|
||||||
|
</NuxtLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Catégorie de produit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md bg-base-200"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Nom du produit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="editionForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="saving"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="editionForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseurs</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="editionForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="product?.constructeurs || []"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="editionForm.supplierPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline">{{ structurePreview }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Mettez à jour les valeurs propres à ce produit.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in customFieldInputs"
|
||||||
|
:key="fieldKey(field, index)"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">{{ field.name }}</span>
|
||||||
|
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
v-model="field.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="field.value"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="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"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option
|
||||||
|
v-for="option in field.options"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="field.value"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
true-value="true"
|
||||||
|
false-value="false"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="field.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Gérez les documents associés à ce produit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedFiles.length" class="badge badge-outline">
|
||||||
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedFiles"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents…
|
||||||
|
</p>
|
||||||
|
<div v-else-if="productDocuments.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
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(document)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && document.path"
|
||||||
|
:src="document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-6 w-6"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ document.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-base-content/70">
|
||||||
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs"
|
||||||
|
:disabled="uploadingDocuments || saving"
|
||||||
|
@click="removeDocument(document.id)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-base-content/70">
|
||||||
|
Aucun document n'est associé à ce produit pour le moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Qui a changé quoi, et quand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||||
|
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||||
|
Chargement de l’historique…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="historyError" class="alert alert-warning">
|
||||||
|
<span>{{ historyError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||||
|
Aucun changement enregistré pour le moment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
<li
|
||||||
|
v-for="entry in historyEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||||
|
<span class="font-medium text-base-content">
|
||||||
|
{{ historyActionLabel(entry.action) }}
|
||||||
|
</span>
|
||||||
|
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="historyDiffEntries(entry).length"
|
||||||
|
class="mt-2 space-y-1 text-xs"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="diffEntry in historyDiffEntries(entry)"
|
||||||
|
:key="`${entry.id}-${diffEntry.field}`"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||||
|
<span class="text-base-content/60">
|
||||||
|
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="entry.snapshot?.name"
|
||||||
|
class="mt-2 text-xs text-base-content/70"
|
||||||
|
>
|
||||||
|
{{ entry.snapshot.name }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
|
Annuler
|
||||||
|
</NuxtLink>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||||
|
Enregistrer les modifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from '#imports'
|
||||||
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||||
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import { getModelType } from '~/services/modelTypes'
|
||||||
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||||
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
|
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
interface CustomFieldInput {
|
||||||
|
id: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
value: string
|
||||||
|
customFieldId: string | null
|
||||||
|
customFieldValueId: string | null
|
||||||
|
orderIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { getProduct, updateProduct } = useProducts()
|
||||||
|
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||||
|
const {
|
||||||
|
loadDocumentsByProduct,
|
||||||
|
uploadDocuments: uploadProductDocuments,
|
||||||
|
deleteDocument: deleteProductDocument,
|
||||||
|
} = useDocuments()
|
||||||
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useProductHistory()
|
||||||
|
|
||||||
|
const product = ref<any | null>(null)
|
||||||
|
const productType = ref<any | null>(null)
|
||||||
|
const structure = ref<ProductModelStructure | null>(null)
|
||||||
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const uploadingDocuments = ref(false)
|
||||||
|
const loadingDocuments = ref(false)
|
||||||
|
const productDocuments = ref<any[]>([])
|
||||||
|
const previewDocument = ref<any | null>(null)
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
|
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
||||||
|
|
||||||
|
const historyFieldLabels: Record<string, string> = {
|
||||||
|
name: 'Nom',
|
||||||
|
reference: 'Référence',
|
||||||
|
supplierPrice: 'Prix fournisseur',
|
||||||
|
typeProduct: 'Catégorie',
|
||||||
|
constructeurIds: 'Fournisseurs',
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyActionLabel = (action: string) => {
|
||||||
|
if (action === 'create') {
|
||||||
|
return 'Création'
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
return 'Suppression'
|
||||||
|
}
|
||||||
|
return 'Modification'
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatHistoryDate = (value: string) => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return historyDateFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatHistoryValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const maybeRecord = value as Record<string, unknown>
|
||||||
|
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||||
|
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||||
|
if (name && id) {
|
||||||
|
return `${name} (#${id})`
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
return `#${id}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDiffEntries = (entry: ProductHistoryEntry) => {
|
||||||
|
const diff = entry.diff ?? {}
|
||||||
|
return Object.entries(diff).map(([field, change]) => {
|
||||||
|
const label = historyFieldLabels[field] ?? field
|
||||||
|
const fromLabel = formatHistoryValue(change?.from)
|
||||||
|
const toLabel = formatHistoryValue(change?.to)
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
fromLabel,
|
||||||
|
toLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshCustomFieldInputs = (
|
||||||
|
structureOverride?: ProductModelStructure | null,
|
||||||
|
valuesOverride?: any[] | null,
|
||||||
|
) => {
|
||||||
|
const nextStructure = structureOverride ?? structure.value ?? null
|
||||||
|
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
||||||
|
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editionForm = reactive({
|
||||||
|
name: '' as string,
|
||||||
|
reference: '' as string,
|
||||||
|
constructeurIds: [] as string[],
|
||||||
|
supplierPrice: '' as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
|
customFieldInputs.value.every((field) => {
|
||||||
|
if (!field.required) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
return field.value === 'true' || field.value === 'false'
|
||||||
|
}
|
||||||
|
return field.value.trim().length > 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||||
|
|
||||||
|
const documentIcon = (doc: any) =>
|
||||||
|
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||||
|
|
||||||
|
const formatSize = (size: number | null | undefined) => {
|
||||||
|
if (size === null || size === undefined) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
if (size === 0) {
|
||||||
|
return '0 B'
|
||||||
|
}
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||||
|
const formatted = size / Math.pow(1024, index)
|
||||||
|
return `${formatted.toFixed(1)} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
const shouldInlinePdf = (document: any) => {
|
||||||
|
if (!document || !isPdfDocument(document) || !document.path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendPdfViewerParams = (src: string) => {
|
||||||
|
if (!src || src.startsWith('data:')) {
|
||||||
|
return src || ''
|
||||||
|
}
|
||||||
|
if (src.includes('#')) {
|
||||||
|
return `${src}&toolbar=0&navpanes=0`
|
||||||
|
}
|
||||||
|
return `${src}#toolbar=0&navpanes=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentPreviewSrc = (document: any) => {
|
||||||
|
if (!document?.path) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (isPdfDocument(document)) {
|
||||||
|
return appendPdfViewerParams(document.path)
|
||||||
|
}
|
||||||
|
return document.path
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentThumbnailClass = (document: any) => {
|
||||||
|
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||||
|
return 'h-24 w-20'
|
||||||
|
}
|
||||||
|
return 'h-16 w-16'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPreview = (doc: any) => {
|
||||||
|
if (!doc || !canPreviewDocument(doc)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewDocument.value = doc
|
||||||
|
previewVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePreview = () => {
|
||||||
|
previewVisible.value = false
|
||||||
|
previewDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadDocument = (doc: any) => {
|
||||||
|
if (!doc?.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = String(doc.path)
|
||||||
|
if (target.startsWith('data:')) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = target
|
||||||
|
link.download = doc.filename || doc.name || 'document'
|
||||||
|
link.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(target, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProduct = async () => {
|
||||||
|
const id = route.params.id
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
product.value = null
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await getProduct(id)
|
||||||
|
if (result.success) {
|
||||||
|
product.value = result.data
|
||||||
|
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
await loadProductType()
|
||||||
|
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
||||||
|
if (customValues.success && Array.isArray(customValues.data)) {
|
||||||
|
product.value.customFieldValues = customValues.data
|
||||||
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
|
}
|
||||||
|
await hydrateForm()
|
||||||
|
await refreshDocuments()
|
||||||
|
await loadHistory(result.data.id)
|
||||||
|
} else {
|
||||||
|
product.value = null
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshDocuments = async () => {
|
||||||
|
if (!product.value?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingDocuments.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
|
||||||
|
if (result.success) {
|
||||||
|
productDocuments.value = Array.isArray(result.data) ? result.data : []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingDocuments.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||||
|
if (!documentId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await deleteProductDocument(documentId, { updateStore: false })
|
||||||
|
if (result.success) {
|
||||||
|
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
|
||||||
|
toast.showSuccess('Document supprimé')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilesAdded = async (files: File[]) => {
|
||||||
|
if (!files?.length || !product.value?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadingDocuments.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadProductDocuments(
|
||||||
|
{
|
||||||
|
files,
|
||||||
|
context: { productId: product.value.id },
|
||||||
|
},
|
||||||
|
{ updateStore: false },
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
selectedFiles.value = []
|
||||||
|
await refreshDocuments()
|
||||||
|
toast.showSuccess('Document(s) ajouté(s)')
|
||||||
|
} else if (result.error) {
|
||||||
|
toast.showError(result.error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadingDocuments.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProductType = async () => {
|
||||||
|
if (!product.value?.typeProductId) {
|
||||||
|
productType.value = product.value?.typeProduct ?? null
|
||||||
|
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const type = await getModelType(product.value.typeProductId)
|
||||||
|
productType.value = type
|
||||||
|
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement du type de produit:', error)
|
||||||
|
productType.value = product.value?.typeProduct ?? null
|
||||||
|
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateForm = async () => {
|
||||||
|
if (!product.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editionForm.name = product.value.name || ''
|
||||||
|
editionForm.reference = product.value.reference || ''
|
||||||
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
|
product.value,
|
||||||
|
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [],
|
||||||
|
)
|
||||||
|
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
||||||
|
? String(product.value.supplierPrice)
|
||||||
|
: ''
|
||||||
|
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||||
|
if (editionForm.constructeurIds.length) {
|
||||||
|
await ensureConstructeurs(editionForm.constructeurIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => product.value?.documents,
|
||||||
|
(docs) => {
|
||||||
|
if (Array.isArray(docs)) {
|
||||||
|
productDocuments.value = docs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||||
|
field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
|
||||||
|
|
||||||
|
const buildCustomFieldInputs = (
|
||||||
|
productStructure: ProductModelStructure | null,
|
||||||
|
values: any[] | null | undefined,
|
||||||
|
): CustomFieldInput[] => {
|
||||||
|
if (!productStructure || typeof productStructure !== 'object') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const definitions = Array.isArray(productStructure.customFields) ? productStructure.customFields : []
|
||||||
|
const valueList = Array.isArray(values) ? values : []
|
||||||
|
|
||||||
|
const byId = new Map<string, any>()
|
||||||
|
const byName = new Map<string, any>()
|
||||||
|
|
||||||
|
valueList.forEach((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||||
|
if (fieldId) {
|
||||||
|
byId.set(fieldId, entry)
|
||||||
|
}
|
||||||
|
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||||
|
if (fieldName) {
|
||||||
|
byName.set(fieldName, entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return definitions
|
||||||
|
.map((definition, index) => {
|
||||||
|
const definitionId = definition.customFieldId || definition.id || null
|
||||||
|
const matched = (definitionId ? byId.get(definitionId) : null) || byName.get(definition.name)
|
||||||
|
const type = typeof definition.type === 'string' ? definition.type : 'text'
|
||||||
|
const options = Array.isArray(definition.options) ? definition.options : []
|
||||||
|
const required = !!definition.required
|
||||||
|
const orderIndex = typeof definition.orderIndex === 'number' ? definition.orderIndex : index
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
id: definition.id ?? null,
|
||||||
|
name: definition.name,
|
||||||
|
type,
|
||||||
|
required,
|
||||||
|
options,
|
||||||
|
value: '',
|
||||||
|
customFieldId: definition.customFieldId || definition.id || null,
|
||||||
|
customFieldValueId: null,
|
||||||
|
orderIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedValue = matched.value ?? ''
|
||||||
|
return {
|
||||||
|
id: definition.id ?? null,
|
||||||
|
name: definition.name,
|
||||||
|
type,
|
||||||
|
required,
|
||||||
|
options,
|
||||||
|
value: formatDefaultValue(type, resolvedValue),
|
||||||
|
customFieldId: matched.customField?.id || definition.customFieldId || definition.id || null,
|
||||||
|
customFieldValueId: matched.id ?? null,
|
||||||
|
orderIndex,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((field): field is CustomFieldInput => !!field?.name)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDefaultValue = (type: string, value: any): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return String(value === true || String(value).toLowerCase() === 'true')
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitEdition = async () => {
|
||||||
|
if (!product.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||||
|
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
name: editionForm.name.trim(),
|
||||||
|
reference: editionForm.reference.trim() || null,
|
||||||
|
constructeurIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
||||||
|
? editionForm.supplierPrice.trim()
|
||||||
|
: editionForm.supplierPrice
|
||||||
|
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
|
||||||
|
? Number.isNaN(Number(rawPrice))
|
||||||
|
? null
|
||||||
|
: String(Number(rawPrice))
|
||||||
|
: null
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const result = await updateProduct(product.value.id, payload)
|
||||||
|
if (result.success && result.data?.id) {
|
||||||
|
product.value = result.data
|
||||||
|
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||||
|
if (failedFields.length) {
|
||||||
|
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.showSuccess('Produit mis à jour avec succès')
|
||||||
|
await router.push('/product-catalog')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCustomFieldValues = async (productId: string) => {
|
||||||
|
const failed: string[] = []
|
||||||
|
for (const field of customFieldInputs.value) {
|
||||||
|
const value = field.value ?? ''
|
||||||
|
if (field.customFieldValueId) {
|
||||||
|
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||||
|
if (!result.success) {
|
||||||
|
failed.push(field.name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = field.customFieldId
|
||||||
|
? undefined
|
||||||
|
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||||
|
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'product',
|
||||||
|
productId,
|
||||||
|
String(value ?? ''),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
failed.push(field.name)
|
||||||
|
} else {
|
||||||
|
const createdValue = result.data
|
||||||
|
if (createdValue?.id) {
|
||||||
|
field.customFieldValueId = createdValue.id
|
||||||
|
}
|
||||||
|
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||||
|
if (resolvedId) {
|
||||||
|
field.customFieldId = resolvedId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProduct()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
530
app/pages/product/create.vue
Normal file
530
app/pages/product/create.vue
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
<template>
|
||||||
|
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
||||||
|
Retour au catalogue
|
||||||
|
</NuxtLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Catégorie de produit</span>
|
||||||
|
</label>
|
||||||
|
<SearchSelect
|
||||||
|
v-model="selectedTypeId"
|
||||||
|
:options="productTypeList"
|
||||||
|
:loading="loadingTypes"
|
||||||
|
size="sm"
|
||||||
|
placeholder="Rechercher une catégorie..."
|
||||||
|
empty-text="Aucune catégorie disponible"
|
||||||
|
:option-label="typeOptionLabel"
|
||||||
|
:option-description="typeOptionDescription"
|
||||||
|
:disabled="loadingTypes || submitting"
|
||||||
|
/>
|
||||||
|
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||||
|
Chargement des catégories…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Nom du produit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseurs</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="creationForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.supplierPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
|
||||||
|
Cette catégorie ne définit pas encore de champs personnalisés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Renseignez les valeurs propres à ce produit catalogue.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in customFieldInputs"
|
||||||
|
:key="fieldKey(field, index)"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">{{ field.name }}</span>
|
||||||
|
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
v-model="field.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="field.value"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="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"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option
|
||||||
|
v-for="option in field.options"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="field.value"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
true-value="true"
|
||||||
|
false-value="false"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="field.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:required="field.required"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||||
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedDocuments"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
|
Annuler
|
||||||
|
</NuxtLink>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
|
||||||
|
Créer le produit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from '#imports'
|
||||||
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||||
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
|
interface ProductCatalogType extends ModelType {
|
||||||
|
structure: ProductModelStructure | null
|
||||||
|
customFields?: Array<Record<string, any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||||
|
const { createProduct } = useProducts()
|
||||||
|
const toast = useToast()
|
||||||
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
|
||||||
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const creationForm = reactive({
|
||||||
|
name: '' as string,
|
||||||
|
reference: '' as string,
|
||||||
|
constructeurIds: [] as string[],
|
||||||
|
supplierPrice: '' as string,
|
||||||
|
})
|
||||||
|
const selectedDocuments = ref<File[]>([])
|
||||||
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
|
interface CustomFieldInput {
|
||||||
|
id: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
value: string
|
||||||
|
customFieldId: string | null
|
||||||
|
orderIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
|
|
||||||
|
const loadingTypes = computed(() => loadingProductTypes.value)
|
||||||
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||||
|
(productTypes.value || []) as ProductCatalogType[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeOptionLabel = (type?: ProductCatalogType) => type?.name || 'Catégorie'
|
||||||
|
const typeOptionDescription = (type?: ProductCatalogType) =>
|
||||||
|
type?.description ? String(type.description) : ''
|
||||||
|
|
||||||
|
const selectedType = computed(() => {
|
||||||
|
if (!selectedTypeId.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.typeId,
|
||||||
|
(value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
selectedTypeId.value = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(selectedTypeId, (id) => {
|
||||||
|
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
|
||||||
|
if ((id || '') === current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
if (id) {
|
||||||
|
nextQuery.typeId = id
|
||||||
|
} else {
|
||||||
|
delete nextQuery.typeId
|
||||||
|
}
|
||||||
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedType, (type) => {
|
||||||
|
if (!type) {
|
||||||
|
clearForm()
|
||||||
|
customFieldInputs.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!creationForm.name) {
|
||||||
|
creationForm.name = type.name
|
||||||
|
}
|
||||||
|
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure))
|
||||||
|
})
|
||||||
|
|
||||||
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
|
customFieldInputs.value.every((field) => {
|
||||||
|
if (!field.required) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
return field.value === 'true' || field.value === 'false'
|
||||||
|
}
|
||||||
|
return field.value.trim().length > 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmit = computed(() => Boolean(
|
||||||
|
selectedType.value &&
|
||||||
|
creationForm.name.trim().length >= 2 &&
|
||||||
|
requiredCustomFieldsFilled.value &&
|
||||||
|
!submitting.value,
|
||||||
|
))
|
||||||
|
|
||||||
|
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||||
|
field.customFieldId || field.id || `${field.name}-${index}`
|
||||||
|
|
||||||
|
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
|
||||||
|
if (!structure || typeof structure !== 'object') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||||
|
return fields
|
||||||
|
.map((field, index) => normalizeCustomField(field, index))
|
||||||
|
.filter((field): field is CustomFieldInput => field !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||||
|
if (!rawField || typeof rawField !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
|
||||||
|
const required = !!rawField.required
|
||||||
|
const options = Array.isArray(rawField.options)
|
||||||
|
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
|
||||||
|
: []
|
||||||
|
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
|
||||||
|
const value = formatDefaultValue(type, defaultSource)
|
||||||
|
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||||
|
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||||
|
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||||
|
|
||||||
|
return { id, name, type, required, options, value, customFieldId, orderIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||||
|
if (defaultValue === null || defaultValue === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
|
||||||
|
}
|
||||||
|
return String(defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearForm = () => {
|
||||||
|
creationForm.name = ''
|
||||||
|
creationForm.reference = ''
|
||||||
|
creationForm.constructeurIds = []
|
||||||
|
creationForm.supplierPrice = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = () => {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
name: creationForm.name.trim(),
|
||||||
|
typeProductId: selectedType.value?.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = creationForm.reference.trim()
|
||||||
|
if (reference) {
|
||||||
|
payload.reference = reference
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
|
|
||||||
|
const rawPrice = typeof creationForm.supplierPrice === 'string'
|
||||||
|
? creationForm.supplierPrice.trim()
|
||||||
|
: creationForm.supplierPrice
|
||||||
|
if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) {
|
||||||
|
const parsed = Number(rawPrice)
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
payload.supplierPrice = String(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreation = async () => {
|
||||||
|
if (!selectedType.value) {
|
||||||
|
toast.showError('Sélectionnez une catégorie de produit.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = buildPayload()
|
||||||
|
const result = await createProduct(payload)
|
||||||
|
if (result.success && result.data?.id) {
|
||||||
|
const productId = result.data.id
|
||||||
|
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||||
|
if (failedFields.length) {
|
||||||
|
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
|
await router.push(`/product/${result.data.id}/edit`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedDocuments.value.length) {
|
||||||
|
uploadingDocuments.value = true
|
||||||
|
const uploadResult = await uploadDocuments(
|
||||||
|
{
|
||||||
|
files: selectedDocuments.value,
|
||||||
|
context: { productId },
|
||||||
|
},
|
||||||
|
{ updateStore: false },
|
||||||
|
)
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
const message = uploadResult.error
|
||||||
|
? `Documents non ajoutés : ${uploadResult.error}`
|
||||||
|
: 'Documents non ajoutés : une erreur est survenue.'
|
||||||
|
toast.showError(message)
|
||||||
|
} else {
|
||||||
|
selectedDocuments.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.showSuccess('Produit créé avec succès')
|
||||||
|
await router.push('/product-catalog')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
uploadingDocuments.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCustomFieldValues = async (productId: string) => {
|
||||||
|
const failed: string[] = []
|
||||||
|
for (const field of customFieldInputs.value) {
|
||||||
|
if (!field.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const value = field.value ?? ''
|
||||||
|
const metadata = field.customFieldId
|
||||||
|
? undefined
|
||||||
|
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'product',
|
||||||
|
productId,
|
||||||
|
String(value ?? ''),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
failed.push(field.name)
|
||||||
|
} else {
|
||||||
|
const createdValue = result.data
|
||||||
|
if (createdValue?.id) {
|
||||||
|
field.customFieldValueId = createdValue.id
|
||||||
|
}
|
||||||
|
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||||
|
if (resolvedId) {
|
||||||
|
field.customFieldId = resolvedId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProductTypes()
|
||||||
|
if (selectedTypeId.value && !selectedType.value) {
|
||||||
|
await router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: { ...route.query, typeId: undefined },
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -93,6 +93,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Produits requis -->
|
||||||
|
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Produits requis
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="requirement in type.productRequirements"
|
||||||
|
:key="requirement.id || requirement.typeProductId"
|
||||||
|
class="border border-base-200 rounded-lg p-4 bg-base-100"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
|
||||||
|
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
|
|||||||
|
|
||||||
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
|
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
|
||||||
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
|
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
|
||||||
|
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
|
||||||
|
|
||||||
const toDisplayCount = (value, fallback) => {
|
const toDisplayCount = (value, fallback) => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -154,6 +187,14 @@ onMounted(async () => {
|
|||||||
const typeId = route.params.id
|
const typeId = route.params.id
|
||||||
console.log('=== TYPE DETAIL PAGE LOADING ===')
|
console.log('=== TYPE DETAIL PAGE LOADING ===')
|
||||||
console.log('Loading type with ID:', typeId)
|
console.log('Loading type with ID:', typeId)
|
||||||
|
console.log('Full route params:', route.params)
|
||||||
|
|
||||||
|
if (!typeId) {
|
||||||
|
console.error('No type ID provided in route')
|
||||||
|
showError('Aucun identifiant de type fourni')
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getMachineTypeById(typeId)
|
const result = await getMachineTypeById(typeId)
|
||||||
console.log('API Result:', result)
|
console.log('API Result:', result)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -70,7 +71,8 @@ const editedType = ref({
|
|||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
componentRequirements: [],
|
componentRequirements: [],
|
||||||
pieceRequirements: []
|
pieceRequirements: [],
|
||||||
|
productRequirements: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const parseOptions = (field = {}) => {
|
const parseOptions = (field = {}) => {
|
||||||
@@ -89,15 +91,32 @@ const parseOptions = (field = {}) => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toModelTypeIri = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const relationId = extractRelationId(value)
|
||||||
|
if (relationId) {
|
||||||
|
return `/api/model_types/${relationId}`
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeCustomFields = (fields = []) =>
|
const normalizeCustomFields = (fields = []) =>
|
||||||
fields
|
fields
|
||||||
.filter(field => field?.name && field.name.trim() !== '')
|
.filter(field => field?.name && field.name.trim() !== '')
|
||||||
.map(field => ({
|
.map((field, index) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type || '',
|
type: field.type || '',
|
||||||
required: !!field.required,
|
required: !!field.required,
|
||||||
options: parseOptions(field)
|
options: parseOptions(field),
|
||||||
|
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
|
||||||
}))
|
}))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||||
|
|
||||||
const toIntegerOrNull = (value, fallback = null) => {
|
const toIntegerOrNull = (value, fallback = null) => {
|
||||||
if (value === '' || value === undefined || value === null) {
|
if (value === '' || value === undefined || value === null) {
|
||||||
@@ -109,9 +128,9 @@ const toIntegerOrNull = (value, fallback = null) => {
|
|||||||
|
|
||||||
const normalizeComponentRequirements = (requirements = []) =>
|
const normalizeComponentRequirements = (requirements = []) =>
|
||||||
requirements
|
requirements
|
||||||
.filter(req => req?.typeComposantId)
|
.filter(req => req?.typeComposantId || req?.typeComposant)
|
||||||
.map((req, index) => ({
|
.map((req, index) => ({
|
||||||
typeComposantId: req.typeComposantId,
|
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
minCount: toIntegerOrNull(req.minCount, 1),
|
minCount: toIntegerOrNull(req.minCount, 1),
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
@@ -124,9 +143,24 @@ const normalizeComponentRequirements = (requirements = []) =>
|
|||||||
|
|
||||||
const normalizePieceRequirements = (requirements = []) =>
|
const normalizePieceRequirements = (requirements = []) =>
|
||||||
requirements
|
requirements
|
||||||
.filter(req => req?.typePieceId)
|
.filter(req => req?.typePieceId || req?.typePiece)
|
||||||
.map((req, index) => ({
|
.map((req, index) => ({
|
||||||
typePieceId: req.typePieceId,
|
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
||||||
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
|
required: req.required ?? false,
|
||||||
|
allowNewModels: req.allowNewModels ?? true,
|
||||||
|
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
||||||
|
|
||||||
|
const normalizeProductRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typeProductId || req?.typeProduct)
|
||||||
|
.map((req, index) => ({
|
||||||
|
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
||||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||||
minCount: toIntegerOrNull(req.minCount, 0),
|
minCount: toIntegerOrNull(req.minCount, 0),
|
||||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||||
@@ -148,7 +182,8 @@ const saveChanges = async () => {
|
|||||||
...currentEditedType,
|
...currentEditedType,
|
||||||
customFields: normalizeCustomFields(currentEditedType.customFields),
|
customFields: normalizeCustomFields(currentEditedType.customFields),
|
||||||
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
|
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
|
||||||
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements)
|
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
|
||||||
|
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateMachineType(type.value.id, updatedType)
|
const result = await updateMachineType(type.value.id, updatedType)
|
||||||
@@ -174,7 +209,7 @@ onMounted(async () => {
|
|||||||
console.log('=== EDIT TYPE PAGE LOADING ===')
|
console.log('=== EDIT TYPE PAGE LOADING ===')
|
||||||
console.log('Loading type with ID:', typeId)
|
console.log('Loading type with ID:', typeId)
|
||||||
|
|
||||||
const result = await getMachineTypeById(typeId)
|
const result = await getMachineTypeById(typeId, true)
|
||||||
console.log('API Result:', result)
|
console.log('API Result:', result)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -189,7 +224,8 @@ onMounted(async () => {
|
|||||||
maintenanceFrequency: type.value.maintenanceFrequency || '',
|
maintenanceFrequency: type.value.maintenanceFrequency || '',
|
||||||
customFields: type.value.customFields || [],
|
customFields: type.value.customFields || [],
|
||||||
componentRequirements: type.value.componentRequirements || [],
|
componentRequirements: type.value.componentRequirements || [],
|
||||||
pieceRequirements: type.value.pieceRequirements || []
|
pieceRequirements: type.value.pieceRequirements || [],
|
||||||
|
productRequirements: type.value.productRequirements || [],
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to load type:', result.error)
|
console.error('Failed to load type:', result.error)
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
|
|||||||
import type {
|
import type {
|
||||||
ComponentModelStructure,
|
ComponentModelStructure,
|
||||||
PieceModelStructure,
|
PieceModelStructure,
|
||||||
|
ProductModelStructure,
|
||||||
} from '~/shared/types/inventory';
|
} from '~/shared/types/inventory';
|
||||||
|
|
||||||
export type ModelCategory = 'COMPONENT' | 'PIECE';
|
export type ModelCategory = 'COMPONENT' | 'PIECE' | 'PRODUCT';
|
||||||
|
|
||||||
export type ModelTypeStructure = ComponentModelStructure | PieceModelStructure | null;
|
export type ModelTypeStructure =
|
||||||
|
| ComponentModelStructure
|
||||||
|
| PieceModelStructure
|
||||||
|
| ProductModelStructure
|
||||||
|
| null;
|
||||||
|
|
||||||
export interface BaseModelTypePayload {
|
export interface BaseModelTypePayload {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
|
|||||||
structure?: PieceModelStructure | null;
|
structure?: PieceModelStructure | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelTypePayload = ComponentModelTypePayload | PieceModelTypePayload;
|
export interface ProductModelTypePayload extends BaseModelTypePayload {
|
||||||
|
category: 'PRODUCT';
|
||||||
|
structure?: ProductModelStructure | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelTypePayload =
|
||||||
|
| ComponentModelTypePayload
|
||||||
|
| PieceModelTypePayload
|
||||||
|
| ProductModelTypePayload;
|
||||||
|
|
||||||
export interface ModelType extends BaseModelTypePayload {
|
export interface ModelType extends BaseModelTypePayload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +47,9 @@ export interface ModelType extends BaseModelTypePayload {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
category: ModelCategory;
|
category: ModelCategory;
|
||||||
structure: ModelTypeStructure;
|
structure: ModelTypeStructure;
|
||||||
|
componentSkeleton?: ComponentModelStructure | null;
|
||||||
|
pieceSkeleton?: PieceModelStructure | null;
|
||||||
|
productSkeleton?: ProductModelStructure | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelTypeListParams {
|
export interface ModelTypeListParams {
|
||||||
@@ -52,7 +68,7 @@ export interface ModelTypeListResponse {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENDPOINT = '/api/model-types';
|
const ENDPOINT = '/model_types';
|
||||||
|
|
||||||
function resolveBaseUrl() {
|
function resolveBaseUrl() {
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
@@ -67,12 +83,52 @@ function createOptions<T>(options: FetchOptions<T> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
|
const normalizeModelType = (item: any): ModelType => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return item as ModelType;
|
||||||
|
}
|
||||||
|
if (!item.structure) {
|
||||||
|
if (item.category === 'COMPONENT' && item.componentSkeleton) {
|
||||||
|
item.structure = item.componentSkeleton;
|
||||||
|
} else if (item.category === 'PIECE' && item.pieceSkeleton) {
|
||||||
|
item.structure = item.pieceSkeleton;
|
||||||
|
} else if (item.category === 'PRODUCT' && item.productSkeleton) {
|
||||||
|
item.structure = item.productSkeleton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item as ModelType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStructureToSkeleton = <T extends Record<string, any>>(payload: T): T => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (!('structure' in payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const structure = (payload as any).structure;
|
||||||
|
if (!structure) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const category = (payload as any).category;
|
||||||
|
const next = { ...payload } as Record<string, any>;
|
||||||
|
if (category === 'COMPONENT') {
|
||||||
|
next.componentSkeleton = structure;
|
||||||
|
} else if (category === 'PIECE') {
|
||||||
|
next.pieceSkeleton = structure;
|
||||||
|
} else if (category === 'PRODUCT') {
|
||||||
|
next.productSkeleton = structure;
|
||||||
|
}
|
||||||
|
delete next.structure;
|
||||||
|
return next as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
|
||||||
const requestFetch = useRequestFetch();
|
const requestFetch = useRequestFetch();
|
||||||
const query: Record<string, string | number> = {};
|
const query: Record<string, string | number> = {};
|
||||||
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
query.q = params.q;
|
query.name = params.q;
|
||||||
}
|
}
|
||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = params.category;
|
query.category = params.category;
|
||||||
@@ -83,36 +139,84 @@ export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?
|
|||||||
if (params.dir) {
|
if (params.dir) {
|
||||||
query.dir = params.dir;
|
query.dir = params.dir;
|
||||||
}
|
}
|
||||||
if (typeof params.limit === 'number') {
|
const hasCategoryFilter = Boolean(params.category);
|
||||||
query.limit = params.limit;
|
const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
|
||||||
}
|
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
||||||
if (typeof params.offset === 'number') {
|
|
||||||
query.offset = params.offset;
|
if (hasCategoryFilter) {
|
||||||
|
// Fetch enough items to allow client-side category filtering + pagination.
|
||||||
|
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
|
||||||
|
query.offset = 0;
|
||||||
|
} else {
|
||||||
|
if (typeof params.limit === 'number') {
|
||||||
|
query.itemsPerPage = params.limit;
|
||||||
|
}
|
||||||
|
if (typeof params.offset === 'number') {
|
||||||
|
query.offset = params.offset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestFetch<ModelTypeListResponse>(ENDPOINT, createOptions({
|
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
query,
|
query,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const rawItems = Array.isArray(payload?.member)
|
||||||
|
? payload.member
|
||||||
|
: Array.isArray(payload?.['hydra:member'])
|
||||||
|
? payload['hydra:member']
|
||||||
|
: Array.isArray(payload?.items)
|
||||||
|
? payload.items
|
||||||
|
: [];
|
||||||
|
const filteredItems = params.category
|
||||||
|
? rawItems.filter((item: any) => item?.category === params.category)
|
||||||
|
: rawItems;
|
||||||
|
const total = params.category
|
||||||
|
? filteredItems.length
|
||||||
|
: typeof payload?.totalItems === 'number'
|
||||||
|
? payload.totalItems
|
||||||
|
: Array.isArray(payload?.items)
|
||||||
|
? payload.items.length
|
||||||
|
: rawItems.length;
|
||||||
|
const items = (params.category && typeof effectiveLimit === 'number'
|
||||||
|
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
|
||||||
|
: filteredItems).map(normalizeModelType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
offset: effectiveOffset,
|
||||||
|
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
|
||||||
|
} satisfies ModelTypeListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
|
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
|
||||||
const requestFetch = useRequestFetch();
|
const requestFetch = useRequestFetch();
|
||||||
|
const mappedPayload = mapStructureToSkeleton(payload);
|
||||||
return requestFetch<ModelType>(ENDPOINT, createOptions({
|
return requestFetch<ModelType>(ENDPOINT, createOptions({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: payload,
|
headers: {
|
||||||
|
'Content-Type': 'application/ld+json',
|
||||||
|
Accept: 'application/ld+json',
|
||||||
|
},
|
||||||
|
body: mappedPayload,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
}));
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
|
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
|
||||||
const requestFetch = useRequestFetch();
|
const requestFetch = useRequestFetch();
|
||||||
|
const mappedPayload = mapStructureToSkeleton(payload);
|
||||||
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: payload,
|
headers: {
|
||||||
|
'Content-Type': 'application/merge-patch+json',
|
||||||
|
Accept: 'application/ld+json',
|
||||||
|
},
|
||||||
|
body: mappedPayload,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
}));
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
@@ -128,5 +232,5 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
|||||||
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
}));
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|||||||
57
app/shared/apiRelations.ts
Normal file
57
app/shared/apiRelations.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export const RELATION_ID_MAP: Record<string, { key: string; path: string }> = {
|
||||||
|
siteId: { key: 'site', path: 'sites' },
|
||||||
|
machineId: { key: 'machine', path: 'machines' },
|
||||||
|
composantId: { key: 'composant', path: 'composants' },
|
||||||
|
pieceId: { key: 'piece', path: 'pieces' },
|
||||||
|
productId: { key: 'product', path: 'products' },
|
||||||
|
typeMachineId: { key: 'typeMachine', path: 'type_machines' },
|
||||||
|
typeComposantId: { key: 'typeComposant', path: 'model_types' },
|
||||||
|
typePieceId: { key: 'typePiece', path: 'model_types' },
|
||||||
|
typeProductId: { key: 'typeProduct', path: 'model_types' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toIri = (path: string, id: string): string => `/api/${path}/${id}`;
|
||||||
|
|
||||||
|
export const extractRelationId = (value: unknown): string | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed.includes('/')) {
|
||||||
|
const parts = trimmed.split('/').filter(Boolean);
|
||||||
|
return parts.length ? parts[parts.length - 1] : null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && 'id' in (value as Record<string, any>)) {
|
||||||
|
const id = (value as Record<string, any>).id;
|
||||||
|
return typeof id === 'string' ? id : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeRelationIds = <T extends Record<string, any>>(payload: T): T => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Record<string, any> = { ...payload };
|
||||||
|
Object.entries(RELATION_ID_MAP).forEach(([sourceKey, config]) => {
|
||||||
|
const raw = next[sourceKey];
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next[config.key] = toIri(config.path, trimmed);
|
||||||
|
delete next[sourceKey];
|
||||||
|
});
|
||||||
|
|
||||||
|
return next as T;
|
||||||
|
};
|
||||||
164
app/shared/constructeurUtils.ts
Normal file
164
app/shared/constructeurUtils.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { formatPhone } from '~/utils/formatters/phone';
|
||||||
|
|
||||||
|
export interface ConstructeurSummary {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const toStringId = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (trimmed.includes('/')) {
|
||||||
|
const parts = trimmed.split('/').filter(Boolean);
|
||||||
|
return parts.length ? parts[parts.length - 1] : null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uniqueConstructeurIds = (...sources: unknown[]): string[] => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
const pushId = (value: unknown) => {
|
||||||
|
const id = toStringId(value);
|
||||||
|
if (id) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const explore = (value: unknown): void => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(explore);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
pushId(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isObject(value)) {
|
||||||
|
if (Array.isArray(value.constructeurIds)) {
|
||||||
|
value.constructeurIds.forEach(pushId);
|
||||||
|
}
|
||||||
|
if (value.constructeurId) {
|
||||||
|
pushId(value.constructeurId);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value.constructeurs)) {
|
||||||
|
value.constructeurs.forEach(explore);
|
||||||
|
}
|
||||||
|
if (value.constructeur) {
|
||||||
|
explore(value.constructeur);
|
||||||
|
}
|
||||||
|
// Only extract ID if this looks like a constructeur object (has @type or recognizable fields)
|
||||||
|
// Don't extract ID from component/piece/product objects that happen to be passed in
|
||||||
|
if (typeof value.id === 'string' && !value.name && !value.typeComposant && !value.typePiece && !value.typeProduct) {
|
||||||
|
pushId(value.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sources.forEach(explore);
|
||||||
|
return Array.from(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveConstructeurs = (
|
||||||
|
ids: string[],
|
||||||
|
...candidatePools: Array<ConstructeurSummary[] | null | undefined>
|
||||||
|
): ConstructeurSummary[] => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = new Map<string, ConstructeurSummary>();
|
||||||
|
const register = (pool?: ConstructeurSummary[] | null) => {
|
||||||
|
if (!Array.isArray(pool)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pool.forEach((entry) => {
|
||||||
|
if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
|
||||||
|
index.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
candidatePools.forEach(register);
|
||||||
|
|
||||||
|
return ids
|
||||||
|
.map((id) => index.get(id))
|
||||||
|
.filter((item): item is ConstructeurSummary => Boolean(item))
|
||||||
|
.map((item) => ({ ...item }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatConstructeurContact = (
|
||||||
|
constructeur?: ConstructeurSummary | null,
|
||||||
|
): string => {
|
||||||
|
if (!constructeur) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedPhone = formatPhone(constructeur.phone);
|
||||||
|
const phone = formattedPhone || constructeur.phone || null;
|
||||||
|
|
||||||
|
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
|
||||||
|
payload: T,
|
||||||
|
): T & { constructeurs?: string[] } => {
|
||||||
|
const collected = new Set(uniqueConstructeurIds(
|
||||||
|
payload?.constructeurIds,
|
||||||
|
payload?.constructeurId,
|
||||||
|
payload?.constructeur,
|
||||||
|
payload?.constructeurs,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!collected.size) {
|
||||||
|
const fallbackLists = [
|
||||||
|
payload?.constructeurIds,
|
||||||
|
payload?.constructeurs,
|
||||||
|
];
|
||||||
|
fallbackLists.forEach((list) => {
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const id = toStringId(item);
|
||||||
|
if (id) {
|
||||||
|
collected.add(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isObject(item) && typeof item.id === 'string') {
|
||||||
|
collected.add(item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.from(collected);
|
||||||
|
|
||||||
|
const next = { ...payload } as Record<string, any>;
|
||||||
|
delete next.constructeurId;
|
||||||
|
delete next.constructeur;
|
||||||
|
delete next.constructeurs;
|
||||||
|
delete next.constructeurIds;
|
||||||
|
|
||||||
|
if (ids.length) {
|
||||||
|
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next as T & { constructeurs?: string[] };
|
||||||
|
};
|
||||||
@@ -3,14 +3,19 @@ import {
|
|||||||
type ComponentModelCustomFieldType,
|
type ComponentModelCustomFieldType,
|
||||||
type ComponentModelCustomField,
|
type ComponentModelCustomField,
|
||||||
type ComponentModelPiece,
|
type ComponentModelPiece,
|
||||||
|
type ComponentModelProduct,
|
||||||
type ComponentModelStructure,
|
type ComponentModelStructure,
|
||||||
type ComponentModelStructureNode,
|
type ComponentModelStructureNode,
|
||||||
type PieceModelCustomField,
|
type PieceModelCustomField,
|
||||||
|
type PieceModelProduct,
|
||||||
type PieceModelStructure,
|
type PieceModelStructure,
|
||||||
type PieceModelStructureEditorField,
|
type PieceModelStructureEditorField,
|
||||||
type PieceModelStructureForEditor,
|
type PieceModelStructureForEditor,
|
||||||
|
type ProductModelStructure,
|
||||||
|
createEmptyProductModelStructure,
|
||||||
createEmptyPieceModelStructure,
|
createEmptyPieceModelStructure,
|
||||||
} from './types/inventory'
|
} from './types/inventory'
|
||||||
|
import { uniqueConstructeurIds } from './constructeurUtils'
|
||||||
|
|
||||||
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
@@ -19,6 +24,7 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
|
|||||||
export interface ModelStructurePreview {
|
export interface ModelStructurePreview {
|
||||||
customFields: number
|
customFields: number
|
||||||
pieces: number
|
pieces: number
|
||||||
|
products: number
|
||||||
subcomponents: number
|
subcomponents: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
|
|||||||
...base,
|
...base,
|
||||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||||
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
|
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
|
||||||
|
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||||
subcomponents: Array.isArray((input as any).subcomponents)
|
subcomponents: Array.isArray((input as any).subcomponents)
|
||||||
? (input as any).subcomponents
|
? (input as any).subcomponents
|
||||||
: Array.isArray((input as any).subComponents)
|
: Array.isArray((input as any).subComponents)
|
||||||
@@ -93,7 +100,7 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
.map((field) => {
|
.map((field, index) => {
|
||||||
const rawName =
|
const rawName =
|
||||||
typeof field?.name === 'string'
|
typeof field?.name === 'string'
|
||||||
? field.name
|
? field.name
|
||||||
@@ -172,6 +179,8 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
|
|||||||
if (customFieldId) {
|
if (customFieldId) {
|
||||||
result.customFieldId = customFieldId
|
result.customFieldId = customFieldId
|
||||||
}
|
}
|
||||||
|
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||||
|
result.orderIndex = orderIndex
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
.filter((field): field is ComponentModelCustomField => !!field)
|
.filter((field): field is ComponentModelCustomField => !!field)
|
||||||
@@ -237,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
|
|||||||
.filter((piece): piece is ComponentModelPiece => !!piece)
|
.filter((piece): piece is ComponentModelPiece => !!piece)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
|
||||||
|
if (!Array.isArray(products)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return products
|
||||||
|
.map((product) => {
|
||||||
|
const rawTypeProductId = typeof product?.typeProductId === 'string'
|
||||||
|
? product.typeProductId.trim()
|
||||||
|
: typeof product?.typeProduct?.id === 'string'
|
||||||
|
? product.typeProduct.id.trim()
|
||||||
|
: ''
|
||||||
|
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
|
||||||
|
|
||||||
|
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
|
||||||
|
? product.typeProductLabel.trim()
|
||||||
|
: typeof product?.typeProduct?.name === 'string'
|
||||||
|
? product.typeProduct.name.trim()
|
||||||
|
: ''
|
||||||
|
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
|
||||||
|
|
||||||
|
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
|
||||||
|
? product.reference.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const rawFamilyCode = typeof product?.familyCode === 'string'
|
||||||
|
? product.familyCode.trim()
|
||||||
|
: typeof product?.typeProduct?.code === 'string'
|
||||||
|
? product.typeProduct.code.trim()
|
||||||
|
: ''
|
||||||
|
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
|
||||||
|
|
||||||
|
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
|
||||||
|
const role = rawRole.length > 0 ? rawRole : undefined
|
||||||
|
|
||||||
|
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ComponentModelProduct = {}
|
||||||
|
if (role) {
|
||||||
|
result.role = role
|
||||||
|
}
|
||||||
|
if (familyCode) {
|
||||||
|
result.familyCode = familyCode
|
||||||
|
}
|
||||||
|
if (reference !== undefined) {
|
||||||
|
result.reference = reference
|
||||||
|
}
|
||||||
|
if (typeProductId) {
|
||||||
|
result.typeProductId = typeProductId
|
||||||
|
}
|
||||||
|
if (typeProductLabel) {
|
||||||
|
result.typeProductLabel = typeProductLabel
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.filter((product): product is ComponentModelProduct => !!product)
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||||
if (!Array.isArray(components)) {
|
if (!Array.isArray(components)) {
|
||||||
return []
|
return []
|
||||||
@@ -328,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
|
|||||||
const result: ComponentModelStructure = {
|
const result: ComponentModelStructure = {
|
||||||
customFields: customFields as ComponentModelCustomField[],
|
customFields: customFields as ComponentModelCustomField[],
|
||||||
pieces: sanitizePieces(source.pieces),
|
pieces: sanitizePieces(source.pieces),
|
||||||
|
products: sanitizeProducts(source.products),
|
||||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
|
|||||||
return payload
|
return payload
|
||||||
}) as any
|
}) as any
|
||||||
|
|
||||||
|
const backendProducts = sanitizeProducts(source.products).map((product) => {
|
||||||
|
const payload: Record<string, any> = {}
|
||||||
|
if ((product as any).familyCode) {
|
||||||
|
payload.familyCode = (product as any).familyCode
|
||||||
|
}
|
||||||
|
if (product.typeProductId) {
|
||||||
|
payload.typeProductId = product.typeProductId
|
||||||
|
}
|
||||||
|
if (product.role) {
|
||||||
|
payload.role = product.role
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}) as any
|
||||||
|
|
||||||
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
|
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
|
||||||
const payload: Record<string, any> = {}
|
const payload: Record<string, any> = {}
|
||||||
if (subcomponent.typeComposantId) {
|
if (subcomponent.typeComposantId) {
|
||||||
@@ -420,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
|
|||||||
const result: ComponentModelStructure = {
|
const result: ComponentModelStructure = {
|
||||||
customFields: backendCustomFields,
|
customFields: backendCustomFields,
|
||||||
pieces: backendPieces,
|
pieces: backendPieces,
|
||||||
|
products: backendProducts,
|
||||||
subcomponents: backendSubcomponents,
|
subcomponents: backendSubcomponents,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +532,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields.map((field) => {
|
return fields.map((field, index) => {
|
||||||
const valueObject = extractFieldValueObject(field)
|
const valueObject = extractFieldValueObject(field)
|
||||||
const name = typeof field?.name === 'string'
|
const name = typeof field?.name === 'string'
|
||||||
? field.name
|
? field.name
|
||||||
@@ -512,6 +597,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
|
|||||||
|
|
||||||
const id = typeof field?.id === 'string' ? field.id : undefined
|
const id = typeof field?.id === 'string' ? field.id : undefined
|
||||||
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
|
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
|
||||||
|
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -522,6 +608,7 @@ const hydrateCustomFields = (fields: any[]): any[] => {
|
|||||||
defaultValue,
|
defaultValue,
|
||||||
id,
|
id,
|
||||||
customFieldId,
|
customFieldId,
|
||||||
|
orderIndex,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -540,6 +627,20 @@ const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
|
||||||
|
if (!Array.isArray(products)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return products.map((product) => ({
|
||||||
|
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
|
||||||
|
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
|
||||||
|
reference: product?.reference ?? '',
|
||||||
|
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
|
||||||
|
role: product?.role ?? '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||||
if (!Array.isArray(components)) {
|
if (!Array.isArray(components)) {
|
||||||
return []
|
return []
|
||||||
@@ -564,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
|
|||||||
return {
|
return {
|
||||||
customFields: hydrateCustomFields(source.customFields),
|
customFields: hydrateCustomFields(source.customFields),
|
||||||
pieces: hydratePieces(source.pieces),
|
pieces: hydratePieces(source.pieces),
|
||||||
|
products: hydrateProducts(source.products),
|
||||||
subcomponents: hydrateSubcomponents(
|
subcomponents: hydrateSubcomponents(
|
||||||
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
|
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
|
||||||
),
|
),
|
||||||
@@ -579,7 +681,7 @@ const mapComponentCustomFields = (fields: any[]) => {
|
|||||||
if (!Array.isArray(fields)) {
|
if (!Array.isArray(fields)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return hydrateCustomFields(fields).map((field) => {
|
return hydrateCustomFields(fields).map((field, index) => {
|
||||||
const defaultValue =
|
const defaultValue =
|
||||||
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
|
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
|
||||||
? field.defaultValue
|
? field.defaultValue
|
||||||
@@ -596,6 +698,7 @@ const mapComponentCustomFields = (fields: any[]) => {
|
|||||||
typeof (field as any)?.customFieldId === 'string'
|
typeof (field as any)?.customFieldId === 'string'
|
||||||
? (field as any).customFieldId
|
? (field as any).customFieldId
|
||||||
: undefined,
|
: undefined,
|
||||||
|
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -613,6 +716,19 @@ const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
|
||||||
|
if (!Array.isArray(products)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return products.map((product) => ({
|
||||||
|
reference: product?.reference ?? '',
|
||||||
|
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
|
||||||
|
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
|
||||||
|
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
|
||||||
|
role: product?.role ?? '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
|
||||||
if (!Array.isArray(components)) {
|
if (!Array.isArray(components)) {
|
||||||
return []
|
return []
|
||||||
@@ -639,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
|
|||||||
const raw = {
|
const raw = {
|
||||||
customFields: mapComponentCustomFields(component.customFields),
|
customFields: mapComponentCustomFields(component.customFields),
|
||||||
pieces: mapComponentPieces(component.pieces),
|
pieces: mapComponentPieces(component.pieces),
|
||||||
|
products: mapComponentProducts(component.products),
|
||||||
subcomponents: mapSubcomponents(
|
subcomponents: mapSubcomponents(
|
||||||
Array.isArray(component?.subcomponents)
|
Array.isArray(component?.subcomponents)
|
||||||
? component.subcomponents
|
? component.subcomponents
|
||||||
@@ -656,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
|
|||||||
|
|
||||||
export const computeStructureStats = (structure: any): ModelStructurePreview => {
|
export const computeStructureStats = (structure: any): ModelStructurePreview => {
|
||||||
if (!structure || typeof structure !== 'object') {
|
if (!structure || typeof structure !== 'object') {
|
||||||
return { customFields: 0, pieces: 0, subcomponents: 0 }
|
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
|
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
|
||||||
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
|
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
|
||||||
|
products: Array.isArray(structure.products) ? structure.products.length : 0,
|
||||||
subcomponents: Array.isArray(structure.subcomponents)
|
subcomponents: Array.isArray(structure.subcomponents)
|
||||||
? structure.subcomponents.length
|
? structure.subcomponents.length
|
||||||
: Array.isArray(structure.subComponents)
|
: Array.isArray(structure.subComponents)
|
||||||
@@ -672,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
|
|||||||
|
|
||||||
export const formatStructurePreview = (structure: any) => {
|
export const formatStructurePreview = (structure: any) => {
|
||||||
const stats = computeStructureStats(structure)
|
const stats = computeStructureStats(structure)
|
||||||
if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
|
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
|
||||||
return 'Structure vide'
|
return 'Structure vide'
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments: string[] = []
|
const segments: string[] = []
|
||||||
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
|
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
|
||||||
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
|
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
|
||||||
|
if (stats.products) segments.push(`${stats.products} produit(s)`)
|
||||||
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
|
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
|
||||||
return segments.join(' • ')
|
return segments.join(' • ')
|
||||||
}
|
}
|
||||||
@@ -686,7 +805,7 @@ export const formatStructurePreview = (structure: any) => {
|
|||||||
export interface DefinitionOverridePayload {
|
export interface DefinitionOverridePayload {
|
||||||
name?: string
|
name?: string
|
||||||
reference?: string
|
reference?: string
|
||||||
constructeurId?: string | null
|
constructeurIds?: string[]
|
||||||
prix?: number
|
prix?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,8 +830,14 @@ export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') {
|
const constructeurIds = uniqueConstructeurIds(
|
||||||
payload.constructeurId = definition.constructeurId
|
definition.constructeurIds,
|
||||||
|
definition.constructeurId,
|
||||||
|
definition.constructeur,
|
||||||
|
definition.constructeurs,
|
||||||
|
)
|
||||||
|
if (constructeurIds.length) {
|
||||||
|
payload.constructeurIds = constructeurIds
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
|
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
|
||||||
@@ -729,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
|
|||||||
...createEmptyPieceModelStructure(),
|
...createEmptyPieceModelStructure(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const defaultProductStructure = (): ProductModelStructure => ({
|
||||||
|
...createEmptyProductModelStructure(),
|
||||||
|
})
|
||||||
|
|
||||||
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
||||||
const base = createEmptyPieceModelStructure()
|
const base = createEmptyPieceModelStructure()
|
||||||
if (!isPlainObject(input)) {
|
if (!isPlainObject(input)) {
|
||||||
@@ -738,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
|||||||
const clone: PieceModelStructure = {
|
const clone: PieceModelStructure = {
|
||||||
...base,
|
...base,
|
||||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||||
|
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||||
if (key === 'customFields') {
|
if (key === 'customFields' || key === 'products') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
clone[key] = value
|
clone[key] = value
|
||||||
@@ -759,13 +889,17 @@ export const clonePieceStructure = (input: any): PieceModelStructure => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cloneProductStructure = (input: any): ProductModelStructure => {
|
||||||
|
return clonePieceStructure(input)
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
||||||
if (!Array.isArray(fields)) {
|
if (!Array.isArray(fields)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
.map((field) => {
|
.map((field, index) => {
|
||||||
const name = typeof field?.name === 'string' ? field.name.trim() : ''
|
const name = typeof field?.name === 'string' ? field.name.trim() : ''
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return null
|
return null
|
||||||
@@ -792,17 +926,25 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
|||||||
if (options) {
|
if (options) {
|
||||||
result.options = options
|
result.options = options
|
||||||
}
|
}
|
||||||
|
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||||
|
result.orderIndex = orderIndex
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
.filter((field): field is PieceModelCustomField => !!field)
|
.filter((field): field is PieceModelCustomField => !!field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
|
||||||
|
return sanitizeProducts(products) as PieceModelProduct[]
|
||||||
|
}
|
||||||
|
|
||||||
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
|
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
|
||||||
const source = clonePieceStructure(input)
|
const source = clonePieceStructure(input)
|
||||||
|
const restEntries = Object.entries(source).filter(
|
||||||
|
([key]) => key !== 'customFields' && key !== 'products',
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(restEntries),
|
||||||
Object.entries(source).filter(([key]) => key !== 'customFields'),
|
products: sanitizePieceProducts(source.products),
|
||||||
),
|
|
||||||
customFields: sanitizePieceCustomFields(source.customFields),
|
customFields: sanitizePieceCustomFields(source.customFields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -812,7 +954,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields.map((field) => ({
|
return fields.map((field, index) => ({
|
||||||
name: field?.name ?? '',
|
name: field?.name ?? '',
|
||||||
type: field?.type ?? 'text',
|
type: field?.type ?? 'text',
|
||||||
required: !!field?.required,
|
required: !!field?.required,
|
||||||
@@ -822,6 +964,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
|||||||
: Array.isArray(field?.options)
|
: Array.isArray(field?.options)
|
||||||
? field.options.join('\n')
|
? field.options.join('\n')
|
||||||
: '',
|
: '',
|
||||||
|
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,8 +972,9 @@ export const hydratePieceStructureForEditor = (input: any): PieceModelStructureF
|
|||||||
const source = clonePieceStructure(input)
|
const source = clonePieceStructure(input)
|
||||||
const payload: PieceModelStructureForEditor = {
|
const payload: PieceModelStructureForEditor = {
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(source).filter(([key]) => key !== 'customFields'),
|
Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
|
||||||
),
|
),
|
||||||
|
products: hydrateProducts(source.products) as PieceModelProduct[],
|
||||||
customFields: hydratePieceCustomFields(source.customFields),
|
customFields: hydratePieceCustomFields(source.customFields),
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
@@ -844,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
|
|||||||
const customFields = Array.isArray((structure as any).customFields)
|
const customFields = Array.isArray((structure as any).customFields)
|
||||||
? (structure as any).customFields.length
|
? (structure as any).customFields.length
|
||||||
: 0
|
: 0
|
||||||
|
const products = Array.isArray((structure as any).products)
|
||||||
|
? (structure as any).products.length
|
||||||
|
: 0
|
||||||
|
|
||||||
if (!customFields) {
|
if (!customFields && !products) {
|
||||||
return 'Aucun champ personnalisé'
|
return 'Aucun produit ni champ personnalisé'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${customFields} champ(s) personnalisé(s)`
|
const segments: string[] = []
|
||||||
|
if (products) {
|
||||||
|
segments.push(`${products} produit(s)`)
|
||||||
|
}
|
||||||
|
if (customFields) {
|
||||||
|
segments.push(`${customFields} champ(s) personnalisé(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
|
||||||
|
normalizePieceStructureForSave(input)
|
||||||
|
|
||||||
|
export const hydrateProductStructureForEditor = (input: any) =>
|
||||||
|
hydratePieceStructureForEditor(input)
|
||||||
|
|
||||||
|
export const formatProductStructurePreview = (structure: any) =>
|
||||||
|
formatPieceStructurePreview(structure)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ComponentModelCustomField {
|
|||||||
optionsText?: string
|
optionsText?: string
|
||||||
id?: string
|
id?: string
|
||||||
customFieldId?: string
|
customFieldId?: string
|
||||||
|
orderIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentModelPiece {
|
export interface ComponentModelPiece {
|
||||||
@@ -19,18 +20,28 @@ export interface ComponentModelPiece {
|
|||||||
role?: string
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComponentModelProduct {
|
||||||
|
typeProductId?: string
|
||||||
|
typeProductLabel?: string
|
||||||
|
reference?: string
|
||||||
|
familyCode?: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComponentModelStructureNode {
|
export interface ComponentModelStructureNode {
|
||||||
typeComposantId?: string
|
typeComposantId?: string
|
||||||
typeComposantLabel?: string
|
typeComposantLabel?: string
|
||||||
modelId?: string
|
modelId?: string
|
||||||
familyCode?: string
|
familyCode?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
products?: ComponentModelProduct[]
|
||||||
subcomponents: ComponentModelStructureNode[]
|
subcomponents: ComponentModelStructureNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentModelStructure extends ComponentModelStructureNode {
|
export interface ComponentModelStructure extends ComponentModelStructureNode {
|
||||||
customFields: ComponentModelCustomField[]
|
customFields: ComponentModelCustomField[]
|
||||||
pieces: ComponentModelPiece[]
|
pieces: ComponentModelPiece[]
|
||||||
|
products: ComponentModelProduct[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
|
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
|
||||||
@@ -40,10 +51,20 @@ export interface PieceModelCustomField {
|
|||||||
type: PieceModelCustomFieldType
|
type: PieceModelCustomFieldType
|
||||||
required: boolean
|
required: boolean
|
||||||
options?: string[]
|
options?: string[]
|
||||||
|
orderIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PieceModelProduct {
|
||||||
|
typeProductId?: string
|
||||||
|
typeProductLabel?: string
|
||||||
|
reference?: string
|
||||||
|
familyCode?: string
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PieceModelStructure {
|
export interface PieceModelStructure {
|
||||||
customFields: PieceModelCustomField[]
|
customFields: PieceModelCustomField[]
|
||||||
|
products?: PieceModelProduct[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
|
|||||||
|
|
||||||
export interface PieceModelStructureForEditor {
|
export interface PieceModelStructureForEditor {
|
||||||
customFields: PieceModelStructureEditorField[]
|
customFields: PieceModelStructureEditorField[]
|
||||||
|
products?: PieceModelProduct[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductModelCustomField = PieceModelCustomField
|
||||||
|
export type ProductModelStructure = PieceModelStructure
|
||||||
|
|
||||||
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
|
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
|
||||||
|
|
||||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
@@ -250,9 +275,15 @@ export const componentModelStructureValidator = {
|
|||||||
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
|
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
|
||||||
customFields: [],
|
customFields: [],
|
||||||
pieces: [],
|
pieces: [],
|
||||||
|
products: [],
|
||||||
subcomponents: [],
|
subcomponents: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
|
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
|
||||||
customFields: [],
|
customFields: [],
|
||||||
|
products: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createEmptyProductModelStructure = (): ProductModelStructure => ({
|
||||||
|
customFields: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { normalizeEmail } from '~/utils/formatters/email'
|
import { normalizeEmail } from '~/utils/formatters/email'
|
||||||
|
|
||||||
export const EMAIL_INPUT_PATTERN = '[^\s@]+'
|
|
||||||
|
|
||||||
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
export const EMAIL_INPUT_PATTERN = EMAIL_VALIDATION_PATTERN.source
|
||||||
|
|
||||||
export type EmailValidationResult = {
|
export type EmailValidationResult = {
|
||||||
valid: boolean
|
valid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { normalizePhone } from '~/utils/formatters/phone'
|
import { normalizePhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
/** Pattern used for the HTML input `pattern` attribute on phone fields. */
|
/** Pattern used for the HTML input `pattern` attribute on phone fields. */
|
||||||
export const PHONE_INPUT_PATTERN = '[0-9+ ]*'
|
export const PHONE_INPUT_PATTERN = '[0-9+ .]*'
|
||||||
|
|
||||||
const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/
|
const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const PHONE_CHAR_PATTERN = /[^+\d]/g
|
|||||||
* Normalises a phone number by trimming whitespace, removing spacing/separators and
|
* Normalises a phone number by trimming whitespace, removing spacing/separators and
|
||||||
* converting international prefixes written with `00` to their `+` variant.
|
* converting international prefixes written with `00` to their `+` variant.
|
||||||
*/
|
*/
|
||||||
export const normalizePhone = (rawValue: string): string => {
|
export const normalizePhone = (rawValue: string | null | undefined): string => {
|
||||||
const trimmed = (rawValue || '').trim()
|
const trimmed = typeof rawValue === 'string' ? rawValue.trim() : ''
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -26,30 +26,53 @@ export const normalizePhone = (rawValue: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a phone number by grouping digits by two while keeping any international
|
* Formats a phone number by grouping digits by two and joining them with dots while
|
||||||
* prefix. The function remains tolerant to partially entered numbers.
|
* keeping any international prefix. The function remains tolerant to partially
|
||||||
|
* entered numbers and returns an empty string for nullish inputs.
|
||||||
*/
|
*/
|
||||||
export const formatPhone = (rawValue: string): string => {
|
export const formatPhone = (rawValue: string | null | undefined): string => {
|
||||||
|
if (rawValue == null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const normalized = normalizePhone(rawValue)
|
const normalized = normalizePhone(rawValue)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('+33')) {
|
||||||
|
let nationalNumber = normalized.slice(3)
|
||||||
|
if (nationalNumber.startsWith('0')) {
|
||||||
|
nationalNumber = nationalNumber.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nationalNumber.length % 2 !== 0) {
|
||||||
|
nationalNumber = `0${nationalNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = nationalNumber.match(/\d{1,2}/g) ?? []
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return '+33'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['+33', ...groups].join('.')
|
||||||
|
}
|
||||||
|
|
||||||
const hasInternationalPrefix = normalized.startsWith('+')
|
const hasInternationalPrefix = normalized.startsWith('+')
|
||||||
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
|
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
|
||||||
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
|
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
|
||||||
|
|
||||||
const groups = digits.match(/.{1,2}/g) ?? []
|
const groups = digits.match(/\d{1,2}/g) ?? []
|
||||||
const grouped = groups.join(' ')
|
const grouped = groups.join('.')
|
||||||
|
|
||||||
return prefix ? `${prefix}${grouped ? ' ' : ''}${grouped}` : grouped
|
return prefix ? `${prefix}${grouped}` : grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Masks a phone number for display purposes by replacing the middle digits with ·.
|
* Masks a phone number for display purposes by replacing the middle digits with ·.
|
||||||
* Useful for UI fragments where the full number should not be exposed.
|
* Useful for UI fragments where the full number should not be exposed.
|
||||||
*/
|
*/
|
||||||
export const maskPhone = (rawValue: string): string => {
|
export const maskPhone = (rawValue: string | null | undefined): string => {
|
||||||
const normalized = normalizePhone(rawValue)
|
const normalized = normalizePhone(rawValue)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
import {
|
||||||
|
uniqueConstructeurIds,
|
||||||
|
resolveConstructeurs,
|
||||||
|
formatConstructeurContact,
|
||||||
|
} from '~/shared/constructeurUtils'
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
currencyDisplay: 'narrowSymbol',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const number = Number(value)
|
||||||
|
if (Number.isNaN(number)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return currencyFormatter.format(number)
|
||||||
|
}
|
||||||
|
|
||||||
const formatSize = (size) => {
|
const formatSize = (size) => {
|
||||||
if (size === undefined || size === null) { return '—' }
|
if (size === undefined || size === null) { return '—' }
|
||||||
if (size === 0) { return '0 B' }
|
if (size === 0) { return '0 B' }
|
||||||
@@ -49,6 +72,49 @@ const renderPrintDocuments = (documents = [], title, sectionClass = 'print-secti
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderPrintProductSummary = (product, title = 'Produit catalogue', sectionClass = 'print-piece-section') => {
|
||||||
|
if (!product) { return '' }
|
||||||
|
|
||||||
|
const infoEntries = [
|
||||||
|
{ label: 'Nom', value: product.name || '—' },
|
||||||
|
{ label: 'Référence', value: product.reference || '—' },
|
||||||
|
{ label: 'Catégorie', value: product.typeName || '—' },
|
||||||
|
{
|
||||||
|
label: 'Prix indicatif',
|
||||||
|
value: product.supplierPrice || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fournisseur(s)',
|
||||||
|
value: product.constructeurs?.length
|
||||||
|
? product.constructeurs.map((constructeur) => constructeur.name).filter(Boolean).join(', ') || '—'
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const infoMarkup = infoEntries
|
||||||
|
.map((field) => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const customFieldsBlock = product.customFields?.length
|
||||||
|
? renderPrintCustomFields(product.customFields, 'Champs personnalisés du produit', 'print-subsection')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const documentsBlock = product.documents?.length
|
||||||
|
? renderPrintDocuments(product.documents, 'Documents du produit', 'print-subsection')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${sectionClass}">
|
||||||
|
<h4>${title}</h4>
|
||||||
|
<div class="print-grid">
|
||||||
|
${infoMarkup}
|
||||||
|
</div>
|
||||||
|
${customFieldsBlock}
|
||||||
|
${documentsBlock}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
const renderPrintPieces = (
|
const renderPrintPieces = (
|
||||||
pieces = [],
|
pieces = [],
|
||||||
title = 'Pièces indépendantes',
|
title = 'Pièces indépendantes',
|
||||||
@@ -59,9 +125,12 @@ const renderPrintPieces = (
|
|||||||
const cards = pieces
|
const cards = pieces
|
||||||
.map((piece, idx) => {
|
.map((piece, idx) => {
|
||||||
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
|
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
|
||||||
const constructeurBadge = piece.constructeur?.name
|
const constructeurBadges = (piece.constructeurs || [])
|
||||||
? `<span class="print-badge print-badge--subtle">Constructeur: ${piece.constructeur.name}</span>`
|
.map((constructeur, badgeIdx) => {
|
||||||
: ''
|
const suffix = piece.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
|
||||||
|
return `<span class="print-badge print-badge--subtle">Fournisseur${suffix}: ${constructeur.name}</span>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
const customFields = (piece.customFields || [])
|
const customFields = (piece.customFields || [])
|
||||||
.filter(field => field.value && field.value !== '—' && field.value !== '')
|
.filter(field => field.value && field.value !== '—' && field.value !== '')
|
||||||
@@ -85,6 +154,8 @@ const renderPrintPieces = (
|
|||||||
.join('')}</ul></div>`
|
.join('')}</ul></div>`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="print-piece-card">
|
<div class="print-piece-card">
|
||||||
<div class="print-piece-header">
|
<div class="print-piece-header">
|
||||||
@@ -93,19 +164,27 @@ const renderPrintPieces = (
|
|||||||
<div class="print-piece-title">${piece.name}</div>
|
<div class="print-piece-title">${piece.name}</div>
|
||||||
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
|
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
|
||||||
</div>
|
</div>
|
||||||
${constructeurBadge}
|
${constructeurBadges}
|
||||||
</div>
|
</div>
|
||||||
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
|
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
|
||||||
<div class="print-piece-meta">
|
<div class="print-piece-meta">
|
||||||
<div class="print-field-mini">
|
<div class="print-field-mini">
|
||||||
<label>Constructeur</label>
|
<label>Fournisseur(s)</label>
|
||||||
<span>${piece.constructeur?.name || '—'}</span>
|
<span>${piece.constructeurs?.length
|
||||||
|
? piece.constructeurs.map(constructeur => constructeur.name).join(', ')
|
||||||
|
: '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="print-field-mini">
|
<div class="print-field-mini">
|
||||||
<label>Contact</label>
|
<label>Contact(s)</label>
|
||||||
<span>${piece.constructeur?.contact || '—'}</span>
|
<span>${piece.constructeurs?.length
|
||||||
|
? piece.constructeurs
|
||||||
|
.map(constructeur => constructeur.contact)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' • ') || '—'
|
||||||
|
: '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${productBlock}
|
||||||
${customFieldsBlock}
|
${customFieldsBlock}
|
||||||
${documentsBlock}
|
${documentsBlock}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,12 +207,17 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
|||||||
return components
|
return components
|
||||||
.map((component, idx) => {
|
.map((component, idx) => {
|
||||||
const badges = []
|
const badges = []
|
||||||
if (component.constructeur?.name) {
|
if (component.constructeurs?.length) {
|
||||||
badges.push(`Constructeur: ${component.constructeur.name}`)
|
const label = component.constructeurs.map((constructeur, badgeIdx) => {
|
||||||
|
const suffix = component.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
|
||||||
|
return `Constructeur${suffix}: ${constructeur.name}`
|
||||||
|
})
|
||||||
|
badges.push(...label)
|
||||||
}
|
}
|
||||||
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
|
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
|
||||||
const currentIndex = [...indexPath, idx + 1]
|
const currentIndex = [...indexPath, idx + 1]
|
||||||
const indexLabel = currentIndex.join('.')
|
const indexLabel = currentIndex.join('.')
|
||||||
|
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
|
||||||
return `
|
return `
|
||||||
<div class="${sectionClass}">
|
<div class="${sectionClass}">
|
||||||
<h3>
|
<h3>
|
||||||
@@ -142,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
|||||||
</h3>
|
</h3>
|
||||||
${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
|
${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
|
||||||
${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
|
${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
|
||||||
|
${productBlock}
|
||||||
${renderPrintCustomFields(
|
${renderPrintCustomFields(
|
||||||
component.customFields,
|
component.customFields,
|
||||||
'Champs personnalisés',
|
'Champs personnalisés',
|
||||||
@@ -183,33 +268,115 @@ const normalizeCustomFields = (values = []) => {
|
|||||||
|
|
||||||
const normalizeConstructeur = (constructeur) => {
|
const normalizeConstructeur = (constructeur) => {
|
||||||
if (!constructeur) { return null }
|
if (!constructeur) { return null }
|
||||||
|
const contact = formatConstructeurContact(constructeur)
|
||||||
return {
|
return {
|
||||||
|
id: constructeur.id || null,
|
||||||
name: constructeur.name || '—',
|
name: constructeur.name || '—',
|
||||||
contact: [constructeur.email, constructeur.phone].filter(Boolean).join(' • ') || '—'
|
contact: contact || '—'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizePiece = piece => ({
|
const normalizeConstructeurList = (...sources) => {
|
||||||
id: piece.id,
|
const ids = uniqueConstructeurIds(...sources)
|
||||||
name: piece.name || 'Pièce sans nom',
|
const pools = sources
|
||||||
description: piece.description || '',
|
.flatMap((source) => {
|
||||||
reference: piece.reference || '',
|
if (Array.isArray(source)) {
|
||||||
customFields: normalizeCustomFields(piece.customFieldValues || []),
|
if (source.length && typeof source[0] === 'object') {
|
||||||
documents: normalizeDocuments(piece.documents || []),
|
return [source]
|
||||||
constructeur: normalizeConstructeur(piece.constructeur),
|
}
|
||||||
indexPath: piece.indexPath || null
|
return []
|
||||||
})
|
}
|
||||||
|
if (source && typeof source === 'object' && 'id' in source) {
|
||||||
|
return [[source]]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
const resolved = resolveConstructeurs(ids, ...pools)
|
||||||
|
return resolved
|
||||||
|
.map(normalizeConstructeur)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeComponent = component => ({
|
const normalizeProduct = (product) => {
|
||||||
id: component.id,
|
if (!product) { return null }
|
||||||
name: component.name || 'Composant sans nom',
|
const constructeurs = normalizeConstructeurList(
|
||||||
description: component.description || '',
|
product.constructeurs,
|
||||||
customFields: normalizeCustomFields(component.customFieldValues || []),
|
product.constructeur,
|
||||||
documents: normalizeDocuments(component.documents || []),
|
product.constructeurIds,
|
||||||
pieces: (component.pieces || []).map(normalizePiece),
|
product.constructeurId,
|
||||||
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
)
|
||||||
constructeur: normalizeConstructeur(component.constructeur)
|
return {
|
||||||
})
|
id: product.id || null,
|
||||||
|
name: product.name || 'Produit sans nom',
|
||||||
|
reference: product.reference || '',
|
||||||
|
supplierPrice: formatCurrency(product.supplierPrice),
|
||||||
|
typeName: product.typeProduct?.name || null,
|
||||||
|
constructeurs,
|
||||||
|
customFields: normalizeCustomFields(product.customFieldValues || []),
|
||||||
|
documents: normalizeDocuments(product.documents || []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePiece = piece => {
|
||||||
|
const rawProduct = piece.product || null
|
||||||
|
const constructeurs = normalizeConstructeurList(
|
||||||
|
piece.constructeurs,
|
||||||
|
piece.constructeur,
|
||||||
|
piece.originalPiece?.constructeurs,
|
||||||
|
piece.originalPiece?.constructeur,
|
||||||
|
piece.constructeurIds,
|
||||||
|
piece.constructeurId,
|
||||||
|
rawProduct?.constructeurs,
|
||||||
|
rawProduct?.constructeur,
|
||||||
|
rawProduct?.constructeurIds,
|
||||||
|
rawProduct?.constructeurId,
|
||||||
|
)
|
||||||
|
const product = normalizeProduct(rawProduct)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: piece.id,
|
||||||
|
name: piece.name || 'Pièce sans nom',
|
||||||
|
description: piece.description || '',
|
||||||
|
reference: piece.reference || '',
|
||||||
|
customFields: normalizeCustomFields(piece.customFieldValues || []),
|
||||||
|
documents: normalizeDocuments(piece.documents || []),
|
||||||
|
constructeurs,
|
||||||
|
constructeur: constructeurs[0] || null,
|
||||||
|
product,
|
||||||
|
indexPath: piece.indexPath || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeComponent = component => {
|
||||||
|
const rawProduct = component.product || null
|
||||||
|
const constructeurs = normalizeConstructeurList(
|
||||||
|
component.constructeurs,
|
||||||
|
component.constructeur,
|
||||||
|
component.originalComposant?.constructeurs,
|
||||||
|
component.originalComposant?.constructeur,
|
||||||
|
component.constructeurIds,
|
||||||
|
component.constructeurId,
|
||||||
|
rawProduct?.constructeurs,
|
||||||
|
rawProduct?.constructeur,
|
||||||
|
rawProduct?.constructeurIds,
|
||||||
|
rawProduct?.constructeurId,
|
||||||
|
)
|
||||||
|
const product = normalizeProduct(rawProduct)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: component.id,
|
||||||
|
name: component.name || 'Composant sans nom',
|
||||||
|
description: component.description || '',
|
||||||
|
customFields: normalizeCustomFields(component.customFieldValues || []),
|
||||||
|
documents: normalizeDocuments(component.documents || []),
|
||||||
|
pieces: (component.pieces || []).map(normalizePiece),
|
||||||
|
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
||||||
|
constructeurs,
|
||||||
|
constructeur: constructeurs[0] || null,
|
||||||
|
product,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const buildMachinePrintContext = ({
|
export const buildMachinePrintContext = ({
|
||||||
machine,
|
machine,
|
||||||
@@ -255,6 +422,24 @@ export const buildMachinePrintContext = ({
|
|||||||
machineBadges.push(`Ref: ${machineReference}`)
|
machineBadges.push(`Ref: ${machineReference}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const machineConstructeurs = normalizeConstructeurList(
|
||||||
|
machine?.constructeurs,
|
||||||
|
machine?.constructeur,
|
||||||
|
machine?.constructeurIds,
|
||||||
|
machine?.constructeurId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineConstructeurNames = machineConstructeurs.length
|
||||||
|
? machineConstructeurs.map((constructeur) => constructeur.name).join(', ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const machineConstructeurContacts = machineConstructeurs.length
|
||||||
|
? machineConstructeurs
|
||||||
|
.map((constructeur) => constructeur.contact)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' • ')
|
||||||
|
: ''
|
||||||
|
|
||||||
const normalizedPieces = machinePieces
|
const normalizedPieces = machinePieces
|
||||||
.map(normalizePiece)
|
.map(normalizePiece)
|
||||||
.filter(piece => isPieceSelected(piece.id))
|
.filter(piece => isPieceSelected(piece.id))
|
||||||
@@ -300,7 +485,10 @@ export const buildMachinePrintContext = ({
|
|||||||
site: machine?.site?.name || '',
|
site: machine?.site?.name || '',
|
||||||
category: machine?.typeMachine?.category || '',
|
category: machine?.typeMachine?.category || '',
|
||||||
badges: machineBadges,
|
badges: machineBadges,
|
||||||
constructeur: normalizeConstructeur(machine?.constructeur),
|
constructeurs: machineConstructeurs,
|
||||||
|
constructeur: machineConstructeurs[0] || null,
|
||||||
|
constructeurNames: machineConstructeurNames,
|
||||||
|
constructeurContacts: machineConstructeurContacts,
|
||||||
includeInfo: includeMachineInfo,
|
includeInfo: includeMachineInfo,
|
||||||
customFields: includeMachineCustomFields
|
customFields: includeMachineCustomFields
|
||||||
? normalizeCustomFields(machine?.customFieldValues || [])
|
? normalizeCustomFields(machine?.customFieldValues || [])
|
||||||
@@ -342,11 +530,11 @@ export const buildMachinePrintHtml = (context, styles) => {
|
|||||||
<div class="print-section print-section--machine">
|
<div class="print-section print-section--machine">
|
||||||
<h3>Informations générales</h3>
|
<h3>Informations générales</h3>
|
||||||
<div class="print-grid">
|
<div class="print-grid">
|
||||||
${renderPrintField('Nom', context.machine.name)}
|
${renderPrintField('Nom', context.machine.name)}
|
||||||
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
|
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
|
||||||
${renderPrintField('Site', context.machine.site, 'Non défini')}
|
${renderPrintField('Site', context.machine.site, 'Non défini')}
|
||||||
${renderPrintField('Constructeur', context.machine.constructeur?.name, 'Non défini')}
|
${renderPrintField('Constructeur(s)', context.machine.constructeurNames, 'Non défini')}
|
||||||
${renderPrintField('Contact Constructeur', context.machine.constructeur?.contact, 'Non défini')}
|
${renderPrintField('Contact(s) Constructeur(s)', context.machine.constructeurContacts, 'Non défini')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// Lire la version depuis le fichier VERSION à la racine du projet parent
|
||||||
|
const getAppVersion = (): string => {
|
||||||
|
try {
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const versionPath = resolve(__dirname, '..', 'VERSION')
|
||||||
|
return readFileSync(versionPath, 'utf-8').trim()
|
||||||
|
} catch {
|
||||||
|
return '0.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
|
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
devServer: {
|
devServer: {
|
||||||
port: 3001
|
host: '0.0.0.0',
|
||||||
|
port: 3000
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
[
|
[
|
||||||
@@ -18,11 +37,14 @@ export default defineNuxtConfig({
|
|||||||
]
|
]
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
apiBaseUrl: process.env.NUXT_API_BASE_URL
|
||||||
|
|| process.env.NUXT_PUBLIC_API_BASE_URL
|
||||||
|
|| 'http://localhost/api',
|
||||||
public: {
|
public: {
|
||||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
||||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
||||||
appVersion: process.env.NUXT_PUBLIC_APP_VERSION || '0.1.0',
|
appVersion: appVersion,
|
||||||
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
||||||
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
||||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -3754,7 +3754,6 @@
|
|||||||
"integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==",
|
"integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.0",
|
"@eslint-community/eslint-utils": "^4.9.0",
|
||||||
"@typescript-eslint/types": "^8.44.0",
|
"@typescript-eslint/types": "^8.44.0",
|
||||||
@@ -11673,7 +11672,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -12483,6 +12481,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
|
||||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user