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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user