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>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown('products-mobile')"
|
||||
:aria-expanded="openDropdown === 'products-mobile'"
|
||||
>
|
||||
<span>Produits</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-mobile'"
|
||||
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="rounded-md px-2 py-1 transition-colors block"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li class="mt-1 border-t border-base-200 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -356,6 +411,67 @@
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('products-desktop')"
|
||||
@mouseleave="scheduleDropdownClose('products-desktop')"
|
||||
@focusin="setDropdown('products-desktop')"
|
||||
@focusout="scheduleDropdownClose('products-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category') || isActive('/product-catalog')
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
@click="toggleDropdown('products-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown('products-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown('products-desktop')"
|
||||
:aria-expanded="openDropdown === 'products-desktop'"
|
||||
>
|
||||
Produits
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === 'products-desktop'"
|
||||
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
|
||||
>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-category"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-category')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catégorie de produit
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/product-catalog"
|
||||
class="block rounded-md px-2 py-1 transition-colors"
|
||||
:class="
|
||||
isActive('/product-catalog')
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||
"
|
||||
>
|
||||
Catalogue des produits
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li
|
||||
class="relative"
|
||||
@mouseenter="setDropdown('component-desktop')"
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
</span>
|
||||
</template>
|
||||
<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
|
||||
v-if="component.typeMachineComponentRequirement"
|
||||
class="badge badge-outline badge-sm"
|
||||
@@ -124,6 +130,94 @@
|
||||
<span v-else class="font-medium">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Produit catalogue</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
|
||||
<template v-if="displayProduct">
|
||||
<span class="font-semibold text-base-content">
|
||||
{{ displayProductName || 'Produit catalogue' }}
|
||||
</span>
|
||||
<span
|
||||
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>
|
||||
@@ -432,6 +526,110 @@ const childComponents = computed(() => {
|
||||
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const buildProductDisplay = (product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? product.constructeurs
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name) => typeof name === 'string' && name.trim().length > 0)
|
||||
.join(', ')
|
||||
: product.supplierLabel || null
|
||||
|
||||
const priceValue =
|
||||
product.supplierPrice ??
|
||||
product.price ??
|
||||
product.priceLabel ??
|
||||
product.priceDisplay ??
|
||||
null
|
||||
|
||||
let price = null
|
||||
if (priceValue !== null && priceValue !== undefined) {
|
||||
const parsed = Number(priceValue)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = currencyFormatter.format(parsed)
|
||||
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
|
||||
price = priceValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name:
|
||||
product.name ||
|
||||
product.label ||
|
||||
product.reference ||
|
||||
product.productName ||
|
||||
null,
|
||||
reference: product.reference || null,
|
||||
category: product.typeProduct?.name || product.category || null,
|
||||
suppliers,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
const displayProduct = computed(() => {
|
||||
const explicit = props.component.product || null
|
||||
const normalized = buildProductDisplay(explicit)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
const fallback = props.component.__productDisplay
|
||||
if (fallback) {
|
||||
return {
|
||||
name: fallback.name || null,
|
||||
reference: fallback.reference || null,
|
||||
category: fallback.category || null,
|
||||
suppliers: fallback.suppliers || null,
|
||||
price: fallback.price || null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (displayProduct.value?.name) {
|
||||
return displayProduct.value.name
|
||||
}
|
||||
return (
|
||||
props.component.product?.name ||
|
||||
props.component.productName ||
|
||||
props.component.productLabel ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const displayProductCategory = computed(() => displayProduct.value?.category || null)
|
||||
const displayProductReference = computed(() => displayProduct.value?.reference || null)
|
||||
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
|
||||
const displayProductPrice = computed(() => displayProduct.value?.price || null)
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return []
|
||||
}
|
||||
const rows = []
|
||||
if (displayProductReference.value) {
|
||||
rows.push({ label: 'Référence', value: displayProductReference.value })
|
||||
}
|
||||
if (displayProductPrice.value) {
|
||||
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
|
||||
}
|
||||
if (displayProductSuppliers.value) {
|
||||
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
|
||||
}
|
||||
if (displayProductCategory.value) {
|
||||
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product = props.component.product
|
||||
return Array.isArray(product?.documents) ? product.documents : []
|
||||
})
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.component,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:depth="0"
|
||||
:component-types="availableComponentTypes"
|
||||
:piece-types="availablePieceTypes"
|
||||
:product-types="availableProductTypes"
|
||||
:lock-type="lockRootType"
|
||||
:locked-type-label="displayedRootTypeLabel"
|
||||
:allow-subcomponents="allowSubcomponents"
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '~/shared/modelUtils'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
|
||||
defineOptions({ name: 'ComponentModelStructureEditor' })
|
||||
@@ -62,9 +64,11 @@ const previousLockedLabel = ref(props.rootTypeLabel || '')
|
||||
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
|
||||
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
|
||||
const availableComponentTypes = computed(() => componentTypes.value ?? [])
|
||||
const availableProductTypes = computed(() => productTypes.value ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
@@ -187,9 +191,10 @@ const syncFromProps = (value: any) => {
|
||||
return
|
||||
}
|
||||
const hydrated = hydrateStructureForEditor(value)
|
||||
localStructure.customFields = hydrated.customFields
|
||||
localStructure.pieces = hydrated.pieces
|
||||
localStructure.subcomponents = hydrated.subcomponents
|
||||
localStructure.customFields = hydrated.customFields
|
||||
localStructure.pieces = hydrated.pieces
|
||||
localStructure.products = hydrated.products
|
||||
localStructure.subcomponents = hydrated.subcomponents
|
||||
localStructure.typeComposantId = hydrated.typeComposantId
|
||||
localStructure.typeComposantLabel = hydrated.typeComposantLabel
|
||||
localStructure.modelId = hydrated.modelId
|
||||
@@ -243,6 +248,9 @@ onMounted(async () => {
|
||||
if (!availableComponentTypes.value.length) {
|
||||
loaders.push(loadComponentTypes())
|
||||
}
|
||||
if (!availableProductTypes.value.length) {
|
||||
loaders.push(loadProductTypes())
|
||||
}
|
||||
if (loaders.length) {
|
||||
await Promise.allSettled(loaders)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,44 @@
|
||||
</div>
|
||||
</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">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
@@ -81,9 +119,11 @@
|
||||
:key="subAssignment.path"
|
||||
:assignment="subAssignment"
|
||||
:pieces="pieces"
|
||||
:products="products"
|
||||
:components="components"
|
||||
:components-loading="componentsLoading"
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</section>
|
||||
@@ -95,6 +135,7 @@ import { computed, watch } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory';
|
||||
|
||||
@@ -122,17 +163,36 @@ interface PieceOption {
|
||||
} | 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 {
|
||||
path: string;
|
||||
definition: ComponentModelPiece;
|
||||
selectedPieceId: string;
|
||||
}
|
||||
|
||||
export interface StructureProductAssignment {
|
||||
path: string;
|
||||
definition: ComponentModelProduct;
|
||||
selectedProductId: string;
|
||||
}
|
||||
|
||||
export interface StructureAssignmentNode {
|
||||
path: string;
|
||||
definition: ComponentModelStructureNode;
|
||||
selectedComponentId: string;
|
||||
pieces: StructurePieceAssignment[];
|
||||
products: StructureProductAssignment[];
|
||||
subcomponents: StructureAssignmentNode[];
|
||||
}
|
||||
|
||||
@@ -140,17 +200,21 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
assignment: StructureAssignmentNode;
|
||||
pieces: PieceOption[] | null;
|
||||
products: ProductOption[] | null;
|
||||
components: ComponentOption[] | null;
|
||||
depth?: number;
|
||||
componentsLoading?: boolean;
|
||||
piecesLoading?: boolean;
|
||||
productsLoading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
pieces: () => [],
|
||||
products: () => [],
|
||||
components: () => [],
|
||||
componentsLoading: false,
|
||||
piecesLoading: false,
|
||||
productsLoading: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -269,6 +333,102 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
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 definition = props.assignment.definition || {};
|
||||
const alias = definition.alias || definition.typeComposantLabel;
|
||||
@@ -377,4 +537,20 @@ watch(
|
||||
},
|
||||
{ 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>
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
>
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,6 +119,120 @@
|
||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
||||
}}</span>
|
||||
</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>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
@@ -364,6 +484,7 @@
|
||||
<script setup>
|
||||
import { reactive, onMounted, watch, ref, computed } from "vue";
|
||||
import ConstructeurSelect from "./ConstructeurSelect.vue";
|
||||
import ProductSelect from "~/components/ProductSelect.vue";
|
||||
import { useConstructeurs } from "~/composables/useConstructeurs";
|
||||
import { useCustomFields } from "~/composables/useCustomFields";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
@@ -373,6 +494,7 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
|
||||
import DocumentUpload from "~/components/DocumentUpload.vue";
|
||||
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
|
||||
import IconLucidePackage from "~icons/lucide/package";
|
||||
import { useProducts } from "~/composables/useProducts";
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
@@ -401,6 +523,7 @@ const pieceData = reactive({
|
||||
name: props.piece.name || "",
|
||||
reference: props.piece.reference || "",
|
||||
prix: props.piece.prix || "",
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
});
|
||||
|
||||
const selectedFiles = ref([]);
|
||||
@@ -779,6 +902,7 @@ const candidateCustomFields = computed(() => {
|
||||
});
|
||||
|
||||
const { constructeurs } = useConstructeurs();
|
||||
const { products, loadProducts, getProduct } = useProducts();
|
||||
|
||||
const pieceConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
@@ -800,6 +924,237 @@ const pieceConstructeursDisplay = computed(() =>
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur);
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
});
|
||||
|
||||
const selectedProduct = computed(() => {
|
||||
const id = pieceData.productId;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const list = Array.isArray(products.value) ? products.value : [];
|
||||
const cached = list.find((product) => product && product.id === id) || null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const current = props.piece.product;
|
||||
if (current && current.id === id) {
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const productConstructeurs = computed(() => {
|
||||
const product = selectedProduct.value;
|
||||
if (!product) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(product.constructeurs) ? product.constructeurs : [];
|
||||
return list.filter((item) => item && typeof item === "object");
|
||||
});
|
||||
|
||||
const productConstructeurNames = computed(() => {
|
||||
const list = productConstructeurs.value;
|
||||
if (!list.length) {
|
||||
return "";
|
||||
}
|
||||
return list
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name) => typeof name === "string" && name.trim().length > 0)
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
const productSupplierPrice = computed(() => {
|
||||
const product = selectedProduct.value;
|
||||
if (!product || product.supplierPrice === undefined || product.supplierPrice === null) {
|
||||
return null;
|
||||
}
|
||||
const number = Number(product.supplierPrice);
|
||||
if (Number.isNaN(number)) {
|
||||
return null;
|
||||
}
|
||||
return currencyFormatter.format(number);
|
||||
});
|
||||
|
||||
const displayProduct = computed(() => selectedProduct.value || props.piece.__productDisplay || null);
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return null
|
||||
}
|
||||
const product = displayProduct.value
|
||||
return (
|
||||
product.name ||
|
||||
product.label ||
|
||||
product.reference ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const displayProductCategory = computed(() =>
|
||||
displayProduct.value
|
||||
? displayProduct.value.typeProduct?.name ||
|
||||
displayProduct.value.category ||
|
||||
null
|
||||
: null,
|
||||
);
|
||||
|
||||
const displayProductReference = computed(() =>
|
||||
displayProduct.value ? displayProduct.value.reference || null : null,
|
||||
);
|
||||
|
||||
const displayProductSuppliers = computed(() => {
|
||||
if (selectedProduct.value) {
|
||||
return productConstructeurNames.value;
|
||||
}
|
||||
if (displayProduct.value) {
|
||||
return (
|
||||
displayProduct.value.suppliers ||
|
||||
displayProduct.value.supplierLabel ||
|
||||
null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const displayProductPrice = computed(() => {
|
||||
if (selectedProduct.value) {
|
||||
return productSupplierPrice.value;
|
||||
}
|
||||
if (displayProduct.value) {
|
||||
const price =
|
||||
displayProduct.value.price ||
|
||||
displayProduct.value.priceLabel ||
|
||||
displayProduct.value.priceDisplay ||
|
||||
displayProduct.value.priceLabel;
|
||||
return price || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return [];
|
||||
}
|
||||
const rows = [];
|
||||
if (displayProductReference.value) {
|
||||
rows.push({ label: "Référence", value: displayProductReference.value });
|
||||
}
|
||||
if (displayProductPrice.value) {
|
||||
rows.push({ label: "Prix indicatif", value: displayProductPrice.value });
|
||||
}
|
||||
if (displayProductSuppliers.value) {
|
||||
rows.push({ label: "Fournisseur(s)", value: displayProductSuppliers.value });
|
||||
}
|
||||
if (displayProductCategory.value) {
|
||||
rows.push({ label: "Catégorie", value: displayProductCategory.value });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product =
|
||||
selectedProduct.value ||
|
||||
props.piece.product ||
|
||||
null;
|
||||
return Array.isArray(product?.documents) ? product.documents : [];
|
||||
});
|
||||
|
||||
const ensureProductLoaded = async (id) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const list = Array.isArray(products.value) ? products.value : [];
|
||||
const cached = list.find((product) => product && product.id === id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await getProduct(id, { force: true });
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts().catch(() => {});
|
||||
if (pieceData.productId) {
|
||||
ensureProductLoaded(pieceData.productId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.piece.product?.id || props.piece.productId || null,
|
||||
async (id, prevId) => {
|
||||
if (pieceData.productId === id) {
|
||||
if (id && !selectedProduct.value) {
|
||||
const resolved = await ensureProductLoaded(id);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
props.piece.product = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
pieceData.productId = id;
|
||||
if (id) {
|
||||
const resolved = await ensureProductLoaded(id);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
const supplierPrice = resolved.supplierPrice;
|
||||
if (
|
||||
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
supplierPrice !== null &&
|
||||
supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(supplierPrice);
|
||||
if (!Number.isNaN(number)) {
|
||||
pieceData.prix = String(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.piece.product = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleProductChange = async (value) => {
|
||||
const nextId = value || null;
|
||||
pieceData.productId = nextId;
|
||||
props.piece.productId = nextId;
|
||||
|
||||
if (!nextId) {
|
||||
props.piece.product = null;
|
||||
updatePiece();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await ensureProductLoaded(nextId);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
const supplierPrice = resolved.supplierPrice;
|
||||
if (
|
||||
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
supplierPrice !== null &&
|
||||
supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(supplierPrice);
|
||||
if (!Number.isNaN(number)) {
|
||||
pieceData.prix = String(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePiece();
|
||||
};
|
||||
|
||||
const handleConstructeurChange = (value) => {
|
||||
const ids = uniqueConstructeurIds(value);
|
||||
props.piece.constructeurIds = [...ids];
|
||||
@@ -1060,10 +1415,24 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
|
||||
|
||||
const updatePiece = () => {
|
||||
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", {
|
||||
...props.piece,
|
||||
...pieceData,
|
||||
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null,
|
||||
prix: parsedPrice,
|
||||
productId: pieceData.productId || null,
|
||||
product,
|
||||
constructeurIds: pieceConstructeurIds.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,32 +1,95 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
<div class="space-y-6">
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé n'a encore été défini.
|
||||
</p>
|
||||
<p v-if="!products.length" class="text-xs text-gray-500">
|
||||
Aucun produit défini.
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(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"
|
||||
>
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(product, index) in products"
|
||||
:key="product.uid"
|
||||
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Famille de produit</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une famille
|
||||
</option>
|
||||
<option
|
||||
v-for="type in productTypeOptions"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ formatProductTypeOption(type) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeProduct(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,25 +150,34 @@
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import type {
|
||||
PieceModelCustomField,
|
||||
PieceModelCustomFieldType,
|
||||
PieceModelProduct,
|
||||
PieceModelStructure,
|
||||
PieceModelStructureEditorField,
|
||||
} from '~/shared/types/inventory'
|
||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
|
||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||
|
||||
type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||
type EditorProduct = {
|
||||
uid: string
|
||||
typeProductId: string
|
||||
typeProductLabel: string
|
||||
familyCode: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
@@ -115,12 +187,15 @@ const emit = defineEmits<{
|
||||
(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 =>
|
||||
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 {
|
||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||
} catch {
|
||||
@@ -132,17 +207,19 @@ const extractRest = (structure?: PieceModelStructure | null): Record<string, unk
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
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), {})
|
||||
}
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (): string => {
|
||||
const createUid = (scope: 'field' | 'product'): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `piece-field-${Date.now().toString(36)}-${uidCounter}`
|
||||
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
const toEditorField = (
|
||||
@@ -159,7 +236,7 @@ const toEditorField = (
|
||||
)
|
||||
|
||||
return {
|
||||
uid: createUid(),
|
||||
uid: createUid('field'),
|
||||
name: typeof input?.name === 'string' ? input.name : '',
|
||||
type: baseType as PieceModelCustomFieldType,
|
||||
required: Boolean(input?.required),
|
||||
@@ -176,7 +253,81 @@ const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] =>
|
||||
.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 products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
@@ -185,8 +336,30 @@ const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
||||
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
||||
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
||||
|
||||
if (!typeProductId && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: PieceModelProduct = {}
|
||||
if (typeProductId) {
|
||||
payload.typeProductId = typeProductId
|
||||
}
|
||||
if (familyCode) {
|
||||
payload.familyCode = familyCode
|
||||
}
|
||||
if (product.typeProductLabel) {
|
||||
payload.typeProductLabel = product.typeProductLabel
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const buildPayload = (
|
||||
fieldsSource: EditorField[],
|
||||
productsSource: EditorProduct[],
|
||||
restSource: Record<string, unknown>,
|
||||
): PieceModelStructure => {
|
||||
const normalizedFields = fieldsSource
|
||||
@@ -219,8 +392,13 @@ const buildPayload = (
|
||||
})
|
||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||
|
||||
const normalizedProducts = productsSource
|
||||
.map((product) => normalizeProductEntry(product))
|
||||
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||
|
||||
const draft: PieceModelStructure = {
|
||||
...safeClone(restSource, {}),
|
||||
products: normalizedProducts,
|
||||
customFields: normalizedFields,
|
||||
}
|
||||
|
||||
@@ -234,7 +412,7 @@ const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
||||
let lastEmitted = serializeStructure(props.modelValue)
|
||||
|
||||
const emitUpdate = () => {
|
||||
const payload = buildPayload(fields.value, restState.value)
|
||||
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||
const serialized = JSON.stringify(payload)
|
||||
if (serialized !== lastEmitted) {
|
||||
lastEmitted = serialized
|
||||
@@ -243,6 +421,10 @@ const emitUpdate = () => {
|
||||
}
|
||||
|
||||
watch(fields, emitUpdate, { deep: true })
|
||||
watch(products, emitUpdate, { deep: true })
|
||||
watch(productTypeOptions, () => {
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -253,11 +435,20 @@ watch(
|
||||
}
|
||||
restState.value = extractRest(value)
|
||||
fields.value = hydrateFields(value)
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypeOptions.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
@@ -328,7 +519,7 @@ const reorderClass = (index: number) => {
|
||||
}
|
||||
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid(),
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
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>
|
||||
</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">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">
|
||||
@@ -251,6 +314,7 @@
|
||||
:depth="depth + 1"
|
||||
:component-types="componentTypes"
|
||||
:piece-types="pieceTypes"
|
||||
:product-types="productTypes"
|
||||
:allow-subcomponents="childAllowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
@remove="removeSubComponent(index)"
|
||||
@@ -268,7 +332,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
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' })
|
||||
|
||||
@@ -281,6 +345,7 @@ type ModelTypeOption = {
|
||||
type EditableStructureNode = ComponentModelStructureNode & {
|
||||
customFields?: any[]
|
||||
pieces?: ComponentModelPiece[]
|
||||
products?: ComponentModelProduct[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -288,6 +353,7 @@ const props = withDefaults(defineProps<{
|
||||
depth?: number
|
||||
componentTypes?: ModelTypeOption[]
|
||||
pieceTypes?: ModelTypeOption[]
|
||||
productTypes?: ModelTypeOption[]
|
||||
isRoot?: boolean
|
||||
lockType?: boolean
|
||||
lockedTypeLabel?: string
|
||||
@@ -297,6 +363,7 @@ const props = withDefaults(defineProps<{
|
||||
depth: 0,
|
||||
componentTypes: () => [],
|
||||
pieceTypes: () => [],
|
||||
productTypes: () => [],
|
||||
isRoot: false,
|
||||
lockType: false,
|
||||
lockedTypeLabel: '',
|
||||
@@ -308,6 +375,7 @@ const emit = defineEmits(['remove'])
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
@@ -372,6 +440,16 @@ const pieceTypeMap = computed(() => {
|
||||
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) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||
@@ -382,16 +460,26 @@ const getPieceTypeLabel = (id?: string) => {
|
||||
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) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
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 (key === 'subcomponents') {
|
||||
props.node.subcomponents = []
|
||||
} else if (key === 'products') {
|
||||
props.node.products = []
|
||||
} else {
|
||||
(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[]) => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
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) => {
|
||||
syncComponentType(component)
|
||||
}
|
||||
@@ -524,6 +652,25 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) {
|
||||
return
|
||||
}
|
||||
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
|
||||
if (!id) {
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
const option = productTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
product.typeProductId = ''
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
}
|
||||
|
||||
const customFieldDragState = ref({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
@@ -633,6 +780,20 @@ const removePiece = (index: number) => {
|
||||
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 = () => {
|
||||
if (!canManageSubcomponents.value) {
|
||||
return
|
||||
@@ -655,6 +816,8 @@ const removeSubComponent = (index: number) => {
|
||||
|
||||
const draggingPieceIndex = 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 subcomponentDropTargetIndex = ref<number | null>(null)
|
||||
|
||||
@@ -676,6 +839,11 @@ const resetPieceDragState = () => {
|
||||
pieceDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const resetProductDragState = () => {
|
||||
draggingProductIndex.value = null
|
||||
productDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onPieceDragStart = (index: number, event: DragEvent) => {
|
||||
draggingPieceIndex.value = index
|
||||
pieceDropTargetIndex.value = index
|
||||
@@ -728,6 +896,58 @@ const pieceReorderClass = (index: number) => {
|
||||
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 = () => {
|
||||
draggingSubcomponentIndex.value = null
|
||||
subcomponentDropTargetIndex.value = null
|
||||
@@ -818,6 +1038,18 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(productTypes, () => {
|
||||
syncProductLabels(props.node?.products)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.products,
|
||||
(value) => {
|
||||
syncProductLabels(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.customFields,
|
||||
(value) => {
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
@update:model-value="(value) => (formData.pieceRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditProductRequirementsSection
|
||||
:model-value="formData.productRequirements"
|
||||
@update:model-value="(value) => (formData.productRequirements = value)"
|
||||
/>
|
||||
|
||||
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
|
||||
</form>
|
||||
</template>
|
||||
@@ -38,6 +43,7 @@ import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSectio
|
||||
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
|
||||
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
|
||||
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
|
||||
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -80,7 +86,8 @@ const createDefaultForm = (source = {}) => ({
|
||||
maintenanceFrequency: source.maintenanceFrequency || '',
|
||||
customFields: withNormalizedOrder(source.customFields || []),
|
||||
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
|
||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || [])
|
||||
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
|
||||
productRequirements: withNormalizedOrder(source.productRequirements || []),
|
||||
})
|
||||
|
||||
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>Familles de composants:</strong> {{ type.componentRequirements?.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">
|
||||
<strong>Description:</strong> {{ type.description }}
|
||||
</p>
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
import { useToast } from "~/composables/useToast";
|
||||
|
||||
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(
|
||||
defineProps<{
|
||||
@@ -210,8 +210,15 @@ const onOffsetChange = (value: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveCategoryBasePath = (category: ModelCategory) =>
|
||||
category === "COMPONENT" ? "/component-category" : "/piece-category";
|
||||
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component-category";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/piece-category";
|
||||
}
|
||||
return "/product-category";
|
||||
};
|
||||
|
||||
const openCreatePage = () => {
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
>
|
||||
<option value="COMPONENT">Composants</option>
|
||||
<option value="PIECE">Pièces</option>
|
||||
<option value="PRODUCT">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-else-if="form.category === 'PIECE'"
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
@@ -93,6 +94,17 @@
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -114,12 +126,16 @@ import ComponentModelStructureEditor from '~/components/ComponentModelStructureE
|
||||
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
||||
import {
|
||||
clonePieceStructure,
|
||||
cloneProductStructure,
|
||||
cloneStructure,
|
||||
defaultPieceStructure,
|
||||
defaultProductStructure,
|
||||
defaultStructure,
|
||||
formatPieceStructurePreview,
|
||||
formatProductStructurePreview,
|
||||
formatStructurePreview,
|
||||
normalizePieceStructureForSave,
|
||||
normalizeProductStructureForSave,
|
||||
normalizeStructureForEditor,
|
||||
normalizeStructureForSave,
|
||||
} from '~/shared/modelUtils'
|
||||
@@ -171,6 +187,7 @@ const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
|
||||
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
|
||||
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
|
||||
|
||||
const generateCodeFromName = (name: string) => {
|
||||
const fallback = 'type'
|
||||
@@ -196,10 +213,19 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
|
||||
return
|
||||
}
|
||||
|
||||
pieceStructure.value = normalizePieceStructureForSave(
|
||||
incomingStructure && props.initialData?.category === 'PIECE'
|
||||
? incomingStructure
|
||||
: defaultPieceStructure(),
|
||||
if (category === 'PIECE') {
|
||||
pieceStructure.value = normalizePieceStructureForSave(
|
||||
incomingStructure && props.initialData?.category === 'PIECE'
|
||||
? incomingStructure
|
||||
: defaultPieceStructure(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
productStructure.value = normalizeProductStructureForSave(
|
||||
incomingStructure && props.initialData?.category === 'PRODUCT'
|
||||
? cloneProductStructure(incomingStructure)
|
||||
: defaultProductStructure(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,10 +289,19 @@ const handleSubmit = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (form.category === 'PIECE') {
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
category: 'PRODUCT',
|
||||
structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,11 +346,16 @@ watch(
|
||||
if (category === 'PIECE') {
|
||||
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
|
||||
}
|
||||
|
||||
if (category === 'PRODUCT') {
|
||||
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
|
||||
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
|
||||
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => nameInput.value?.focus())
|
||||
|
||||
@@ -131,6 +131,7 @@ const emit = defineEmits<{
|
||||
const categoryDictionary: Record<ModelCategory, string> = {
|
||||
COMPONENT: 'Composants',
|
||||
PIECE: 'Pièces',
|
||||
PRODUCT: 'Produits',
|
||||
};
|
||||
|
||||
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
|
||||
|
||||
@@ -78,12 +78,13 @@
|
||||
import { computed } from 'vue';
|
||||
import IconLucidePlus from '~icons/lucide/plus';
|
||||
import IconLucideSearch from '~icons/lucide/search';
|
||||
import type { ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
type SortField = 'name' | 'createdAt';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const props = defineProps<{
|
||||
category: 'COMPONENT' | 'PIECE';
|
||||
category: ModelCategory;
|
||||
search: string;
|
||||
sort: SortField;
|
||||
dir: SortDirection;
|
||||
@@ -92,16 +93,17 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void;
|
||||
(e: 'update:category', value: ModelCategory): void;
|
||||
(e: 'update:search', value: string): void;
|
||||
(e: 'update:sort', value: SortField): void;
|
||||
(e: 'update:dir', value: SortDirection): void;
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [
|
||||
const categories: Array<{ label: string; value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
{ label: 'Pièces', value: 'PIECE' },
|
||||
{ label: 'Produits', value: 'PRODUCT' },
|
||||
];
|
||||
|
||||
const onSearch = (event: Event) => {
|
||||
|
||||
@@ -60,6 +60,11 @@ export function useDocuments () {
|
||||
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByProduct = async (productId, options = {}) => {
|
||||
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
|
||||
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByPiece = async (pieceId, options = {}) => {
|
||||
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
|
||||
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
|
||||
@@ -140,6 +145,7 @@ export function useDocuments () {
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByComponent,
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
deleteDocument
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import { useApi } from './useApi'
|
||||
const machineTypes = ref([])
|
||||
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 () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
|
||||
try {
|
||||
const result = await get('/types/machines')
|
||||
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`)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
|
||||
try {
|
||||
const result = await post('/types/machines', typeData)
|
||||
if (result.success) {
|
||||
machineTypes.value.push(result.data)
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
|
||||
}
|
||||
return result
|
||||
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
|
||||
try {
|
||||
const result = await patch(`/types/machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
machineTypes.value[index] = result.data
|
||||
machineTypes.value[index] = normalized
|
||||
}
|
||||
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}`)
|
||||
if (result.success) {
|
||||
// Ajouter au cache local
|
||||
machineTypes.value.push(result.data)
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
}
|
||||
return result
|
||||
} 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>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -107,6 +108,7 @@
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>{{ resolveComponentType(component) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
@@ -178,6 +180,17 @@ const resolvePreviewAlt = (component: Record<string, any>) => {
|
||||
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 blockingReasons: string[] = []
|
||||
const machineLinks = Array.isArray(component?.machineLinks)
|
||||
|
||||
@@ -445,7 +445,6 @@ const loadingDocuments = ref(false)
|
||||
const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
|
||||
@@ -148,6 +148,18 @@
|
||||
</ul>
|
||||
</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">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<ComponentStructureAssignmentNode
|
||||
v-else-if="structureAssignments"
|
||||
:assignment="structureAssignments"
|
||||
:pieces="availablePieces"
|
||||
:products="availableProducts"
|
||||
:components="availableComponents"
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:components-loading="componentsLoading"
|
||||
/>
|
||||
<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 { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
@@ -342,6 +357,7 @@ import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/mo
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructure,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory'
|
||||
@@ -367,6 +383,11 @@ const {
|
||||
loadPieces,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loadProducts,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
@@ -387,9 +408,10 @@ const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||
const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value,
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
)
|
||||
|
||||
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 = (
|
||||
definition: ComponentModelStructureNode | ComponentModelStructure,
|
||||
path: string,
|
||||
@@ -496,6 +533,12 @@ const buildAssignmentNode = (
|
||||
selectedPieceId: '',
|
||||
}))
|
||||
|
||||
const products = extractProductsFromNode(definition).map((product, index) => ({
|
||||
path: `${path}:product-${index}`,
|
||||
definition: product,
|
||||
selectedProductId: '',
|
||||
}))
|
||||
|
||||
const subcomponents = extractSubcomponents(definition).map(
|
||||
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
|
||||
)
|
||||
@@ -505,6 +548,7 @@ const buildAssignmentNode = (
|
||||
definition,
|
||||
selectedComponentId: '',
|
||||
pieces,
|
||||
products,
|
||||
subcomponents,
|
||||
}
|
||||
}
|
||||
@@ -522,7 +566,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
|
||||
if (!node) {
|
||||
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 node.subcomponents.some((child) => hasAssignments(child))
|
||||
@@ -539,13 +583,21 @@ const isAssignmentNodeComplete = (
|
||||
const piecesComplete = node.pieces.every(
|
||||
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
|
||||
)
|
||||
const productsComplete = node.products.every(
|
||||
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
|
||||
)
|
||||
const subcomponentsComplete = node.subcomponents.every(
|
||||
(child) =>
|
||||
!!child.selectedComponentId &&
|
||||
child.selectedComponentId.length > 0 &&
|
||||
isAssignmentNodeComplete(child, false),
|
||||
)
|
||||
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId)
|
||||
return (
|
||||
piecesComplete &&
|
||||
productsComplete &&
|
||||
subcomponentsComplete &&
|
||||
(isRootNode || !!node.selectedComponentId)
|
||||
)
|
||||
}
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
@@ -588,6 +640,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
|
||||
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 = (
|
||||
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
|
||||
.map((child) => serializeNode(child, false))
|
||||
.filter((child) => Object.keys(child).length > 0)
|
||||
@@ -624,6 +695,9 @@ const serializeStructureAssignments = (
|
||||
if (serializedPieces.length) {
|
||||
base.pieces = serializedPieces
|
||||
}
|
||||
if (serializedProducts.length) {
|
||||
base.products = serializedProducts
|
||||
}
|
||||
if (serializedSubcomponents.length) {
|
||||
base.subcomponents = serializedSubcomponents
|
||||
}
|
||||
@@ -634,6 +708,7 @@ const serializeStructureAssignments = (
|
||||
const serializedRoot = serializeNode(root, true)
|
||||
if (
|
||||
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
|
||||
(!serializedRoot.products || serializedRoot.products.length === 0) &&
|
||||
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
|
||||
) {
|
||||
return null
|
||||
@@ -682,6 +757,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
@@ -709,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
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 parts: string[] = []
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -829,6 +936,7 @@ onMounted(async () => {
|
||||
loadComponentTypes(),
|
||||
loadPieces(),
|
||||
loadComposants(),
|
||||
loadProducts(),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -423,6 +423,12 @@
|
||||
selectedMachineType.pieceRequirements?.length || 0
|
||||
}}</span>
|
||||
</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">
|
||||
<span class="font-medium">Catégorie :</span>
|
||||
<span class="badge badge-outline badge-sm">{{
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
pièces</span
|
||||
>
|
||||
</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 class="card-actions justify-end mt-4">
|
||||
<button
|
||||
@@ -99,6 +106,7 @@ import { useToast } from "~/composables/useToast";
|
||||
import IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucidePackage from "~icons/lucide/package";
|
||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||
import IconLucideBox from "~icons/lucide/box";
|
||||
|
||||
const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
|
||||
useMachineTypesApi();
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
||||
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
||||
</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>
|
||||
</article>
|
||||
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
import IconLucideList from '~icons/lucide/list'
|
||||
import IconLucideBox from '~icons/lucide/box'
|
||||
|
||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||
const { showError } = useToast()
|
||||
@@ -100,7 +105,8 @@ const createEmptyType = () => ({
|
||||
maintenanceFrequency: '',
|
||||
customFields: [],
|
||||
componentRequirements: [],
|
||||
pieceRequirements: []
|
||||
pieceRequirements: [],
|
||||
productRequirements: []
|
||||
})
|
||||
|
||||
const draftType = ref(createEmptyType())
|
||||
@@ -187,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.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 => ({
|
||||
name: typeData.name,
|
||||
description: typeData.description,
|
||||
@@ -194,7 +215,8 @@ const buildPayload = typeData => ({
|
||||
maintenanceFrequency: typeData.maintenanceFrequency,
|
||||
customFields: normalizeCustomFields(typeData.customFields),
|
||||
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
||||
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
|
||||
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
|
||||
productRequirements: normalizeProductRequirements(typeData.productRequirements)
|
||||
})
|
||||
|
||||
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="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</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="font-medium">Catégorie :</span>
|
||||
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
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 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 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="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -486,6 +610,73 @@
|
||||
Aucun groupe de pièces à configurer pour ce type.
|
||||
</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
|
||||
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
|
||||
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 { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
@@ -566,6 +759,7 @@ const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||
const toast = useToast()
|
||||
|
||||
const submitting = ref(false)
|
||||
@@ -579,6 +773,7 @@ const newMachine = reactive({
|
||||
|
||||
const componentRequirementSelections = reactive({})
|
||||
const pieceRequirementSelections = reactive({})
|
||||
const productRequirementSelections = reactive({})
|
||||
|
||||
const selectedMachineType = computed(() => {
|
||||
if (!newMachine.typeMachineId) {
|
||||
@@ -604,7 +799,12 @@ const machineTypeDescription = (type) => {
|
||||
}
|
||||
const componentCount = type.componentRequirements?.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(' • ')
|
||||
}
|
||||
|
||||
@@ -630,6 +830,17 @@ const pieceById = computed(() => {
|
||||
|
||||
const componentInventory = computed(() => composants.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)
|
||||
|
||||
@@ -904,6 +1115,11 @@ const componentOptionDescription = (component) => {
|
||||
if (machineAssignments.length) {
|
||||
parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||
}
|
||||
const productTypeName = component.product?.typeProduct?.name
|
||||
const productLabel = component.product?.name || component.product?.reference
|
||||
if (productTypeName || productLabel) {
|
||||
parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
@@ -929,9 +1145,83 @@ const pieceOptionDescription = (piece) => {
|
||||
if (componentAssignments.length) {
|
||||
parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
||||
}
|
||||
const productTypeName = piece.product?.typeProduct?.name
|
||||
const productLabel = piece.product?.name || piece.product?.reference
|
||||
if (productTypeName || productLabel) {
|
||||
parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
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 entries = getComponentRequirementEntries(requirement.id)
|
||||
const entry = entries[index]
|
||||
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
|
||||
}
|
||||
return pieceById.value.get(id) || null
|
||||
}
|
||||
|
||||
const findProductById = (id) => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return productById.value.get(id) || null
|
||||
}
|
||||
const getStatusBadgeClass = (status) => {
|
||||
if (status === 'ready') {
|
||||
return 'badge-success'
|
||||
@@ -1003,6 +1300,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
|
||||
|
||||
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
|
||||
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
|
||||
const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
|
||||
|
||||
const createComponentSelectionEntry = (requirement, source = null) => ({
|
||||
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
|
||||
@@ -1016,6 +1314,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
|
||||
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 = () => {
|
||||
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||
delete componentRequirementSelections[key]
|
||||
@@ -1023,6 +1485,9 @@ const clearRequirementSelections = () => {
|
||||
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||
delete pieceRequirementSelections[key]
|
||||
})
|
||||
Object.keys(productRequirementSelections).forEach((key) => {
|
||||
delete productRequirementSelections[key]
|
||||
})
|
||||
}
|
||||
|
||||
const addComponentSelectionEntry = (requirement) => {
|
||||
@@ -1061,6 +1526,51 @@ const removePieceSelectionEntry = (requirementId, 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) => {
|
||||
if (!isPlainObject(source)) {
|
||||
return {}
|
||||
@@ -1113,6 +1623,7 @@ const validateRequirementSelections = (type) => {
|
||||
const errors = []
|
||||
const componentLinksPayload = []
|
||||
const pieceLinksPayload = []
|
||||
const productLinksPayload = []
|
||||
|
||||
for (const requirement of type.componentRequirements || []) {
|
||||
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) {
|
||||
return { valid: false, error: errors[0] }
|
||||
}
|
||||
@@ -1224,6 +1787,7 @@ const validateRequirementSelections = (type) => {
|
||||
valid: true,
|
||||
componentLinks: componentLinksPayload,
|
||||
pieceLinks: pieceLinksPayload,
|
||||
productLinks: productLinksPayload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1425,20 +1989,24 @@ const machinePreview = computed(() => {
|
||||
issues,
|
||||
completed,
|
||||
total: entries.length,
|
||||
status
|
||||
}
|
||||
})
|
||||
status
|
||||
}
|
||||
})
|
||||
|
||||
const { stats: productGroups } = buildProductRequirementStats(type)
|
||||
|
||||
const aggregatedIssues = [
|
||||
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
|
||||
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label })))
|
||||
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||
...productGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
|
||||
]
|
||||
|
||||
const statuses = [
|
||||
baseStatus,
|
||||
...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')
|
||||
@@ -1455,11 +2023,14 @@ const machinePreview = computed(() => {
|
||||
},
|
||||
componentGroups,
|
||||
pieceGroups,
|
||||
productGroups,
|
||||
type: {
|
||||
name: type.name,
|
||||
category: type.category || null,
|
||||
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,
|
||||
ready: overallStatus === 'ready',
|
||||
@@ -1508,6 +2079,7 @@ const handleIssueClick = (issue) => {
|
||||
const initializeRequirementSelections = (type) => {
|
||||
const componentRequirements = type.componentRequirements || []
|
||||
const pieceRequirements = type.pieceRequirements || []
|
||||
const productRequirements = type.productRequirements || []
|
||||
|
||||
componentRequirements.forEach((requirement) => {
|
||||
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
|
||||
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 () => {
|
||||
@@ -1553,10 +2138,14 @@ const finalizeMachineCreation = async () => {
|
||||
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 pieceLinks = []
|
||||
let productLinks = []
|
||||
|
||||
if (hasRequirements) {
|
||||
const validationResult = validateRequirementSelections(type)
|
||||
@@ -1566,6 +2155,7 @@ const finalizeMachineCreation = async () => {
|
||||
}
|
||||
componentLinks = validationResult.componentLinks
|
||||
pieceLinks = validationResult.pieceLinks
|
||||
productLinks = validationResult.productLinks
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
|
||||
...(hasRequirements
|
||||
? {
|
||||
componentLinks,
|
||||
pieceLinks
|
||||
pieceLinks,
|
||||
productLinks
|
||||
}
|
||||
: {})
|
||||
}
|
||||
@@ -1621,7 +2212,8 @@ onMounted(async () => {
|
||||
loadSites(),
|
||||
loadMachineTypes(),
|
||||
loadComposants(),
|
||||
loadPieces()
|
||||
loadPieces(),
|
||||
loadProducts()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de pièce</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -106,6 +107,7 @@
|
||||
</td>
|
||||
<td>{{ piece.name || 'Pièce sans nom' }}</td>
|
||||
<td>{{ piece.reference || '—' }}</td>
|
||||
<td>{{ resolvePieceType(piece) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
@@ -180,6 +182,17 @@ const resolvePreviewAlt = (piece: Record<string, any>) => {
|
||||
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 blockingReasons: string[] = []
|
||||
const machineLinks = Array.isArray(piece?.machineLinks)
|
||||
|
||||
@@ -123,6 +123,36 @@
|
||||
</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 class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -356,6 +386,7 @@ import { useRoute, useRouter } from '#imports'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
@@ -366,7 +397,7 @@ import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
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'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
@@ -411,6 +442,7 @@ const editionForm = reactive({
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
@@ -542,6 +574,42 @@ const selectedType = computed(() => {
|
||||
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(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
@@ -554,12 +622,15 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
piece.value &&
|
||||
editionForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
!saving.value,
|
||||
))
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
piece.value &&
|
||||
editionForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || editionForm.productId) &&
|
||||
!saving.value,
|
||||
),
|
||||
)
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -610,6 +681,7 @@ watch(
|
||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
|
||||
|
||||
customFieldInputs.value = buildCustomFieldInputs(
|
||||
currentType?.structure ?? null,
|
||||
@@ -636,6 +708,11 @@ const submitEdition = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !editionForm.productId) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
@@ -650,6 +727,12 @@ const submitEdition = async () => {
|
||||
payload.reference = reference ? reference : null
|
||||
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
const selectedProductId =
|
||||
typeof editionForm.productId === 'string'
|
||||
? editionForm.productId.trim()
|
||||
: ''
|
||||
payload.productId = selectedProductId || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
@@ -841,7 +924,11 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
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) => ({
|
||||
customFieldName: field.name,
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-model="creationForm.constructeurId"
|
||||
v-model="creationForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="submitting || !selectedType"
|
||||
placeholder="Rechercher un fournisseur..."
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +96,36 @@
|
||||
</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 class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -253,6 +283,7 @@ 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 ProductSelect from '~/components/ProductSelect.vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
@@ -261,7 +292,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
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'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
@@ -286,6 +317,7 @@ const creationForm = reactive({
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
|
||||
const lastSuggestedName = ref('')
|
||||
@@ -332,6 +364,42 @@ const selectedType = computed(() => {
|
||||
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) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
@@ -343,6 +411,7 @@ watch(selectedType, (type) => {
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||
creationForm.productId = null
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
@@ -357,12 +426,15 @@ const requiredCustomFieldsFilled = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
selectedType.value &&
|
||||
creationForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
!submitting.value,
|
||||
))
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
selectedType.value &&
|
||||
creationForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || creationForm.productId) &&
|
||||
!submitting.value,
|
||||
),
|
||||
)
|
||||
|
||||
const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -377,13 +449,18 @@ const toFieldString = (value: unknown): string => {
|
||||
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 = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
creationForm.prix = ''
|
||||
creationForm.productId = null
|
||||
lastSuggestedName.value = ''
|
||||
}
|
||||
|
||||
@@ -392,6 +469,12 @@ const submitCreation = async () => {
|
||||
toast.showError('Sélectionnez une catégorie de pièce.')
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !creationForm.productId) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: creationForm.name.trim(),
|
||||
typePieceId: selectedType.value.id,
|
||||
@@ -406,6 +489,14 @@ const submitCreation = async () => {
|
||||
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'
|
||||
? creationForm.prix.trim()
|
||||
: 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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
|
||||
|
||||
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
|
||||
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
|
||||
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
|
||||
|
||||
const toDisplayCount = (value, fallback) => {
|
||||
if (value === null || value === undefined) {
|
||||
|
||||
@@ -70,7 +70,8 @@ const editedType = ref({
|
||||
maintenanceFrequency: '',
|
||||
customFields: [],
|
||||
componentRequirements: [],
|
||||
pieceRequirements: []
|
||||
pieceRequirements: [],
|
||||
productRequirements: []
|
||||
})
|
||||
|
||||
const parseOptions = (field = {}) => {
|
||||
@@ -140,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.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 () => {
|
||||
try {
|
||||
saving.value = true
|
||||
@@ -151,7 +167,8 @@ const saveChanges = async () => {
|
||||
...currentEditedType,
|
||||
customFields: normalizeCustomFields(currentEditedType.customFields),
|
||||
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
|
||||
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements)
|
||||
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
|
||||
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
|
||||
}
|
||||
|
||||
const result = await updateMachineType(type.value.id, updatedType)
|
||||
@@ -192,7 +209,8 @@ onMounted(async () => {
|
||||
maintenanceFrequency: type.value.maintenanceFrequency || '',
|
||||
customFields: type.value.customFields || [],
|
||||
componentRequirements: type.value.componentRequirements || [],
|
||||
pieceRequirements: type.value.pieceRequirements || []
|
||||
pieceRequirements: type.value.pieceRequirements || [],
|
||||
productRequirements: type.value.productRequirements || [],
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load type:', result.error)
|
||||
|
||||
@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
|
||||
import type {
|
||||
ComponentModelStructure,
|
||||
PieceModelStructure,
|
||||
ProductModelStructure,
|
||||
} 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 {
|
||||
name: string;
|
||||
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
|
||||
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 {
|
||||
id: string;
|
||||
|
||||
@@ -3,12 +3,16 @@ import {
|
||||
type ComponentModelCustomFieldType,
|
||||
type ComponentModelCustomField,
|
||||
type ComponentModelPiece,
|
||||
type ComponentModelProduct,
|
||||
type ComponentModelStructure,
|
||||
type ComponentModelStructureNode,
|
||||
type PieceModelCustomField,
|
||||
type PieceModelProduct,
|
||||
type PieceModelStructure,
|
||||
type PieceModelStructureEditorField,
|
||||
type PieceModelStructureForEditor,
|
||||
type ProductModelStructure,
|
||||
createEmptyProductModelStructure,
|
||||
createEmptyPieceModelStructure,
|
||||
} from './types/inventory'
|
||||
import { uniqueConstructeurIds } from './constructeurUtils'
|
||||
@@ -20,6 +24,7 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
|
||||
export interface ModelStructurePreview {
|
||||
customFields: number
|
||||
pieces: number
|
||||
products: number
|
||||
subcomponents: number
|
||||
}
|
||||
|
||||
@@ -37,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
|
||||
...base,
|
||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
|
||||
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||
subcomponents: Array.isArray((input as any).subcomponents)
|
||||
? (input as any).subcomponents
|
||||
: Array.isArray((input as any).subComponents)
|
||||
@@ -240,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
|
||||
.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[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
@@ -331,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: customFields as ComponentModelCustomField[],
|
||||
pieces: sanitizePieces(source.pieces),
|
||||
products: sanitizeProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||
}
|
||||
|
||||
@@ -398,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
|
||||
return payload
|
||||
}) as any
|
||||
|
||||
const backendProducts = sanitizeProducts(source.products).map((product) => {
|
||||
const payload: Record<string, any> = {}
|
||||
if ((product as any).familyCode) {
|
||||
payload.familyCode = (product as any).familyCode
|
||||
}
|
||||
if (product.typeProductId) {
|
||||
payload.typeProductId = product.typeProductId
|
||||
}
|
||||
if (product.role) {
|
||||
payload.role = product.role
|
||||
}
|
||||
return payload
|
||||
}) as any
|
||||
|
||||
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
|
||||
const payload: Record<string, any> = {}
|
||||
if (subcomponent.typeComposantId) {
|
||||
@@ -423,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: backendCustomFields,
|
||||
pieces: backendPieces,
|
||||
products: backendProducts,
|
||||
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[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
@@ -569,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
|
||||
return {
|
||||
customFields: hydrateCustomFields(source.customFields),
|
||||
pieces: hydratePieces(source.pieces),
|
||||
products: hydrateProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(
|
||||
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
|
||||
),
|
||||
@@ -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[] => {
|
||||
if (!Array.isArray(components)) {
|
||||
return []
|
||||
@@ -645,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
|
||||
const raw = {
|
||||
customFields: mapComponentCustomFields(component.customFields),
|
||||
pieces: mapComponentPieces(component.pieces),
|
||||
products: mapComponentProducts(component.products),
|
||||
subcomponents: mapSubcomponents(
|
||||
Array.isArray(component?.subcomponents)
|
||||
? component.subcomponents
|
||||
@@ -662,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
|
||||
|
||||
export const computeStructureStats = (structure: any): ModelStructurePreview => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return { customFields: 0, pieces: 0, subcomponents: 0 }
|
||||
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
|
||||
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
|
||||
products: Array.isArray(structure.products) ? structure.products.length : 0,
|
||||
subcomponents: Array.isArray(structure.subcomponents)
|
||||
? structure.subcomponents.length
|
||||
: Array.isArray(structure.subComponents)
|
||||
@@ -678,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
|
||||
|
||||
export const formatStructurePreview = (structure: any) => {
|
||||
const stats = computeStructureStats(structure)
|
||||
if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
|
||||
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
|
||||
return 'Structure vide'
|
||||
}
|
||||
|
||||
const segments: string[] = []
|
||||
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
|
||||
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
|
||||
if (stats.products) segments.push(`${stats.products} produit(s)`)
|
||||
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
|
||||
return segments.join(' • ')
|
||||
}
|
||||
@@ -741,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
|
||||
...createEmptyPieceModelStructure(),
|
||||
})
|
||||
|
||||
export const defaultProductStructure = (): ProductModelStructure => ({
|
||||
...createEmptyProductModelStructure(),
|
||||
})
|
||||
|
||||
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
||||
const base = createEmptyPieceModelStructure()
|
||||
if (!isPlainObject(input)) {
|
||||
@@ -750,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
|
||||
const clone: PieceModelStructure = {
|
||||
...base,
|
||||
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
|
||||
products: Array.isArray((input as any).products) ? (input as any).products : [],
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (key === 'customFields') {
|
||||
if (key === 'customFields' || key === 'products') {
|
||||
continue
|
||||
}
|
||||
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[] => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return []
|
||||
@@ -811,12 +933,18 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
||||
.filter((field): field is PieceModelCustomField => !!field)
|
||||
}
|
||||
|
||||
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
|
||||
return sanitizeProducts(products) as PieceModelProduct[]
|
||||
}
|
||||
|
||||
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
|
||||
const source = clonePieceStructure(input)
|
||||
const restEntries = Object.entries(source).filter(
|
||||
([key]) => key !== 'customFields' && key !== 'products',
|
||||
)
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => key !== 'customFields'),
|
||||
),
|
||||
...Object.fromEntries(restEntries),
|
||||
products: sanitizePieceProducts(source.products),
|
||||
customFields: sanitizePieceCustomFields(source.customFields),
|
||||
}
|
||||
}
|
||||
@@ -844,8 +972,9 @@ export const hydratePieceStructureForEditor = (input: any): PieceModelStructureF
|
||||
const source = clonePieceStructure(input)
|
||||
const payload: PieceModelStructureForEditor = {
|
||||
...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),
|
||||
}
|
||||
return payload
|
||||
@@ -859,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
|
||||
const customFields = Array.isArray((structure as any).customFields)
|
||||
? (structure as any).customFields.length
|
||||
: 0
|
||||
const products = Array.isArray((structure as any).products)
|
||||
? (structure as any).products.length
|
||||
: 0
|
||||
|
||||
if (!customFields) {
|
||||
return 'Aucun champ personnalisé'
|
||||
if (!customFields && !products) {
|
||||
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
|
||||
}
|
||||
|
||||
export interface ComponentModelProduct {
|
||||
typeProductId?: string
|
||||
typeProductLabel?: string
|
||||
reference?: string
|
||||
familyCode?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface ComponentModelStructureNode {
|
||||
typeComposantId?: string
|
||||
typeComposantLabel?: string
|
||||
modelId?: string
|
||||
familyCode?: string
|
||||
alias?: string
|
||||
products?: ComponentModelProduct[]
|
||||
subcomponents: ComponentModelStructureNode[]
|
||||
}
|
||||
|
||||
export interface ComponentModelStructure extends ComponentModelStructureNode {
|
||||
customFields: ComponentModelCustomField[]
|
||||
pieces: ComponentModelPiece[]
|
||||
products: ComponentModelProduct[]
|
||||
}
|
||||
|
||||
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
|
||||
@@ -44,8 +54,17 @@ export interface PieceModelCustomField {
|
||||
orderIndex?: number
|
||||
}
|
||||
|
||||
export interface PieceModelProduct {
|
||||
typeProductId?: string
|
||||
typeProductLabel?: string
|
||||
reference?: string
|
||||
familyCode?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export interface PieceModelStructure {
|
||||
customFields: PieceModelCustomField[]
|
||||
products?: PieceModelProduct[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -55,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
|
||||
|
||||
export interface PieceModelStructureForEditor {
|
||||
customFields: PieceModelStructureEditorField[]
|
||||
products?: PieceModelProduct[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ProductModelCustomField = PieceModelCustomField
|
||||
export type ProductModelStructure = PieceModelStructure
|
||||
|
||||
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
@@ -252,9 +275,15 @@ export const componentModelStructureValidator = {
|
||||
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
products: [],
|
||||
subcomponents: [],
|
||||
})
|
||||
|
||||
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
|
||||
customFields: [],
|
||||
products: [],
|
||||
})
|
||||
|
||||
export const createEmptyProductModelStructure = (): ProductModelStructure => ({
|
||||
customFields: [],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,23 @@ import {
|
||||
formatConstructeurContact,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
})
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
const number = Number(value)
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
return currencyFormatter.format(number)
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
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 = (
|
||||
pieces = [],
|
||||
title = 'Pièces indépendantes',
|
||||
@@ -94,6 +154,8 @@ const renderPrintPieces = (
|
||||
.join('')}</ul></div>`
|
||||
: ''
|
||||
|
||||
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
|
||||
|
||||
return `
|
||||
<div class="print-piece-card">
|
||||
<div class="print-piece-header">
|
||||
@@ -122,6 +184,7 @@ const renderPrintPieces = (
|
||||
: '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${productBlock}
|
||||
${customFieldsBlock}
|
||||
${documentsBlock}
|
||||
</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 currentIndex = [...indexPath, idx + 1]
|
||||
const indexLabel = currentIndex.join('.')
|
||||
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>
|
||||
@@ -162,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
||||
</h3>
|
||||
${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>` : ''}
|
||||
${productBlock}
|
||||
${renderPrintCustomFields(
|
||||
component.customFields,
|
||||
'Champs personnalisés',
|
||||
@@ -233,7 +298,28 @@ const normalizeConstructeurList = (...sources) => {
|
||||
.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 rawProduct = piece.product || null
|
||||
const constructeurs = normalizeConstructeurList(
|
||||
piece.constructeurs,
|
||||
piece.constructeur,
|
||||
@@ -241,7 +327,12 @@ const normalizePiece = piece => {
|
||||
piece.originalPiece?.constructeur,
|
||||
piece.constructeurIds,
|
||||
piece.constructeurId,
|
||||
rawProduct?.constructeurs,
|
||||
rawProduct?.constructeur,
|
||||
rawProduct?.constructeurIds,
|
||||
rawProduct?.constructeurId,
|
||||
)
|
||||
const product = normalizeProduct(rawProduct)
|
||||
|
||||
return {
|
||||
id: piece.id,
|
||||
@@ -252,11 +343,13 @@ const normalizePiece = piece => {
|
||||
documents: normalizeDocuments(piece.documents || []),
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || null,
|
||||
product,
|
||||
indexPath: piece.indexPath || null
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeComponent = component => {
|
||||
const rawProduct = component.product || null
|
||||
const constructeurs = normalizeConstructeurList(
|
||||
component.constructeurs,
|
||||
component.constructeur,
|
||||
@@ -264,7 +357,12 @@ const normalizeComponent = component => {
|
||||
component.originalComposant?.constructeur,
|
||||
component.constructeurIds,
|
||||
component.constructeurId,
|
||||
rawProduct?.constructeurs,
|
||||
rawProduct?.constructeur,
|
||||
rawProduct?.constructeurIds,
|
||||
rawProduct?.constructeurId,
|
||||
)
|
||||
const product = normalizeProduct(rawProduct)
|
||||
|
||||
return {
|
||||
id: component.id,
|
||||
@@ -276,6 +374,7 @@ const normalizeComponent = component => {
|
||||
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || null,
|
||||
product,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user