feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
This commit is contained in:
116
app/app.vue
116
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"
|
||||||
@@ -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')"
|
||||||
|
|||||||
@@ -42,6 +42,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</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"
|
||||||
@@ -124,6 +130,94 @@
|
|||||||
<span v-else class="font-medium">Non défini</span>
|
<span v-else class="font-medium">Non défini</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
|
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>
|
</div>
|
||||||
@@ -432,6 +526,110 @@ const childComponents = computed(() => {
|
|||||||
|
|
||||||
const { constructeurs } = useConstructeurs()
|
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(() =>
|
const componentConstructeurIds = computed(() =>
|
||||||
uniqueConstructeurIds(
|
uniqueConstructeurIds(
|
||||||
props.component,
|
props.component,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,44 @@
|
|||||||
</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.definition) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
|
||||||
|
Aucun produit disponible pour cette catégorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="productAssignment.selectedProductId || ''"
|
||||||
|
:options="getProductOptions(productAssignment.definition)"
|
||||||
|
:loading="productsLoading"
|
||||||
|
size="xs"
|
||||||
|
placeholder="Rechercher un produit..."
|
||||||
|
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||||
|
:option-label="productOptionLabel"
|
||||||
|
:option-description="productOptionDescription"
|
||||||
|
@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 +119,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>
|
||||||
@@ -95,6 +135,7 @@ import { computed, watch } from 'vue';
|
|||||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
|
ComponentModelProduct,
|
||||||
ComponentModelStructureNode,
|
ComponentModelStructureNode,
|
||||||
} from '~/shared/types/inventory';
|
} from '~/shared/types/inventory';
|
||||||
|
|
||||||
@@ -122,17 +163,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 +200,21 @@ 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;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
depth: 0,
|
depth: 0,
|
||||||
pieces: () => [],
|
pieces: () => [],
|
||||||
|
products: () => [],
|
||||||
components: () => [],
|
components: () => [],
|
||||||
componentsLoading: false,
|
componentsLoading: false,
|
||||||
piecesLoading: false,
|
piecesLoading: false,
|
||||||
|
productsLoading: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -269,6 +333,102 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getProductOptions = (definition: ComponentModelProduct) => {
|
||||||
|
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 = (definition: ComponentModelProduct) => {
|
||||||
|
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(definition);
|
||||||
|
const fallbackProduct = options[0] || null;
|
||||||
|
const fallbackType = fallbackProduct?.typeProduct || null;
|
||||||
|
|
||||||
|
addPart(definition.role);
|
||||||
|
addPart(
|
||||||
|
definition.typeProductLabel ||
|
||||||
|
(definition as any).typeProduct?.name ||
|
||||||
|
fallbackType?.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -377,4 +537,20 @@ watch(
|
|||||||
},
|
},
|
||||||
{ 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.definition);
|
||||||
|
if (
|
||||||
|
productAssignment.selectedProductId &&
|
||||||
|
!options.some((product) => product.id === productAssignment.selectedProductId)
|
||||||
|
) {
|
||||||
|
productAssignment.selectedProductId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -113,6 +119,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 -->
|
||||||
@@ -364,6 +484,7 @@
|
|||||||
<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 { useConstructeurs } from "~/composables/useConstructeurs";
|
||||||
import { useCustomFields } from "~/composables/useCustomFields";
|
import { useCustomFields } from "~/composables/useCustomFields";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
@@ -373,6 +494,7 @@ 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 {
|
import {
|
||||||
formatConstructeurContact as formatConstructeurContactSummary,
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
resolveConstructeurs,
|
resolveConstructeurs,
|
||||||
@@ -401,6 +523,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([]);
|
||||||
@@ -779,6 +902,7 @@ const candidateCustomFields = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { constructeurs } = useConstructeurs();
|
const { constructeurs } = useConstructeurs();
|
||||||
|
const { products, loadProducts, getProduct } = useProducts();
|
||||||
|
|
||||||
const pieceConstructeurIds = computed(() =>
|
const pieceConstructeurIds = computed(() =>
|
||||||
uniqueConstructeurIds(
|
uniqueConstructeurIds(
|
||||||
@@ -800,6 +924,237 @@ const pieceConstructeursDisplay = computed(() =>
|
|||||||
const formatConstructeurContact = (constructeur) =>
|
const formatConstructeurContact = (constructeur) =>
|
||||||
formatConstructeurContactSummary(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) => {
|
||||||
const ids = uniqueConstructeurIds(value);
|
const ids = uniqueConstructeurIds(value);
|
||||||
props.piece.constructeurIds = [...ids];
|
props.piece.constructeurIds = [...ids];
|
||||||
@@ -1060,10 +1415,24 @@ 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,
|
||||||
|
productId: pieceData.productId || null,
|
||||||
|
product,
|
||||||
constructeurIds: pieceConstructeurIds.value,
|
constructeurIds: pieceConstructeurIds.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
<header class="flex items-center justify-between">
|
<section class="space-y-3">
|
||||||
<h3 class="text-sm font-semibold">
|
<header class="flex items-center justify-between">
|
||||||
Champs personnalisés
|
<div>
|
||||||
</h3>
|
<h3 class="text-sm font-semibold">
|
||||||
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
Produits inclus par défaut
|
||||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
</h3>
|
||||||
Ajouter
|
<p class="text-xs text-base-content/70">
|
||||||
</button>
|
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||||
</header>
|
</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="!fields.length" class="text-xs text-gray-500">
|
<p v-if="!products.length" class="text-xs text-gray-500">
|
||||||
Aucun champ personnalisé n'a encore été défini.
|
Aucun produit défini.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul v-else class="space-y-2" role="list">
|
<ul v-else class="space-y-2" role="list">
|
||||||
<li
|
<li
|
||||||
v-for="(field, index) in fields"
|
v-for="(product, index) in products"
|
||||||
:key="field.uid"
|
:key="product.uid"
|
||||||
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
|
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
|
||||||
:class="reorderClass(index)"
|
>
|
||||||
draggable="true"
|
<div class="flex items-start justify-between gap-3">
|
||||||
@dragstart="onDragStart(index, $event)"
|
<div class="flex-1 space-y-3">
|
||||||
@dragenter="onDragEnter(index)"
|
<div class="form-control">
|
||||||
@dragover.prevent="onDragEnter(index)"
|
<label class="label py-1">
|
||||||
@drop.prevent="onDrop(index)"
|
<span class="label-text text-xs">Famille de produit</span>
|
||||||
@dragend="onDragEnd"
|
</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">
|
||||||
|
Champs personnalisés
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||||
|
Aucun champ personnalisé n'a encore été défini.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2" role="list">
|
||||||
|
<li
|
||||||
|
v-for="(field, index) in fields"
|
||||||
|
:key="field.uid"
|
||||||
|
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 gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -87,25 +150,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
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 {
|
import type {
|
||||||
PieceModelCustomField,
|
PieceModelCustomField,
|
||||||
PieceModelCustomFieldType,
|
PieceModelCustomFieldType,
|
||||||
|
PieceModelProduct,
|
||||||
PieceModelStructure,
|
PieceModelStructure,
|
||||||
PieceModelStructureEditorField,
|
PieceModelStructureEditorField,
|
||||||
} from '~/shared/types/inventory'
|
} from '~/shared/types/inventory'
|
||||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
|
||||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||||
|
|
||||||
type EditorField = PieceModelStructureEditorField & { uid: string }
|
type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||||
|
type EditorProduct = {
|
||||||
|
uid: string
|
||||||
|
typeProductId: string
|
||||||
|
typeProductLabel: string
|
||||||
|
familyCode: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
@@ -115,12 +187,15 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: PieceModelStructure): void
|
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const ensureArray = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : [])
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
|
||||||
|
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
||||||
|
Array.isArray(value) ? value : []
|
||||||
|
|
||||||
const normalizeLineEndings = (value: string): string =>
|
const normalizeLineEndings = (value: string): string =>
|
||||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
|
||||||
const safeClone = <T>(value: T, fallback: T): T => {
|
const safeClone = <T,>(value: T, fallback: T): T => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||||
} catch {
|
} catch {
|
||||||
@@ -132,17 +207,19 @@ const extractRest = (structure?: PieceModelStructure | null): Record<string, unk
|
|||||||
if (!structure || typeof structure !== 'object') {
|
if (!structure || typeof structure !== 'object') {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
const entries = Object.entries(structure).filter(([key]) => key !== 'customFields')
|
const entries = Object.entries(structure).filter(
|
||||||
|
([key]) => key !== 'customFields' && key !== 'products',
|
||||||
|
)
|
||||||
return safeClone(Object.fromEntries(entries), {})
|
return safeClone(Object.fromEntries(entries), {})
|
||||||
}
|
}
|
||||||
|
|
||||||
let uidCounter = 0
|
let uidCounter = 0
|
||||||
const createUid = (): string => {
|
const createUid = (scope: 'field' | 'product'): string => {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
uidCounter += 1
|
uidCounter += 1
|
||||||
return `piece-field-${Date.now().toString(36)}-${uidCounter}`
|
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const toEditorField = (
|
const toEditorField = (
|
||||||
@@ -159,7 +236,7 @@ const toEditorField = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uid: createUid(),
|
uid: createUid('field'),
|
||||||
name: typeof input?.name === 'string' ? input.name : '',
|
name: typeof input?.name === 'string' ? input.name : '',
|
||||||
type: baseType as PieceModelCustomFieldType,
|
type: baseType as PieceModelCustomFieldType,
|
||||||
required: Boolean(input?.required),
|
required: Boolean(input?.required),
|
||||||
@@ -176,7 +253,81 @@ const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] =>
|
|||||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
.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 hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
||||||
|
const source = Array.isArray(structure?.products) ? structure?.products : []
|
||||||
|
return source.map((product) => toEditorProduct(product))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||||
|
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||||
|
|
||||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||||
@@ -185,8 +336,30 @@ const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
|||||||
orderIndex: index,
|
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 = (
|
const buildPayload = (
|
||||||
fieldsSource: EditorField[],
|
fieldsSource: EditorField[],
|
||||||
|
productsSource: EditorProduct[],
|
||||||
restSource: Record<string, unknown>,
|
restSource: Record<string, unknown>,
|
||||||
): PieceModelStructure => {
|
): PieceModelStructure => {
|
||||||
const normalizedFields = fieldsSource
|
const normalizedFields = fieldsSource
|
||||||
@@ -219,8 +392,13 @@ const buildPayload = (
|
|||||||
})
|
})
|
||||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||||
|
|
||||||
|
const normalizedProducts = productsSource
|
||||||
|
.map((product) => normalizeProductEntry(product))
|
||||||
|
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||||
|
|
||||||
const draft: PieceModelStructure = {
|
const draft: PieceModelStructure = {
|
||||||
...safeClone(restSource, {}),
|
...safeClone(restSource, {}),
|
||||||
|
products: normalizedProducts,
|
||||||
customFields: normalizedFields,
|
customFields: normalizedFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +412,7 @@ const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
|||||||
let lastEmitted = serializeStructure(props.modelValue)
|
let lastEmitted = serializeStructure(props.modelValue)
|
||||||
|
|
||||||
const emitUpdate = () => {
|
const emitUpdate = () => {
|
||||||
const payload = buildPayload(fields.value, restState.value)
|
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||||
const serialized = JSON.stringify(payload)
|
const serialized = JSON.stringify(payload)
|
||||||
if (serialized !== lastEmitted) {
|
if (serialized !== lastEmitted) {
|
||||||
lastEmitted = serialized
|
lastEmitted = serialized
|
||||||
@@ -243,6 +421,10 @@ const emitUpdate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(fields, emitUpdate, { deep: true })
|
watch(fields, emitUpdate, { deep: true })
|
||||||
|
watch(products, emitUpdate, { deep: true })
|
||||||
|
watch(productTypeOptions, () => {
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -253,11 +435,20 @@ watch(
|
|||||||
}
|
}
|
||||||
restState.value = extractRest(value)
|
restState.value = extractRest(value)
|
||||||
fields.value = hydrateFields(value)
|
fields.value = hydrateFields(value)
|
||||||
|
products.value = hydrateProducts(value)
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
lastEmitted = incomingSerialized
|
lastEmitted = incomingSerialized
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!productTypeOptions.value.length) {
|
||||||
|
await loadProductTypes()
|
||||||
|
}
|
||||||
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
|
})
|
||||||
|
|
||||||
const dragState = reactive({
|
const dragState = reactive({
|
||||||
draggingIndex: null as number | null,
|
draggingIndex: null as number | null,
|
||||||
dropTargetIndex: null as number | null,
|
dropTargetIndex: null as number | null,
|
||||||
@@ -328,7 +519,7 @@ const reorderClass = (index: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||||
uid: createUid(),
|
uid: createUid('field'),
|
||||||
name: '',
|
name: '',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
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>
|
||||||
@@ -139,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">
|
||||||
@@ -251,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)"
|
||||||
@@ -268,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' })
|
||||||
|
|
||||||
@@ -281,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<{
|
||||||
@@ -288,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
|
||||||
@@ -297,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: '',
|
||||||
@@ -308,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,
|
||||||
@@ -372,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))
|
||||||
@@ -382,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] = []
|
||||||
}
|
}
|
||||||
@@ -493,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
|
||||||
@@ -502,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)
|
||||||
}
|
}
|
||||||
@@ -524,6 +652,25 @@ 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({
|
const customFieldDragState = ref({
|
||||||
draggingIndex: null as number | null,
|
draggingIndex: null as number | null,
|
||||||
dropTargetIndex: null as number | null,
|
dropTargetIndex: null as number | null,
|
||||||
@@ -633,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
|
||||||
@@ -655,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)
|
||||||
|
|
||||||
@@ -676,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
|
||||||
@@ -728,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
|
||||||
@@ -818,6 +1038,18 @@ 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(
|
watch(
|
||||||
() => props.node.customFields,
|
() => props.node.customFields,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -80,7 +86,8 @@ const createDefaultForm = (source = {}) => ({
|
|||||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||||
customFields: withNormalizedOrder(source.customFields || []),
|
customFields: withNormalizedOrder(source.customFields || []),
|
||||||
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
||||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || [])
|
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
||||||
|
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = reactive(createDefaultForm(props.modelValue))
|
const formData = reactive(createDefaultForm(props.modelValue))
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -51,7 +51,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<{
|
||||||
@@ -210,8 +210,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);
|
||||||
|
|||||||
@@ -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,6 +94,17 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -114,12 +126,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'
|
||||||
@@ -171,6 +187,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 +213,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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +289,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 +346,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())
|
||||||
|
|||||||
@@ -131,6 +131,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) => {
|
||||||
|
|||||||
@@ -60,6 +60,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 })
|
||||||
@@ -140,6 +145,7 @@ export function useDocuments () {
|
|||||||
loadDocumentsByMachine,
|
loadDocumentsByMachine,
|
||||||
loadDocumentsByComponent,
|
loadDocumentsByComponent,
|
||||||
loadDocumentsByPiece,
|
loadDocumentsByPiece,
|
||||||
|
loadDocumentsByProduct,
|
||||||
uploadDocuments,
|
uploadDocuments,
|
||||||
deleteDocument
|
deleteDocument
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ import { useApi } from './useApi'
|
|||||||
const machineTypes = ref([])
|
const machineTypes = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const normalizeRequirementList = (value) => (Array.isArray(value) ? value : [])
|
||||||
|
|
||||||
|
const normalizeMachineType = (type) => {
|
||||||
|
if (!type || typeof type !== 'object') {
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...type,
|
||||||
|
componentRequirements: normalizeRequirementList(type.componentRequirements),
|
||||||
|
pieceRequirements: normalizeRequirementList(type.pieceRequirements),
|
||||||
|
productRequirements: normalizeRequirementList(type.productRequirements),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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, patch, delete: del } = useApi()
|
||||||
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
|
|||||||
try {
|
try {
|
||||||
const result = await get('/types/machines')
|
const result = await get('/types/machines')
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
machineTypes.value = result.data
|
machineTypes.value = Array.isArray(result.data)
|
||||||
|
? result.data.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) {
|
||||||
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
|
|||||||
try {
|
try {
|
||||||
const result = await post('/types/machines', typeData)
|
const result = await post('/types/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
|
||||||
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
|
|||||||
try {
|
try {
|
||||||
const result = await patch(`/types/machines/${id}`, typeData)
|
const result = await patch(`/types/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`)
|
||||||
}
|
}
|
||||||
@@ -91,7 +108,7 @@ export function useMachineTypesApi () {
|
|||||||
const result = await get(`/types/machines/${id}`)
|
const result = await get(`/types/machines/${id}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Ajouter au cache local
|
// Ajouter au cache local
|
||||||
machineTypes.value.push(result.data)
|
machineTypes.value.push(normalizeMachineType(result.data))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/composables/useProducts.js
Normal file
184
app/composables/useProducts.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProducts () {
|
||||||
|
const { showError } = useToast()
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
|
||||||
|
const loadProducts = async (options = {}) => {
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: products.value, total: total.value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loaded.value && !options.force) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: products.value, total: total.value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await get('/products?limit=100')
|
||||||
|
if (result.success) {
|
||||||
|
const items = Array.isArray(result.data?.items) ? result.data.items : []
|
||||||
|
products.value = items
|
||||||
|
total.value = typeof result.data?.total === 'number' ? result.data.total : items.length
|
||||||
|
loaded.value = true
|
||||||
|
} 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) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await post('/products', payload)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const added = replaceInCache(result.data)
|
||||||
|
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) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await patch(`/products/${id}`, payload)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
replaceInCache(result.data)
|
||||||
|
} 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 = {}) => {
|
||||||
|
if (!options.force) {
|
||||||
|
const cached = products.value.find((product) => product.id === id)
|
||||||
|
if (cached) {
|
||||||
|
return { success: true, data: cached }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await get(`/products/${id}`)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
replaceInCache(result.data)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
<th class="w-24">Aperçu</th>
|
<th class="w-24">Aperçu</th>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Référence</th>
|
<th>Référence</th>
|
||||||
|
<th>Type de composant</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||||
<td>{{ component.reference || '—' }}</td>
|
<td>{{ component.reference || '—' }}</td>
|
||||||
|
<td>{{ resolveComponentType(component) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -178,6 +180,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)
|
||||||
|
|||||||
@@ -445,7 +445,6 @@ const loadingDocuments = ref(false)
|
|||||||
const componentDocuments = ref<any[]>([])
|
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 selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
|
|||||||
@@ -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,14 +201,16 @@
|
|||||||
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"
|
||||||
/>
|
/>
|
||||||
<p v-else class="text-xs text-error">
|
<p v-else class="text-xs text-error">
|
||||||
@@ -335,6 +349,7 @@ 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 { useProducts } from '~/composables/useProducts'
|
||||||
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'
|
||||||
@@ -342,6 +357,7 @@ import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/mo
|
|||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
|
ComponentModelProduct,
|
||||||
ComponentModelStructure,
|
ComponentModelStructure,
|
||||||
ComponentModelStructureNode,
|
ComponentModelStructureNode,
|
||||||
} from '~/shared/types/inventory'
|
} from '~/shared/types/inventory'
|
||||||
@@ -367,6 +383,11 @@ const {
|
|||||||
loadPieces,
|
loadPieces,
|
||||||
loading: piecesLoading,
|
loading: piecesLoading,
|
||||||
} = usePieces()
|
} = usePieces()
|
||||||
|
const {
|
||||||
|
products: productCatalogRef,
|
||||||
|
loadProducts,
|
||||||
|
loading: productsLoading,
|
||||||
|
} = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
@@ -387,9 +408,10 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -486,6 +508,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,
|
||||||
@@ -496,6 +533,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}`),
|
||||||
)
|
)
|
||||||
@@ -505,6 +548,7 @@ const buildAssignmentNode = (
|
|||||||
definition,
|
definition,
|
||||||
selectedComponentId: '',
|
selectedComponentId: '',
|
||||||
pieces,
|
pieces,
|
||||||
|
products,
|
||||||
subcomponents,
|
subcomponents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,7 +566,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))
|
||||||
@@ -539,13 +583,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(() => {
|
||||||
@@ -588,6 +640,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,
|
||||||
) => {
|
) => {
|
||||||
@@ -609,6 +670,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)
|
||||||
@@ -624,6 +695,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
|
||||||
}
|
}
|
||||||
@@ -634,6 +708,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
|
||||||
@@ -682,6 +757,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
|
||||||
@@ -709,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -776,8 +874,17 @@ const submitCreation = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,6 +936,7 @@ onMounted(async () => {
|
|||||||
loadComponentTypes(),
|
loadComponentTypes(),
|
||||||
loadPieces(),
|
loadPieces(),
|
||||||
loadComposants(),
|
loadComposants(),
|
||||||
|
loadProducts(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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">{{
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
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 +105,8 @@ const createEmptyType = () => ({
|
|||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
componentRequirements: [],
|
componentRequirements: [],
|
||||||
pieceRequirements: []
|
pieceRequirements: [],
|
||||||
|
productRequirements: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftType = ref(createEmptyType())
|
const draftType = ref(createEmptyType())
|
||||||
@@ -187,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
|
|||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
.map((req, index) => ({ ...req, orderIndex: index }))
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
||||||
|
|
||||||
|
const normalizeProductRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typeProductId)
|
||||||
|
.map((req, index) => ({
|
||||||
|
typeProductId: req.typeProductId,
|
||||||
|
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 buildPayload = typeData => ({
|
const buildPayload = typeData => ({
|
||||||
name: typeData.name,
|
name: typeData.name,
|
||||||
description: typeData.description,
|
description: typeData.description,
|
||||||
@@ -194,7 +215,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
@@ -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.
|
||||||
@@ -304,10 +308,130 @@
|
|||||||
|
|
||||||
</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 +610,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 +742,11 @@ 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 { 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'
|
||||||
@@ -566,6 +759,7 @@ 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 toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -579,6 +773,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 +799,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 +830,17 @@ 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 isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
@@ -904,6 +1115,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 +1145,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]
|
||||||
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
|
|||||||
}
|
}
|
||||||
return pieceById.value.get(id) || null
|
return pieceById.value.get(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') {
|
||||||
return 'badge-success'
|
return 'badge-success'
|
||||||
@@ -1003,6 +1300,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 +1314,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 +1485,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) => {
|
||||||
@@ -1061,6 +1526,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 +1623,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 +1727,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 +1787,7 @@ const validateRequirementSelections = (type) => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
componentLinks: componentLinksPayload,
|
componentLinks: componentLinksPayload,
|
||||||
pieceLinks: pieceLinksPayload,
|
pieceLinks: pieceLinksPayload,
|
||||||
|
productLinks: productLinksPayload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,20 +1989,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 +2023,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 +2079,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)
|
||||||
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
|
|||||||
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 +2138,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,6 +2155,7 @@ const finalizeMachineCreation = async () => {
|
|||||||
}
|
}
|
||||||
componentLinks = validationResult.componentLinks
|
componentLinks = validationResult.componentLinks
|
||||||
pieceLinks = validationResult.pieceLinks
|
pieceLinks = validationResult.pieceLinks
|
||||||
|
productLinks = validationResult.productLinks
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
|
|||||||
...(hasRequirements
|
...(hasRequirements
|
||||||
? {
|
? {
|
||||||
componentLinks,
|
componentLinks,
|
||||||
pieceLinks
|
pieceLinks,
|
||||||
|
productLinks
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
@@ -1621,7 +2212,8 @@ onMounted(async () => {
|
|||||||
loadSites(),
|
loadSites(),
|
||||||
loadMachineTypes(),
|
loadMachineTypes(),
|
||||||
loadComposants(),
|
loadComposants(),
|
||||||
loadPieces()
|
loadPieces(),
|
||||||
|
loadProducts()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
<th class="w-24">Aperçu</th>
|
<th class="w-24">Aperçu</th>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Référence</th>
|
<th>Référence</th>
|
||||||
|
<th>Type de pièce</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -106,6 +107,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ piece.name || 'Pièce sans nom' }}</td>
|
<td>{{ piece.name || 'Pièce sans nom' }}</td>
|
||||||
<td>{{ piece.reference || '—' }}</td>
|
<td>{{ piece.reference || '—' }}</td>
|
||||||
|
<td>{{ resolvePieceType(piece) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -180,6 +182,17 @@ 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 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)
|
||||||
|
|||||||
@@ -123,6 +123,36 @@
|
|||||||
</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">
|
||||||
|
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>
|
||||||
|
<ProductSelect
|
||||||
|
v-model="editionForm.productId"
|
||||||
|
:disabled="saving"
|
||||||
|
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||||
|
helper-text="Un produit valide est requis pour cette pièce."
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
@@ -356,6 +386,7 @@ 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'
|
||||||
@@ -366,7 +397,7 @@ 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 { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
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 {
|
||||||
@@ -411,6 +442,7 @@ const editionForm = reactive({
|
|||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
|
productId: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
@@ -542,6 +574,42 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const structureProducts = computed(() =>
|
||||||
|
getStructureProducts(selectedType.value?.structure ?? null),
|
||||||
|
)
|
||||||
|
|
||||||
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
|
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||||
|
|
||||||
|
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 requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
customFieldInputs.value.every((field) => {
|
customFieldInputs.value.every((field) => {
|
||||||
if (!field.required) {
|
if (!field.required) {
|
||||||
@@ -554,12 +622,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 &&
|
||||||
))
|
(!requiresProductSelection.value || editionForm.productId) &&
|
||||||
|
!saving.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const toFieldString = (value: unknown): string => {
|
const toFieldString = (value: unknown): string => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -610,6 +681,7 @@ watch(
|
|||||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||||
)
|
)
|
||||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||||
|
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
|
||||||
|
|
||||||
customFieldInputs.value = buildCustomFieldInputs(
|
customFieldInputs.value = buildCustomFieldInputs(
|
||||||
currentType?.structure ?? null,
|
currentType?.structure ?? null,
|
||||||
@@ -636,6 +708,11 @@ const submitEdition = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiresProductSelection.value && !editionForm.productId) {
|
||||||
|
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
|
||||||
@@ -650,6 +727,12 @@ const submitEdition = async () => {
|
|||||||
payload.reference = reference ? reference : null
|
payload.reference = reference ? reference : null
|
||||||
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||||
|
|
||||||
|
const selectedProductId =
|
||||||
|
typeof editionForm.productId === 'string'
|
||||||
|
? editionForm.productId.trim()
|
||||||
|
: ''
|
||||||
|
payload.productId = selectedProductId || null
|
||||||
|
|
||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
@@ -841,7 +924,11 @@ 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 getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -71,10 +71,10 @@
|
|||||||
<span class="label-text">Fournisseur</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 fournisseur..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,36 @@
|
|||||||
</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>
|
||||||
|
<ProductSelect
|
||||||
|
v-model="creationForm.productId"
|
||||||
|
:disabled="submitting || !selectedType"
|
||||||
|
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||||
|
helper-text="Un produit est requis pour cette pièce."
|
||||||
|
/>
|
||||||
|
</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 +283,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'
|
||||||
@@ -261,7 +292,7 @@ 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 { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
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 {
|
||||||
@@ -286,6 +317,7 @@ const creationForm = reactive({
|
|||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
|
productId: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
@@ -332,6 +364,42 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const structureProducts = computed(() =>
|
||||||
|
getStructureProducts(selectedType.value?.structure ?? null),
|
||||||
|
)
|
||||||
|
|
||||||
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
|
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
watch(selectedType, (type) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearCreationForm()
|
clearCreationForm()
|
||||||
@@ -343,6 +411,7 @@ watch(selectedType, (type) => {
|
|||||||
}
|
}
|
||||||
lastSuggestedName.value = creationForm.name
|
lastSuggestedName.value = creationForm.name
|
||||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||||
|
creationForm.productId = null
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
@@ -357,12 +426,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 &&
|
||||||
))
|
(!requiresProductSelection.value || creationForm.productId) &&
|
||||||
|
!submitting.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const toFieldString = (value: unknown): string => {
|
const toFieldString = (value: unknown): string => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
@@ -377,13 +449,18 @@ const toFieldString = (value: unknown): string => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
|
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurIds = []
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
|
creationForm.productId = null
|
||||||
lastSuggestedName.value = ''
|
lastSuggestedName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +469,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 (requiresProductSelection.value && !creationForm.productId) {
|
||||||
|
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,
|
||||||
@@ -406,6 +489,14 @@ const submitCreation = async () => {
|
|||||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedProductId =
|
||||||
|
typeof creationForm.productId === 'string'
|
||||||
|
? creationForm.productId.trim()
|
||||||
|
: ''
|
||||||
|
if (selectedProductId) {
|
||||||
|
payload.productId = selectedProductId
|
||||||
|
}
|
||||||
|
|
||||||
const rawPrice = typeof creationForm.prix === 'string'
|
const rawPrice = typeof creationForm.prix === 'string'
|
||||||
? creationForm.prix.trim()
|
? creationForm.prix.trim()
|
||||||
: creationForm.prix === null || creationForm.prix === undefined
|
: creationForm.prix === null || creationForm.prix === undefined
|
||||||
|
|||||||
254
app/pages/product-catalog.vue
Normal file
254
app/pages/product-catalog.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<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>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="product in filteredProducts" :key="product.id">
|
||||||
|
<td class="font-medium">{{ product.name }}</td>
|
||||||
|
<td>{{ product.reference || '—' }}</td>
|
||||||
|
<td>{{ product.typeProduct?.name || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="product.constructeurs?.length" class="text-sm">
|
||||||
|
{{ formatConstructeurs(product.constructeurs) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{{ formatPrice(product.supplierPrice) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/product/${product.id}/edit`"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="confirmDelete(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 { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: 'Catalogue des produits',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
loadProducts,
|
||||||
|
deleteProduct,
|
||||||
|
} = useProducts()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const searchTerm = ref('')
|
||||||
|
const sortField = ref<'name' | 'createdAt'>('name')
|
||||||
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const normalizedProducts = computed(() => (Array.isArray(products.value) ? products.value : []))
|
||||||
|
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 formatConstructeurs = (constructeurs: Array<Record<string, any>>) =>
|
||||||
|
constructeurs
|
||||||
|
.map((constructeur) => constructeur?.name)
|
||||||
|
.filter((name): name is string => Boolean(name))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
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 loadProducts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
122
app/pages/product-category/[id]/edit.vue
Normal file
122
app/pages/product-category/[id]/edit.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<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"
|
||||||
|
@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 { 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 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,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(normalizeError(error))
|
||||||
|
await navigateBackToList()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigateBackToList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
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>
|
||||||
747
app/pages/product/[id]/edit.vue
Normal file
747
app/pages/product/[id]/edit.vue
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
<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..."
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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 { 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 } = useCustomFields()
|
||||||
|
const {
|
||||||
|
loadDocumentsByProduct,
|
||||||
|
uploadDocuments: uploadProductDocuments,
|
||||||
|
deleteDocument: deleteProductDocument,
|
||||||
|
} = useDocuments()
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
hydrateForm()
|
||||||
|
await refreshDocuments()
|
||||||
|
} 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 = () => {
|
||||||
|
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)
|
||||||
|
: ''
|
||||||
|
customFieldInputs.value = buildCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 payload: Record<string, any> = {
|
||||||
|
name: editionForm.name.trim(),
|
||||||
|
reference: editionForm.reference.trim() || null,
|
||||||
|
constructeurIds: uniqueConstructeurIds(editionForm.constructeurIds),
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPrice = editionForm.supplierPrice.trim()
|
||||||
|
payload.supplierPrice = rawPrice
|
||||||
|
? Number.isNaN(Number(rawPrice))
|
||||||
|
? null
|
||||||
|
: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!field.customFieldId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'product',
|
||||||
|
productId,
|
||||||
|
String(value ?? ''),
|
||||||
|
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
failed.push(field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProduct()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
518
app/pages/product/create.vue
Normal file
518
app/pages/product/create.vue
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
<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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creationForm.constructeurIds.length) {
|
||||||
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPrice = creationForm.supplierPrice.trim()
|
||||||
|
if (rawPrice) {
|
||||||
|
const parsed = Number(rawPrice)
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
payload.supplierPrice = 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.customFieldId || !field.name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const value = field.value ?? ''
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'product',
|
||||||
|
productId,
|
||||||
|
String(value ?? ''),
|
||||||
|
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
failed.push(field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ const editedType = ref({
|
|||||||
maintenanceFrequency: '',
|
maintenanceFrequency: '',
|
||||||
customFields: [],
|
customFields: [],
|
||||||
componentRequirements: [],
|
componentRequirements: [],
|
||||||
pieceRequirements: []
|
pieceRequirements: [],
|
||||||
|
productRequirements: []
|
||||||
})
|
})
|
||||||
|
|
||||||
const parseOptions = (field = {}) => {
|
const parseOptions = (field = {}) => {
|
||||||
@@ -140,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
|
|||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||||
.map((req, index) => ({ ...req, orderIndex: index }))
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
||||||
|
|
||||||
|
const normalizeProductRequirements = (requirements = []) =>
|
||||||
|
requirements
|
||||||
|
.filter(req => req?.typeProductId)
|
||||||
|
.map((req, index) => ({
|
||||||
|
typeProductId: req.typeProductId,
|
||||||
|
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 saveChanges = async () => {
|
const saveChanges = async () => {
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -151,7 +167,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)
|
||||||
@@ -192,7 +209,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;
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ 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'
|
import { uniqueConstructeurIds } from './constructeurUtils'
|
||||||
@@ -20,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,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)
|
||||||
@@ -240,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 []
|
||||||
@@ -331,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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,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) {
|
||||||
@@ -423,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,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 []
|
||||||
@@ -569,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,
|
||||||
),
|
),
|
||||||
@@ -619,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 []
|
||||||
@@ -645,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
|
||||||
@@ -662,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)
|
||||||
@@ -678,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(' • ')
|
||||||
}
|
}
|
||||||
@@ -741,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)) {
|
||||||
@@ -750,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
|
||||||
@@ -771,6 +889,10 @@ 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 []
|
||||||
@@ -811,12 +933,18 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
|||||||
.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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,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
|
||||||
@@ -859,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)
|
||||||
|
|||||||
@@ -20,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
|
||||||
@@ -44,8 +54,17 @@ export interface PieceModelCustomField {
|
|||||||
orderIndex?: number
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,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> => {
|
||||||
@@ -252,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: [],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import {
|
|||||||
formatConstructeurContact,
|
formatConstructeurContact,
|
||||||
} from '~/shared/constructeurUtils'
|
} 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' }
|
||||||
@@ -55,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',
|
||||||
@@ -94,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">
|
||||||
@@ -122,6 +184,7 @@ const renderPrintPieces = (
|
|||||||
: '—'}</span>
|
: '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${productBlock}
|
||||||
${customFieldsBlock}
|
${customFieldsBlock}
|
||||||
${documentsBlock}
|
${documentsBlock}
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +217,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
|||||||
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>
|
||||||
@@ -162,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',
|
||||||
@@ -233,7 +298,28 @@ const normalizeConstructeurList = (...sources) => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeProduct = (product) => {
|
||||||
|
if (!product) { return null }
|
||||||
|
const constructeurs = normalizeConstructeurList(
|
||||||
|
product.constructeurs,
|
||||||
|
product.constructeur,
|
||||||
|
product.constructeurIds,
|
||||||
|
product.constructeurId,
|
||||||
|
)
|
||||||
|
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 normalizePiece = piece => {
|
||||||
|
const rawProduct = piece.product || null
|
||||||
const constructeurs = normalizeConstructeurList(
|
const constructeurs = normalizeConstructeurList(
|
||||||
piece.constructeurs,
|
piece.constructeurs,
|
||||||
piece.constructeur,
|
piece.constructeur,
|
||||||
@@ -241,7 +327,12 @@ const normalizePiece = piece => {
|
|||||||
piece.originalPiece?.constructeur,
|
piece.originalPiece?.constructeur,
|
||||||
piece.constructeurIds,
|
piece.constructeurIds,
|
||||||
piece.constructeurId,
|
piece.constructeurId,
|
||||||
|
rawProduct?.constructeurs,
|
||||||
|
rawProduct?.constructeur,
|
||||||
|
rawProduct?.constructeurIds,
|
||||||
|
rawProduct?.constructeurId,
|
||||||
)
|
)
|
||||||
|
const product = normalizeProduct(rawProduct)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: piece.id,
|
id: piece.id,
|
||||||
@@ -252,11 +343,13 @@ const normalizePiece = piece => {
|
|||||||
documents: normalizeDocuments(piece.documents || []),
|
documents: normalizeDocuments(piece.documents || []),
|
||||||
constructeurs,
|
constructeurs,
|
||||||
constructeur: constructeurs[0] || null,
|
constructeur: constructeurs[0] || null,
|
||||||
|
product,
|
||||||
indexPath: piece.indexPath || null
|
indexPath: piece.indexPath || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeComponent = component => {
|
const normalizeComponent = component => {
|
||||||
|
const rawProduct = component.product || null
|
||||||
const constructeurs = normalizeConstructeurList(
|
const constructeurs = normalizeConstructeurList(
|
||||||
component.constructeurs,
|
component.constructeurs,
|
||||||
component.constructeur,
|
component.constructeur,
|
||||||
@@ -264,7 +357,12 @@ const normalizeComponent = component => {
|
|||||||
component.originalComposant?.constructeur,
|
component.originalComposant?.constructeur,
|
||||||
component.constructeurIds,
|
component.constructeurIds,
|
||||||
component.constructeurId,
|
component.constructeurId,
|
||||||
|
rawProduct?.constructeurs,
|
||||||
|
rawProduct?.constructeur,
|
||||||
|
rawProduct?.constructeurIds,
|
||||||
|
rawProduct?.constructeurId,
|
||||||
)
|
)
|
||||||
|
const product = normalizeProduct(rawProduct)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: component.id,
|
id: component.id,
|
||||||
@@ -276,6 +374,7 @@ const normalizeComponent = component => {
|
|||||||
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
||||||
constructeurs,
|
constructeurs,
|
||||||
constructeur: constructeurs[0] || null,
|
constructeur: constructeurs[0] || null,
|
||||||
|
product,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user