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:
Matthieu
2025-11-05 15:35:02 +01:00
parent 3af6c50892
commit d860f24e69
42 changed files with 6052 additions and 142 deletions

View File

@@ -114,6 +114,61 @@
</ul> </ul>
</Transition> </Transition>
</li> </li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-mobile')"
@keydown.enter.prevent="toggleDropdown('products-mobile')"
@keydown.space.prevent="toggleDropdown('products-mobile')"
:aria-expanded="openDropdown === 'products-mobile'"
>
<span>Produits</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'products-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/product-catalog"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-category"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li class="mt-1 border-t border-base-200 pt-2"> <li class="mt-1 border-t border-base-200 pt-2">
<button <button
type="button" type="button"
@@ -356,6 +411,67 @@
</ul> </ul>
</Transition> </Transition>
</li> </li>
<li
class="relative"
@mouseenter="setDropdown('products-desktop')"
@mouseleave="scheduleDropdownClose('products-desktop')"
@focusin="setDropdown('products-desktop')"
@focusout="scheduleDropdownClose('products-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-desktop')"
@keydown.enter.prevent="toggleDropdown('products-desktop')"
@keydown.space.prevent="toggleDropdown('products-desktop')"
:aria-expanded="openDropdown === 'products-desktop'"
>
Produits
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'products-desktop'"
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
>
<li>
<NuxtLink
to="/product-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li <li
class="relative" class="relative"
@mouseenter="setDropdown('component-desktop')" @mouseenter="setDropdown('component-desktop')"

View File

@@ -42,6 +42,12 @@
</span> </span>
</template> </template>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span> <span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span <span
v-if="component.typeMachineComponentRequirement" v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm" class="badge badge-outline badge-sm"
@@ -124,6 +130,94 @@
<span v-else class="font-medium">Non défini</span> <span v-else class="font-medium">Non défini</span>
</div> </div>
</div> </div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Produit catalogue</span>
</label>
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
<template v-if="displayProduct">
<span class="font-semibold text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</span>
<span
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
{{ info.label }} : {{ info.value }}
</span>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</template>
<span v-else class="font-medium">Non défini</span>
</div>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h4 class="font-medium text-base-content">
Documents du produit
</h4>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -432,6 +526,110 @@ const childComponents = computed(() => {
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const buildProductDisplay = (product) => {
if (!product || typeof product !== 'object') {
return null
}
const suppliers = Array.isArray(product.constructeurs)
? product.constructeurs
.map((constructeur) => constructeur?.name)
.filter((name) => typeof name === 'string' && name.trim().length > 0)
.join(', ')
: product.supplierLabel || null
const priceValue =
product.supplierPrice ??
product.price ??
product.priceLabel ??
product.priceDisplay ??
null
let price = null
if (priceValue !== null && priceValue !== undefined) {
const parsed = Number(priceValue)
if (!Number.isNaN(parsed)) {
price = currencyFormatter.format(parsed)
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
price = priceValue
}
}
return {
name:
product.name ||
product.label ||
product.reference ||
product.productName ||
null,
reference: product.reference || null,
category: product.typeProduct?.name || product.category || null,
suppliers,
price,
}
}
const displayProduct = computed(() => {
const explicit = props.component.product || null
const normalized = buildProductDisplay(explicit)
if (normalized) {
return normalized
}
const fallback = props.component.__productDisplay
if (fallback) {
return {
name: fallback.name || null,
reference: fallback.reference || null,
category: fallback.category || null,
suppliers: fallback.suppliers || null,
price: fallback.price || null,
}
}
return null
})
const displayProductName = computed(() => {
if (displayProduct.value?.name) {
return displayProduct.value.name
}
return (
props.component.product?.name ||
props.component.productName ||
props.component.productLabel ||
null
)
})
const displayProductCategory = computed(() => displayProduct.value?.category || null)
const displayProductReference = computed(() => displayProduct.value?.reference || null)
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
const displayProductPrice = computed(() => displayProduct.value?.price || null)
const productInfoRows = computed(() => {
if (!displayProduct.value) {
return []
}
const rows = []
if (displayProductReference.value) {
rows.push({ label: 'Référence', value: displayProductReference.value })
}
if (displayProductPrice.value) {
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
}
if (displayProductSuppliers.value) {
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
}
if (displayProductCategory.value) {
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
}
return rows
})
const productDocuments = computed(() => {
const product = props.component.product
return Array.isArray(product?.documents) ? product.documents : []
})
const componentConstructeurIds = computed(() => const componentConstructeurIds = computed(() =>
uniqueConstructeurIds( uniqueConstructeurIds(
props.component, props.component,

View File

@@ -5,6 +5,7 @@
:depth="0" :depth="0"
:component-types="availableComponentTypes" :component-types="availableComponentTypes"
:piece-types="availablePieceTypes" :piece-types="availablePieceTypes"
:product-types="availableProductTypes"
:lock-type="lockRootType" :lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel" :locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents" :allow-subcomponents="allowSubcomponents"
@@ -24,6 +25,7 @@ import {
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' }) defineOptions({ name: 'ComponentModelStructureEditor' })
@@ -62,9 +64,11 @@ const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? []) const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? []) const availableComponentTypes = computed(() => componentTypes.value ?? [])
const availableProductTypes = computed(() => productTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false) const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() => const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -187,9 +191,10 @@ const syncFromProps = (value: any) => {
return return
} }
const hydrated = hydrateStructureForEditor(value) const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces localStructure.pieces = hydrated.pieces
localStructure.subcomponents = hydrated.subcomponents localStructure.products = hydrated.products
localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId localStructure.modelId = hydrated.modelId
@@ -243,6 +248,9 @@ onMounted(async () => {
if (!availableComponentTypes.value.length) { if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes()) loaders.push(loadComponentTypes())
} }
if (!availableProductTypes.value.length) {
loaders.push(loadProductTypes())
}
if (loaders.length) { if (loaders.length) {
await Promise.allSettled(loaders) await Promise.allSettled(loaders)
} }

View File

@@ -66,6 +66,44 @@
</div> </div>
</section> </section>
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les produits catalogue à lier sur chaque position définie.
</p>
</header>
<div
v-for="productAssignment in assignment.products"
:key="productAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment.definition) }}
</p>
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment.definition)"
:loading="productsLoading"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.subcomponents.length" class="space-y-4"> <section v-if="assignment.subcomponents.length" class="space-y-4">
<header class="space-y-1"> <header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content"> <h4 class="text-sm font-semibold text-base-content">
@@ -81,9 +119,11 @@
:key="subAssignment.path" :key="subAssignment.path"
:assignment="subAssignment" :assignment="subAssignment"
:pieces="pieces" :pieces="pieces"
:products="products"
:components="components" :components="components"
:components-loading="componentsLoading" :components-loading="componentsLoading"
:pieces-loading="piecesLoading" :pieces-loading="piecesLoading"
:products-loading="productsLoading"
:depth="depth + 1" :depth="depth + 1"
/> />
</section> </section>
@@ -95,6 +135,7 @@ import { computed, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode, ComponentModelStructureNode,
} from '~/shared/types/inventory'; } from '~/shared/types/inventory';
@@ -122,17 +163,36 @@ interface PieceOption {
} | null; } | null;
} }
interface ProductOption {
id: string;
name?: string | null;
reference?: string | null;
typeProductId?: string | null;
typeProduct?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
export interface StructurePieceAssignment { export interface StructurePieceAssignment {
path: string; path: string;
definition: ComponentModelPiece; definition: ComponentModelPiece;
selectedPieceId: string; selectedPieceId: string;
} }
export interface StructureProductAssignment {
path: string;
definition: ComponentModelProduct;
selectedProductId: string;
}
export interface StructureAssignmentNode { export interface StructureAssignmentNode {
path: string; path: string;
definition: ComponentModelStructureNode; definition: ComponentModelStructureNode;
selectedComponentId: string; selectedComponentId: string;
pieces: StructurePieceAssignment[]; pieces: StructurePieceAssignment[];
products: StructureProductAssignment[];
subcomponents: StructureAssignmentNode[]; subcomponents: StructureAssignmentNode[];
} }
@@ -140,17 +200,21 @@ const props = withDefaults(
defineProps<{ defineProps<{
assignment: StructureAssignmentNode; assignment: StructureAssignmentNode;
pieces: PieceOption[] | null; pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null; components: ComponentOption[] | null;
depth?: number; depth?: number;
componentsLoading?: boolean; componentsLoading?: boolean;
piecesLoading?: boolean; piecesLoading?: boolean;
productsLoading?: boolean;
}>(), }>(),
{ {
depth: 0, depth: 0,
pieces: () => [], pieces: () => [],
products: () => [],
components: () => [], components: () => [],
componentsLoading: false, componentsLoading: false,
piecesLoading: false, piecesLoading: false,
productsLoading: false,
}, },
); );
@@ -269,6 +333,102 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
return parts.length ? parts.join(' • ') : 'Pièce du squelette'; return parts.length ? parts.join(' • ') : 'Pièce du squelette';
}; };
const getProductOptions = (definition: ComponentModelProduct) => {
const requiredTypeId =
definition.typeProductId ||
(definition as any).typeProduct?.id ||
definition.familyCode ||
null;
return (props.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typeProductId || (definition as any).typeProduct?.id) {
return (
product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId ||
product.typeProductId === requiredTypeId
);
}
return false;
});
};
const productOptionLabel = (product?: ProductOption | null) => {
if (!product) {
return 'Produit';
}
return product.name || product.reference || 'Produit';
};
const productOptionDescription = (product?: ProductOption | null) => {
if (!product) {
return '';
}
const parts: string[] = [];
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`);
}
return parts.join(' • ');
};
const describeProductRequirement = (definition: ComponentModelProduct) => {
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getProductOptions(definition);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
addPart(
definition.typeProductLabel ||
(definition as any).typeProduct?.name ||
fallbackType?.name,
);
const family =
definition.familyCode ||
(definition as any).typeProduct?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`);
}
return parts.length ? parts.join(' • ') : 'Produit du squelette';
};
const requirementLabel = computed(() => { const requirementLabel = computed(() => {
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const alias = definition.alias || definition.typeComposantLabel; const alias = definition.alias || definition.typeComposantLabel;
@@ -377,4 +537,20 @@ watch(
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
); );
watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment.definition);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
}
},
{ deep: true, immediate: true },
);
</script> </script>

View File

@@ -48,6 +48,12 @@
> >
Rattachée à {{ piece.parentComponentName }} Rattachée à {{ piece.parentComponentName }}
</span> </span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div> </div>
</div> </div>
@@ -113,6 +119,120 @@
pieceData.prix ? `${pieceData.prix}` : "Non défini" pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span> }}</span>
</div> </div>
<div>
<span class="font-medium">Produit catalogue:</span>
<div v-if="isEditMode" class="mt-2 space-y-2">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div>
<div class="ml-2">
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-5 w-5"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
</div>
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Champs personnalisés de la pièce -->
@@ -364,6 +484,7 @@
<script setup> <script setup>
import { reactive, onMounted, watch, ref, computed } from "vue"; import { reactive, onMounted, watch, ref, computed } from "vue";
import ConstructeurSelect from "./ConstructeurSelect.vue"; import ConstructeurSelect from "./ConstructeurSelect.vue";
import ProductSelect from "~/components/ProductSelect.vue";
import { useConstructeurs } from "~/composables/useConstructeurs"; import { useConstructeurs } from "~/composables/useConstructeurs";
import { useCustomFields } from "~/composables/useCustomFields"; import { useCustomFields } from "~/composables/useCustomFields";
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
@@ -373,6 +494,7 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
import DocumentUpload from "~/components/DocumentUpload.vue"; import DocumentUpload from "~/components/DocumentUpload.vue";
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue"; import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
import IconLucidePackage from "~icons/lucide/package"; import IconLucidePackage from "~icons/lucide/package";
import { useProducts } from "~/composables/useProducts";
import { import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
@@ -401,6 +523,7 @@ const pieceData = reactive({
name: props.piece.name || "", name: props.piece.name || "",
reference: props.piece.reference || "", reference: props.piece.reference || "",
prix: props.piece.prix || "", prix: props.piece.prix || "",
productId: props.piece.product?.id || props.piece.productId || null,
}); });
const selectedFiles = ref([]); const selectedFiles = ref([]);
@@ -779,6 +902,7 @@ const candidateCustomFields = computed(() => {
}); });
const { constructeurs } = useConstructeurs(); const { constructeurs } = useConstructeurs();
const { products, loadProducts, getProduct } = useProducts();
const pieceConstructeurIds = computed(() => const pieceConstructeurIds = computed(() =>
uniqueConstructeurIds( uniqueConstructeurIds(
@@ -800,6 +924,237 @@ const pieceConstructeursDisplay = computed(() =>
const formatConstructeurContact = (constructeur) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur); formatConstructeurContactSummary(constructeur);
const currencyFormatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
currencyDisplay: "narrowSymbol",
});
const selectedProduct = computed(() => {
const id = pieceData.productId;
if (!id) {
return null;
}
const list = Array.isArray(products.value) ? products.value : [];
const cached = list.find((product) => product && product.id === id) || null;
if (cached) {
return cached;
}
const current = props.piece.product;
if (current && current.id === id) {
return current;
}
return null;
});
const productConstructeurs = computed(() => {
const product = selectedProduct.value;
if (!product) {
return [];
}
const list = Array.isArray(product.constructeurs) ? product.constructeurs : [];
return list.filter((item) => item && typeof item === "object");
});
const productConstructeurNames = computed(() => {
const list = productConstructeurs.value;
if (!list.length) {
return "";
}
return list
.map((constructeur) => constructeur?.name)
.filter((name) => typeof name === "string" && name.trim().length > 0)
.join(", ");
});
const productSupplierPrice = computed(() => {
const product = selectedProduct.value;
if (!product || product.supplierPrice === undefined || product.supplierPrice === null) {
return null;
}
const number = Number(product.supplierPrice);
if (Number.isNaN(number)) {
return null;
}
return currencyFormatter.format(number);
});
const displayProduct = computed(() => selectedProduct.value || props.piece.__productDisplay || null);
const displayProductName = computed(() => {
if (!displayProduct.value) {
return null
}
const product = displayProduct.value
return (
product.name ||
product.label ||
product.reference ||
null
)
})
const displayProductCategory = computed(() =>
displayProduct.value
? displayProduct.value.typeProduct?.name ||
displayProduct.value.category ||
null
: null,
);
const displayProductReference = computed(() =>
displayProduct.value ? displayProduct.value.reference || null : null,
);
const displayProductSuppliers = computed(() => {
if (selectedProduct.value) {
return productConstructeurNames.value;
}
if (displayProduct.value) {
return (
displayProduct.value.suppliers ||
displayProduct.value.supplierLabel ||
null
);
}
return null;
});
const displayProductPrice = computed(() => {
if (selectedProduct.value) {
return productSupplierPrice.value;
}
if (displayProduct.value) {
const price =
displayProduct.value.price ||
displayProduct.value.priceLabel ||
displayProduct.value.priceDisplay ||
displayProduct.value.priceLabel;
return price || null;
}
return null;
});
const productInfoRows = computed(() => {
if (!displayProduct.value) {
return [];
}
const rows = [];
if (displayProductReference.value) {
rows.push({ label: "Référence", value: displayProductReference.value });
}
if (displayProductPrice.value) {
rows.push({ label: "Prix indicatif", value: displayProductPrice.value });
}
if (displayProductSuppliers.value) {
rows.push({ label: "Fournisseur(s)", value: displayProductSuppliers.value });
}
if (displayProductCategory.value) {
rows.push({ label: "Catégorie", value: displayProductCategory.value });
}
return rows;
});
const productDocuments = computed(() => {
const product =
selectedProduct.value ||
props.piece.product ||
null;
return Array.isArray(product?.documents) ? product.documents : [];
});
const ensureProductLoaded = async (id) => {
if (!id) {
return null;
}
const list = Array.isArray(products.value) ? products.value : [];
const cached = list.find((product) => product && product.id === id);
if (cached) {
return cached;
}
const result = await getProduct(id, { force: true });
if (result.success && result.data) {
return result.data;
}
return null;
};
onMounted(() => {
loadProducts().catch(() => {});
if (pieceData.productId) {
ensureProductLoaded(pieceData.productId);
}
});
watch(
() => props.piece.product?.id || props.piece.productId || null,
async (id, prevId) => {
if (pieceData.productId === id) {
if (id && !selectedProduct.value) {
const resolved = await ensureProductLoaded(id);
if (resolved) {
props.piece.product = resolved;
}
}
if (!id) {
props.piece.product = null;
}
return;
}
pieceData.productId = id;
if (id) {
const resolved = await ensureProductLoaded(id);
if (resolved) {
props.piece.product = resolved;
const supplierPrice = resolved.supplierPrice;
if (
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null &&
supplierPrice !== undefined
) {
const number = Number(supplierPrice);
if (!Number.isNaN(number)) {
pieceData.prix = String(number);
}
}
}
} else {
props.piece.product = null;
}
},
{ immediate: true }
);
const handleProductChange = async (value) => {
const nextId = value || null;
pieceData.productId = nextId;
props.piece.productId = nextId;
if (!nextId) {
props.piece.product = null;
updatePiece();
return;
}
const resolved = await ensureProductLoaded(nextId);
if (resolved) {
props.piece.product = resolved;
const supplierPrice = resolved.supplierPrice;
if (
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null &&
supplierPrice !== undefined
) {
const number = Number(supplierPrice);
if (!Number.isNaN(number)) {
pieceData.prix = String(number);
}
}
}
updatePiece();
};
const handleConstructeurChange = (value) => { const handleConstructeurChange = (value) => {
const ids = uniqueConstructeurIds(value); const ids = uniqueConstructeurIds(value);
props.piece.constructeurIds = [...ids]; props.piece.constructeurIds = [...ids];
@@ -1060,10 +1415,24 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
const updatePiece = () => { const updatePiece = () => {
const prixValue = pieceData.prix; const prixValue = pieceData.prix;
let parsedPrice = null;
if (
prixValue !== null &&
prixValue !== undefined &&
String(prixValue).trim().length > 0
) {
const numeric = Number(prixValue);
if (!Number.isNaN(numeric)) {
parsedPrice = numeric;
}
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null;
emit("update", { emit("update", {
...props.piece, ...props.piece,
...pieceData, ...pieceData,
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null, prix: parsedPrice,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value, constructeurIds: pieceConstructeurIds.value,
}); });
}; };

View File

@@ -1,32 +1,95 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-6">
<header class="flex items-center justify-between"> <section class="space-y-3">
<h3 class="text-sm font-semibold"> <header class="flex items-center justify-between">
Champs personnalisés <div>
</h3> <h3 class="text-sm font-semibold">
<button type="button" class="btn btn-outline btn-xs" @click="addField"> Produits inclus par défaut
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" /> </h3>
Ajouter <p class="text-xs text-base-content/70">
</button> Ces produits safficheront lors de la création dune pièce basée sur cette catégorie.
</header> </p>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<p v-if="!fields.length" class="text-xs text-gray-500"> <p v-if="!products.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini. Aucun produit défini.
</p> </p>
<ul v-else class="space-y-2" role="list"> <ul v-else class="space-y-2" role="list">
<li <li
v-for="(field, index) in fields" v-for="(product, index) in products"
:key="field.uid" :key="product.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors" class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
:class="reorderClass(index)" >
draggable="true" <div class="flex items-start justify-between gap-3">
@dragstart="onDragStart(index, $event)" <div class="flex-1 space-y-3">
@dragenter="onDragEnter(index)" <div class="form-control">
@dragover.prevent="onDragEnter(index)" <label class="label py-1">
@drop.prevent="onDrop(index)" <span class="label-text text-xs">Famille de produit</span>
@dragend="onDragEnd" </label>
> <select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypeOptions"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
</section>
<section class="space-y-3">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<button <button
type="button" type="button"
@@ -87,25 +150,34 @@
</div> </div>
</li> </li>
</ul> </ul>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical' import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import type { import type {
PieceModelCustomField, PieceModelCustomField,
PieceModelCustomFieldType, PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure, PieceModelStructure,
PieceModelStructureEditorField, PieceModelStructureEditorField,
} from '~/shared/types/inventory' } from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils' import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
defineOptions({ name: 'PieceModelStructureEditor' }) defineOptions({ name: 'PieceModelStructureEditor' })
type EditorField = PieceModelStructureEditorField & { uid: string } type EditorField = PieceModelStructureEditorField & { uid: string }
type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
const props = defineProps<{ const props = defineProps<{
modelValue?: PieceModelStructure | null modelValue?: PieceModelStructure | null
@@ -115,12 +187,15 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void (event: 'update:modelValue', value: PieceModelStructure): void
}>() }>()
const ensureArray = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []) const { productTypes, loadProductTypes } = useProductTypes()
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string => const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T>(value: T, fallback: T): T => { const safeClone = <T,>(value: T, fallback: T): T => {
try { try {
return JSON.parse(JSON.stringify(value ?? fallback)) as T return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch { } catch {
@@ -132,17 +207,19 @@ const extractRest = (structure?: PieceModelStructure | null): Record<string, unk
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return {} return {}
} }
const entries = Object.entries(structure).filter(([key]) => key !== 'customFields') const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {}) return safeClone(Object.fromEntries(entries), {})
} }
let uidCounter = 0 let uidCounter = 0
const createUid = (): string => { const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID() return crypto.randomUUID()
} }
uidCounter += 1 uidCounter += 1
return `piece-field-${Date.now().toString(36)}-${uidCounter}` return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
} }
const toEditorField = ( const toEditorField = (
@@ -159,7 +236,7 @@ const toEditorField = (
) )
return { return {
uid: createUid(), uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '', name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType, type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required), required: Boolean(input?.required),
@@ -176,7 +253,81 @@ const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] =>
.map((field, index) => ({ ...field, orderIndex: index })) .map((field, index) => ({ ...field, orderIndex: index }))
} }
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map((product) => toEditorProduct(product))
}
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const fields = ref<EditorField[]>(hydrateFields(props.modelValue)) const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue)) const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const applyOrderIndex = (list: EditorField[]): EditorField[] => const applyOrderIndex = (list: EditorField[]): EditorField[] =>
@@ -185,8 +336,30 @@ const applyOrderIndex = (list: EditorField[]): EditorField[] =>
orderIndex: index, orderIndex: index,
})) }))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = ( const buildPayload = (
fieldsSource: EditorField[], fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>, restSource: Record<string, unknown>,
): PieceModelStructure => { ): PieceModelStructure => {
const normalizedFields = fieldsSource const normalizedFields = fieldsSource
@@ -219,8 +392,13 @@ const buildPayload = (
}) })
.filter((field): field is PieceModelCustomField => Boolean(field)) .filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map((product) => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = { const draft: PieceModelStructure = {
...safeClone(restSource, {}), ...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields, customFields: normalizedFields,
} }
@@ -234,7 +412,7 @@ const serializeStructure = (structure?: PieceModelStructure | null): string => {
let lastEmitted = serializeStructure(props.modelValue) let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => { const emitUpdate = () => {
const payload = buildPayload(fields.value, restState.value) const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload) const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) { if (serialized !== lastEmitted) {
lastEmitted = serialized lastEmitted = serialized
@@ -243,6 +421,10 @@ const emitUpdate = () => {
} }
watch(fields, emitUpdate, { deep: true }) watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach((product) => updateProductTypeMetadata(product))
})
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -253,11 +435,20 @@ watch(
} }
restState.value = extractRest(value) restState.value = extractRest(value)
fields.value = hydrateFields(value) fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized lastEmitted = incomingSerialized
}, },
{ deep: true }, { deep: true },
) )
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach((product) => updateProductTypeMetadata(product))
})
const dragState = reactive({ const dragState = reactive({
draggingIndex: null as number | null, draggingIndex: null as number | null,
dropTargetIndex: null as number | null, dropTargetIndex: null as number | null,
@@ -328,7 +519,7 @@ const reorderClass = (index: number) => {
} }
const createEmptyField = (orderIndex: number): EditorField => ({ const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid(), uid: createUid('field'),
name: '', name: '',
type: 'text', type: 'text',
required: false, required: false,

View 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>

View File

@@ -139,6 +139,69 @@
</div> </div>
</section> </section>
<section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
Aucun produit défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(product, index) in node.products"
:key="`product-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="productReorderClass(index)"
@dragenter="onProductDragEnter(index)"
@dragover="onProductDragOver"
@drop="onProductDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onProductDragStart(index, $event)"
@dragend="onProductDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypes"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
<section v-if="isRoot" class="space-y-3"> <section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h4 :class="headingClass"> <h4 :class="headingClass">
@@ -251,6 +314,7 @@
:depth="depth + 1" :depth="depth + 1"
:component-types="componentTypes" :component-types="componentTypes"
:piece-types="pieceTypes" :piece-types="pieceTypes"
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents" :allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth" :max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)" @remove="removeSubComponent(index)"
@@ -268,7 +332,7 @@ import { computed, ref, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical' import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { ComponentModelPiece, ComponentModelStructureNode } from '~/shared/types/inventory' import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
defineOptions({ name: 'StructureNodeEditor' }) defineOptions({ name: 'StructureNodeEditor' })
@@ -281,6 +345,7 @@ type ModelTypeOption = {
type EditableStructureNode = ComponentModelStructureNode & { type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[] customFields?: any[]
pieces?: ComponentModelPiece[] pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -288,6 +353,7 @@ const props = withDefaults(defineProps<{
depth?: number depth?: number
componentTypes?: ModelTypeOption[] componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[] pieceTypes?: ModelTypeOption[]
productTypes?: ModelTypeOption[]
isRoot?: boolean isRoot?: boolean
lockType?: boolean lockType?: boolean
lockedTypeLabel?: string lockedTypeLabel?: string
@@ -297,6 +363,7 @@ const props = withDefaults(defineProps<{
depth: 0, depth: 0,
componentTypes: () => [], componentTypes: () => [],
pieceTypes: () => [], pieceTypes: () => [],
productTypes: () => [],
isRoot: false, isRoot: false,
lockType: false, lockType: false,
lockedTypeLabel: '', lockedTypeLabel: '',
@@ -308,6 +375,7 @@ const emit = defineEmits(['remove'])
const componentTypes = computed(() => props.componentTypes ?? []) const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? []) const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false) const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() => const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
@@ -372,6 +440,16 @@ const pieceTypeMap = computed(() => {
return map return map
}) })
const productTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
productTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const getComponentTypeLabel = (id?: string) => { const getComponentTypeLabel = (id?: string) => {
if (!id) return '' if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id)) return formatModelTypeOption(componentTypeMap.value.get(id))
@@ -382,16 +460,26 @@ const getPieceTypeLabel = (id?: string) => {
return formatModelTypeOption(pieceTypeMap.value.get(id)) return formatModelTypeOption(pieceTypeMap.value.get(id))
} }
const getProductTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(productTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) => const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) => const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'subcomponents') => { const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) { if (!Array.isArray((props.node as any)[key])) {
if (key === 'subcomponents') { if (key === 'subcomponents') {
props.node.subcomponents = [] props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else { } else {
(props.node as any)[key] = [] (props.node as any)[key] = []
} }
@@ -493,6 +581,37 @@ const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>)
} }
} }
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) return
if (product.typeProductId) {
const option = productTypeMap.value.get(product.typeProductId)
if (option) {
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
return
}
}
if (product.typeProductLabel) {
const normalized = product.typeProductLabel.trim().toLowerCase()
if (normalized) {
const match = productTypes.value.find((type) => {
const formatted = formatProductTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
product.typeProductId = match.id
product.typeProductLabel = formatProductTypeOption(match)
product.familyCode = match.code ?? product.familyCode ?? ''
return
}
}
}
}
const syncPieceLabels = (pieces?: any[]) => { const syncPieceLabels = (pieces?: any[]) => {
if (!Array.isArray(pieces)) { if (!Array.isArray(pieces)) {
return return
@@ -502,6 +621,15 @@ const syncPieceLabels = (pieces?: any[]) => {
}) })
} }
const syncProductLabels = (products?: any[]) => {
if (!Array.isArray(products)) {
return
}
products.forEach((product) => {
updateProductTypeLabel(product)
})
}
const handleComponentTypeSelect = (component: any) => { const handleComponentTypeSelect = (component: any) => {
syncComponentType(component) syncComponentType(component)
} }
@@ -524,6 +652,25 @@ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>)
piece.typePieceLabel = formatPieceTypeOption(option) piece.typePieceLabel = formatPieceTypeOption(option)
} }
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) {
return
}
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
if (!id) {
product.typeProductLabel = ''
return
}
const option = productTypeMap.value.get(id)
if (!option) {
product.typeProductId = ''
product.typeProductLabel = ''
return
}
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
}
const customFieldDragState = ref({ const customFieldDragState = ref({
draggingIndex: null as number | null, draggingIndex: null as number | null,
dropTargetIndex: null as number | null, dropTargetIndex: null as number | null,
@@ -633,6 +780,20 @@ const removePiece = (index: number) => {
props.node.pieces.splice(index, 1) props.node.pieces.splice(index, 1)
} }
const addProduct = () => {
ensureArray('products')
props.node.products.push({
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
}
const removeProduct = (index: number) => {
if (!Array.isArray(props.node.products)) return
props.node.products.splice(index, 1)
}
const addSubComponent = () => { const addSubComponent = () => {
if (!canManageSubcomponents.value) { if (!canManageSubcomponents.value) {
return return
@@ -655,6 +816,8 @@ const removeSubComponent = (index: number) => {
const draggingPieceIndex = ref<number | null>(null) const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = ref<number | null>(null) const pieceDropTargetIndex = ref<number | null>(null)
const draggingProductIndex = ref<number | null>(null)
const productDropTargetIndex = ref<number | null>(null)
const draggingSubcomponentIndex = ref<number | null>(null) const draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null) const subcomponentDropTargetIndex = ref<number | null>(null)
@@ -676,6 +839,11 @@ const resetPieceDragState = () => {
pieceDropTargetIndex.value = null pieceDropTargetIndex.value = null
} }
const resetProductDragState = () => {
draggingProductIndex.value = null
productDropTargetIndex.value = null
}
const onPieceDragStart = (index: number, event: DragEvent) => { const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index draggingPieceIndex.value = index
pieceDropTargetIndex.value = index pieceDropTargetIndex.value = index
@@ -728,6 +896,58 @@ const pieceReorderClass = (index: number) => {
return '' return ''
} }
const onProductDragStart = (index: number, event: DragEvent) => {
draggingProductIndex.value = index
productDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onProductDragEnter = (index: number) => {
if (draggingProductIndex.value === null) {
return
}
productDropTargetIndex.value = index
}
const onProductDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onProductDrop = (index: number) => {
if (!Array.isArray(props.node.products)) {
resetProductDragState()
return
}
const from = draggingProductIndex.value
const to = index
if (from === null || to === null) {
resetProductDragState()
return
}
moveItemInPlace(props.node.products, from, to)
resetProductDragState()
}
const onProductDragEnd = () => {
resetProductDragState()
}
const productReorderClass = (index: number) => {
if (draggingProductIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingProductIndex.value !== null &&
productDropTargetIndex.value === index &&
draggingProductIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const resetSubcomponentDragState = () => { const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null subcomponentDropTargetIndex.value = null
@@ -818,6 +1038,18 @@ watch(
{ deep: true } { deep: true }
) )
watch(productTypes, () => {
syncProductLabels(props.node?.products)
}, { deep: true, immediate: true })
watch(
() => props.node.products,
(value) => {
syncProductLabels(value)
},
{ deep: true }
)
watch( watch(
() => props.node.customFields, () => props.node.customFields,
(value) => { (value) => {

View File

@@ -26,6 +26,11 @@
@update:model-value="(value) => (formData.pieceRequirements = value)" @update:model-value="(value) => (formData.pieceRequirements = value)"
/> />
<TypeEditProductRequirementsSection
:model-value="formData.productRequirements"
@update:model-value="(value) => (formData.productRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" /> <TypeEditActionsBar :saving="saving" @reset="resetForm" />
</form> </form>
</template> </template>
@@ -38,6 +43,7 @@ import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSectio
import TypeEditToolbar from '~/components/TypeEditToolbar.vue' import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue' import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue' import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -80,7 +86,8 @@ const createDefaultForm = (source = {}) => ({
maintenanceFrequency: source.maintenanceFrequency || '', maintenanceFrequency: source.maintenanceFrequency || '',
customFields: withNormalizedOrder(source.customFields || []), customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: withNormalizedOrder(source.componentRequirements || []), componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []) pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
productRequirements: withNormalizedOrder(source.productRequirements || []),
}) })
const formData = reactive(createDefaultForm(props.modelValue)) const formData = reactive(createDefaultForm(props.modelValue))

View 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>

View File

@@ -9,6 +9,7 @@
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p> <p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p> <p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p> <p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
<p v-if="type.description"> <p v-if="type.description">
<strong>Description:</strong> {{ type.description }} <strong>Description:</strong> {{ type.description }}
</p> </p>

View File

@@ -51,7 +51,7 @@ import {
import { useToast } from "~/composables/useToast"; import { useToast } from "~/composables/useToast";
const DEFAULT_DESCRIPTION = const DEFAULT_DESCRIPTION =
"Gérez les catégories utilisées pour structurer les catalogues de composants et de pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination."; "Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -210,8 +210,15 @@ const onOffsetChange = (value: number) => {
} }
}; };
const resolveCategoryBasePath = (category: ModelCategory) => const resolveCategoryBasePath = (category: ModelCategory) => {
category === "COMPONENT" ? "/component-category" : "/piece-category"; if (category === "COMPONENT") {
return "/component-category";
}
if (category === "PIECE") {
return "/piece-category";
}
return "/product-category";
};
const openCreatePage = () => { const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value); const basePath = resolveCategoryBasePath(selectedCategory.value);

View File

@@ -32,6 +32,7 @@
> >
<option value="COMPONENT">Composants</option> <option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option> <option value="PIECE">Pièces</option>
<option value="PRODUCT">Produits</option>
</select> </select>
</div> </div>
@@ -84,7 +85,7 @@
</div> </div>
<div <div
v-else v-else-if="form.category === 'PIECE'"
class="space-y-3 rounded-lg border border-base-300 p-4" class="space-y-3 rounded-lg border border-base-300 p-4"
> >
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
@@ -93,6 +94,17 @@
</p> </p>
<PieceModelStructureEditor v-model="pieceStructure" /> <PieceModelStructureEditor v-model="pieceStructure" />
</div> </div>
<div
v-else
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template> </template>
</section> </section>
@@ -114,12 +126,16 @@ import ComponentModelStructureEditor from '~/components/ComponentModelStructureE
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue' import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
import { import {
clonePieceStructure, clonePieceStructure,
cloneProductStructure,
cloneStructure, cloneStructure,
defaultPieceStructure, defaultPieceStructure,
defaultProductStructure,
defaultStructure, defaultStructure,
formatPieceStructurePreview, formatPieceStructurePreview,
formatProductStructurePreview,
formatStructurePreview, formatStructurePreview,
normalizePieceStructureForSave, normalizePieceStructureForSave,
normalizeProductStructureForSave,
normalizeStructureForEditor, normalizeStructureForEditor,
normalizeStructureForSave, normalizeStructureForSave,
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
@@ -171,6 +187,7 @@ const nameInput = ref<HTMLInputElement | null>(null)
const componentStructure = ref(normalizeStructureForEditor(defaultStructure())) const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure())) const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
const generateCodeFromName = (name: string) => { const generateCodeFromName = (name: string) => {
const fallback = 'type' const fallback = 'type'
@@ -196,10 +213,19 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
return return
} }
pieceStructure.value = normalizePieceStructureForSave( if (category === 'PIECE') {
incomingStructure && props.initialData?.category === 'PIECE' pieceStructure.value = normalizePieceStructureForSave(
? incomingStructure incomingStructure && props.initialData?.category === 'PIECE'
: defaultPieceStructure(), ? incomingStructure
: defaultPieceStructure(),
)
return
}
productStructure.value = normalizeProductStructureForSave(
incomingStructure && props.initialData?.category === 'PRODUCT'
? cloneProductStructure(incomingStructure)
: defaultProductStructure(),
) )
} }
@@ -263,10 +289,19 @@ const handleSubmit = () => {
return return
} }
if (form.category === 'PIECE') {
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
})
return
}
emit('submit', { emit('submit', {
...common, ...common,
category: 'PIECE', category: 'PRODUCT',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)), structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
}) })
} }
@@ -311,11 +346,16 @@ watch(
if (category === 'PIECE') { if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure()) pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
} }
if (category === 'PRODUCT') {
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
}
}, },
) )
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value)) const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value)) const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
onMounted(() => { onMounted(() => {
nextTick(() => nameInput.value?.focus()) nextTick(() => nameInput.value?.focus())

View File

@@ -131,6 +131,7 @@ const emit = defineEmits<{
const categoryDictionary: Record<ModelCategory, string> = { const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants', COMPONENT: 'Composants',
PIECE: 'Pièces', PIECE: 'Pièces',
PRODUCT: 'Produits',
}; };
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category; const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;

View File

@@ -78,12 +78,13 @@
import { computed } from 'vue'; import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus'; import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search'; import IconLucideSearch from '~icons/lucide/search';
import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt'; type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
category: 'COMPONENT' | 'PIECE'; category: ModelCategory;
search: string; search: string;
sort: SortField; sort: SortField;
dir: SortDirection; dir: SortDirection;
@@ -92,16 +93,17 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void; (e: 'update:category', value: ModelCategory): void;
(e: 'update:search', value: string): void; (e: 'update:search', value: string): void;
(e: 'update:sort', value: SortField): void; (e: 'update:sort', value: SortField): void;
(e: 'update:dir', value: SortDirection): void; (e: 'update:dir', value: SortDirection): void;
(e: 'create'): void; (e: 'create'): void;
}>(); }>();
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [ const categories: Array<{ label: string; value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' }, { label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' }, { label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
]; ];
const onSearch = (event: Event) => { const onSearch = (event: Event) => {

View File

@@ -60,6 +60,11 @@ export function useDocuments () {
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByProduct = async (productId, options = {}) => {
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
}
const loadDocumentsByPiece = async (pieceId, options = {}) => { const loadDocumentsByPiece = async (pieceId, options = {}) => {
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } } if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
@@ -140,6 +145,7 @@ export function useDocuments () {
loadDocumentsByMachine, loadDocumentsByMachine,
loadDocumentsByComponent, loadDocumentsByComponent,
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
deleteDocument deleteDocument
} }

View File

@@ -5,6 +5,20 @@ import { useApi } from './useApi'
const machineTypes = ref([]) const machineTypes = ref([])
const loading = ref(false) const loading = ref(false)
const normalizeRequirementList = (value) => (Array.isArray(value) ? value : [])
const normalizeMachineType = (type) => {
if (!type || typeof type !== 'object') {
return type
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements),
pieceRequirements: normalizeRequirementList(type.pieceRequirements),
productRequirements: normalizeRequirementList(type.productRequirements),
}
}
export function useMachineTypesApi () { export function useMachineTypesApi () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
try { try {
const result = await get('/types/machines') const result = await get('/types/machines')
if (result.success) { if (result.success) {
machineTypes.value = result.data machineTypes.value = Array.isArray(result.data)
? result.data.map(normalizeMachineType)
: []
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`) showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
} }
} catch (error) { } catch (error) {
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
try { try {
const result = await post('/types/machines', typeData) const result = await post('/types/machines', typeData)
if (result.success) { if (result.success) {
machineTypes.value.push(result.data) machineTypes.value.push(normalizeMachineType(result.data))
showSuccess(`Type de machine "${typeData.name}" créé avec succès`) showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
} }
return result return result
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
try { try {
const result = await patch(`/types/machines/${id}`, typeData) const result = await patch(`/types/machines/${id}`, typeData)
if (result.success) { if (result.success) {
const normalized = normalizeMachineType(result.data)
const index = machineTypes.value.findIndex(type => type.id === id) const index = machineTypes.value.findIndex(type => type.id === id)
if (index !== -1) { if (index !== -1) {
machineTypes.value[index] = result.data machineTypes.value[index] = normalized
} }
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`) showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
} }
@@ -91,7 +108,7 @@ export function useMachineTypesApi () {
const result = await get(`/types/machines/${id}`) const result = await get(`/types/machines/${id}`)
if (result.success) { if (result.success) {
// Ajouter au cache local // Ajouter au cache local
machineTypes.value.push(result.data) machineTypes.value.push(normalizeMachineType(result.data))
} }
return result return result
} catch (error) { } catch (error) {

View 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,
}
}

View 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,
}
}

View File

@@ -94,6 +94,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Type de composant</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -107,6 +108,7 @@
</td> </td>
<td>{{ component.name || 'Composant sans nom' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -178,6 +180,17 @@ const resolvePreviewAlt = (component: Record<string, any>) => {
return 'Aperçu du document' return 'Aperçu du document'
} }
const resolveComponentType = (component: Record<string, any>) => {
const type = component?.typeComposant
if (type?.name) {
return type.name
}
if (component?.typeComposantLabel) {
return component.typeComposantLabel
}
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => { const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = [] const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks) const machineLinks = Array.isArray(component?.machineLinks)

View File

@@ -445,7 +445,6 @@ const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([]) const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,

View File

@@ -148,6 +148,18 @@
</ul> </ul>
</div> </div>
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabel(product) }}
</li>
</ul>
</div>
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2"> <div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3> <h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
@@ -189,14 +201,16 @@
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70" class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
> >
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> <span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces et de composants Chargement du catalogue de pièces, produits et composants
</div> </div>
<ComponentStructureAssignmentNode <ComponentStructureAssignmentNode
v-else-if="structureAssignments" v-else-if="structureAssignments"
:assignment="structureAssignments" :assignment="structureAssignments"
:pieces="availablePieces" :pieces="availablePieces"
:products="availableProducts"
:components="availableComponents" :components="availableComponents"
:pieces-loading="piecesLoading" :pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading" :components-loading="componentsLoading"
/> />
<p v-else class="text-xs text-error"> <p v-else class="text-xs text-error">
@@ -335,6 +349,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
@@ -342,6 +357,7 @@ import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/mo
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructure, ComponentModelStructure,
ComponentModelStructureNode, ComponentModelStructureNode,
} from '~/shared/types/inventory' } from '~/shared/types/inventory'
@@ -367,6 +383,11 @@ const {
loadPieces, loadPieces,
loading: piecesLoading, loading: piecesLoading,
} = usePieces() } = usePieces()
const {
products: productCatalogRef,
loadProducts,
loading: productsLoading,
} = useProducts()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
@@ -387,9 +408,10 @@ const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false) const uploadingDocuments = ref(false)
const availablePieces = computed(() => pieceCatalogRef.value ?? []) const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? []) const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed( const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value, () => piecesLoading.value || componentsLoading.value || productsLoading.value,
) )
watch( watch(
@@ -486,6 +508,21 @@ const extractPiecesFromNode = (
) )
} }
const extractProductsFromNode = (
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelProduct[] => {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).products)
? (definition as any).products
: []
return raw.filter(
(item: unknown): item is ComponentModelProduct =>
!!item && typeof item === 'object',
)
}
const buildAssignmentNode = ( const buildAssignmentNode = (
definition: ComponentModelStructureNode | ComponentModelStructure, definition: ComponentModelStructureNode | ComponentModelStructure,
path: string, path: string,
@@ -496,6 +533,12 @@ const buildAssignmentNode = (
selectedPieceId: '', selectedPieceId: '',
})) }))
const products = extractProductsFromNode(definition).map((product, index) => ({
path: `${path}:product-${index}`,
definition: product,
selectedProductId: '',
}))
const subcomponents = extractSubcomponents(definition).map( const subcomponents = extractSubcomponents(definition).map(
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`), (child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
) )
@@ -505,6 +548,7 @@ const buildAssignmentNode = (
definition, definition,
selectedComponentId: '', selectedComponentId: '',
pieces, pieces,
products,
subcomponents, subcomponents,
} }
} }
@@ -522,7 +566,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
if (!node) { if (!node) {
return false return false
} }
if (node.pieces.length > 0 || node.subcomponents.length > 0) { if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
return true return true
} }
return node.subcomponents.some((child) => hasAssignments(child)) return node.subcomponents.some((child) => hasAssignments(child))
@@ -539,13 +583,21 @@ const isAssignmentNodeComplete = (
const piecesComplete = node.pieces.every( const piecesComplete = node.pieces.every(
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0, (piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
) )
const productsComplete = node.products.every(
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
)
const subcomponentsComplete = node.subcomponents.every( const subcomponentsComplete = node.subcomponents.every(
(child) => (child) =>
!!child.selectedComponentId && !!child.selectedComponentId &&
child.selectedComponentId.length > 0 && child.selectedComponentId.length > 0 &&
isAssignmentNodeComplete(child, false), isAssignmentNodeComplete(child, false),
) )
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId) return (
piecesComplete &&
productsComplete &&
subcomponentsComplete &&
(isRootNode || !!node.selectedComponentId)
)
} }
const structureSelectionsComplete = computed(() => { const structureSelectionsComplete = computed(() => {
@@ -588,6 +640,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
familyCode: (definition as any).familyCode ?? null, familyCode: (definition as any).familyCode ?? null,
}) })
const sanitizeProductDefinition = (definition: ComponentModelProduct) =>
stripNullish({
role: (definition as any).role ?? null,
typeProductId: definition.typeProductId ?? null,
typeProductLabel: (definition as any).typeProductLabel ?? null,
reference: (definition as any).reference ?? null,
familyCode: (definition as any).familyCode ?? null,
})
const serializeStructureAssignments = ( const serializeStructureAssignments = (
root: StructureAssignmentNode | null, root: StructureAssignmentNode | null,
) => { ) => {
@@ -609,6 +670,16 @@ const serializeStructureAssignments = (
}), }),
) )
const serializedProducts = assignment.products
.filter((product) => !!product.selectedProductId)
.map((product) =>
stripNullish({
path: product.path,
definition: sanitizeProductDefinition(product.definition),
selectedProductId: product.selectedProductId,
}),
)
const serializedSubcomponents = assignment.subcomponents const serializedSubcomponents = assignment.subcomponents
.map((child) => serializeNode(child, false)) .map((child) => serializeNode(child, false))
.filter((child) => Object.keys(child).length > 0) .filter((child) => Object.keys(child).length > 0)
@@ -624,6 +695,9 @@ const serializeStructureAssignments = (
if (serializedPieces.length) { if (serializedPieces.length) {
base.pieces = serializedPieces base.pieces = serializedPieces
} }
if (serializedProducts.length) {
base.products = serializedProducts
}
if (serializedSubcomponents.length) { if (serializedSubcomponents.length) {
base.subcomponents = serializedSubcomponents base.subcomponents = serializedSubcomponents
} }
@@ -634,6 +708,7 @@ const serializeStructureAssignments = (
const serializedRoot = serializeNode(root, true) const serializedRoot = serializeNode(root, true)
if ( if (
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) && (!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
(!serializedRoot.products || serializedRoot.products.length === 0) &&
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0) (!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
) { ) {
return null return null
@@ -682,6 +757,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : [] return Array.isArray(structure?.pieces) ? structure.pieces : []
} }
const getStructureProducts = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.products) ? structure.products : []
}
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => { const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) { if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents return structure.subcomponents
@@ -709,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
return parts.length ? parts.join(' • ') : 'Pièce' return parts.length ? parts.join(' • ') : 'Pièce'
} }
const resolveProductLabel = (product: Record<string, any>) => {
const parts: string[] = []
if (product.role) {
parts.push(product.role)
}
if (product.typeProduct?.name) {
parts.push(product.typeProduct.name)
} else if (product.typeProductLabel) {
parts.push(product.typeProductLabel)
} else if (product.typeProduct?.code) {
parts.push(`Catégorie ${product.typeProduct.code}`)
} else if (product.familyCode) {
parts.push(`Catégorie ${product.familyCode}`)
} else if (product.typeProductId) {
parts.push(`#${product.typeProductId}`)
}
return parts.length ? parts.join(' • ') : 'Produit'
}
const resolveSubcomponentLabel = (node: Record<string, any>) => { const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = [] const parts: string[] = []
if (node.alias) { if (node.alias) {
@@ -776,8 +874,17 @@ const submitCreation = async () => {
} }
} }
const rootProductSelection =
structureAssignments.value?.products?.find(
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
) ?? null
if (rootProductSelection?.selectedProductId) {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) { if (structureHasRequirements.value && !structureSelectionsComplete.value) {
toast.showError('Complétez la sélection des pièces et sous-composants.') toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
return return
} }
@@ -829,6 +936,7 @@ onMounted(async () => {
loadComponentTypes(), loadComponentTypes(),
loadPieces(), loadPieces(),
loadComposants(), loadComposants(),
loadProducts(),
]) ])
}) })

View File

@@ -423,6 +423,12 @@
selectedMachineType.pieceRequirements?.length || 0 selectedMachineType.pieceRequirements?.length || 0
}}</span> }}</span>
</div> </div>
<div class="flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{
selectedMachineType.productRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">Catégorie :</span> <span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ <span class="badge badge-outline badge-sm">{{

View File

@@ -58,6 +58,13 @@
pièces</span pièces</span
> >
</div> </div>
<div class="flex items-center gap-2">
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.productRequirements?.length || 0 }} produit(s)
requis</span
>
</div>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button <button
@@ -99,6 +106,7 @@ import { useToast } from "~/composables/useToast";
import IconLucidePlus from "~icons/lucide/plus"; import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package"; import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid"; import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { machineTypes, loading, loadMachineTypes, deleteMachineType } = const { machineTypes, loading, loadMachineTypes, deleteMachineType } =
useMachineTypesApi(); useMachineTypesApi();

View File

@@ -65,6 +65,10 @@
<IconLucideList class="h-4 w-4" aria-hidden="true" /> <IconLucideList class="h-4 w-4" aria-hidden="true" />
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces {{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
</span> </span>
<span class="inline-flex items-center gap-1">
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
{{ type.productRequirements?.length || 0 }} produit(s)
</span>
</div> </div>
</div> </div>
</article> </article>
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list' import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list' import IconLucideList from '~icons/lucide/list'
import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi() const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast() const { showError } = useToast()
@@ -100,7 +105,8 @@ const createEmptyType = () => ({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const draftType = ref(createEmptyType()) const draftType = ref(createEmptyType())
@@ -187,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index })) .map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({ const buildPayload = typeData => ({
name: typeData.name, name: typeData.name,
description: typeData.description, description: typeData.description,
@@ -194,7 +215,8 @@ const buildPayload = typeData => ({
maintenanceFrequency: typeData.maintenanceFrequency, maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields), customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements), componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements) pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
productRequirements: normalizeProductRequirements(typeData.productRequirements)
}) })
const resetForm = () => { const resetForm = () => {

File diff suppressed because it is too large Load Diff

View File

@@ -90,13 +90,17 @@
<span class="font-medium">Groupes de pièces :</span> <span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span> <span class="badge badge-sm">{{ selectedMachineType.pieceRequirements?.length || 0 }}</span>
</span> </span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{ selectedMachineType.productRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span class="font-medium">Catégorie :</span> <span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span> <span class="badge badge-outline badge-sm">{{ selectedMachineType.category || 'N/A' }}</span>
</span> </span>
</div> </div>
<p <p
v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0" v-if="(selectedMachineType.componentRequirements?.length || 0) === 0 && (selectedMachineType.pieceRequirements?.length || 0) === 0 && (selectedMachineType.productRequirements?.length || 0) === 0"
class="text-xs text-gray-500" class="text-xs text-gray-500"
> >
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type. Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
@@ -304,10 +308,130 @@
</div> </div>
</div> </div>
</div>
</div>
</div>
<div v-if="selectedMachineType?.productRequirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Produits catalogue requis
</h4>
<div
v-for="requirement in selectedMachineType.productRequirements"
:id="`product-group-${requirement.id}`"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
</h5>
<p class="text-xs text-gray-500">
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '' }}
</p>
<p
v-if="(requirement.allowNewModels ?? true) === false"
class="text-xs text-error"
>
Sélection de produits existants uniquement.
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getProductRequirementEntries(requirement.id).length >= requirement.maxCount"
@click="addProductSelectionEntry(requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getProductRequirementEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun produit sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getProductRequirementEntries(requirement.id)"
:key="`${requirement.id}-product-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
<span>
Catégorie appliquée :
{{ requirement.typeProduct?.name || 'Non définie' }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="removeProductSelectionEntry(requirement.id, entryIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Produit existant</span>
</label>
<ProductSelect
:model-value="entry.productId || ''"
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
:placeholder="productsLoading ? 'Chargement' : 'Sélectionner un produit'"
empty-text="Aucun produit disponible pour cette catégorie"
:disabled="productsLoading"
@update:modelValue="setProductRequirementProduct(requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="!productsLoading && getProductOptions(requirement).length === 0"
class="text-xs text-error"
>
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
</p>
</div>
<div
v-if="entry.productId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findProductById(entry.productId)?.name || 'Produit' }}
</div>
<div>
Référence : {{ findProductById(entry.productId)?.reference || "—" }}
</div>
<div>
Prix indicatif :
<span
v-if="findProductById(entry.productId)?.supplierPrice !== undefined && findProductById(entry.productId)?.supplierPrice !== null"
>
{{ Number(findProductById(entry.productId)?.supplierPrice).toFixed(2) }} €
</span>
<span v-else>
</span>
</div>
<div>
Fournisseurs :
<span v-if="findProductById(entry.productId)?.constructeurs?.length">
{{ findProductById(entry.productId)?.constructeurs.map(constructeur => constructeur?.name).filter(Boolean).join(', ') }}
</span>
<span v-else>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="machinePreview" class="space-y-4"> </div>
</div>
<div v-if="machinePreview" class="space-y-4">
<div class="border border-base-200 rounded-lg bg-base-100/80"> <div class="border border-base-200 rounded-lg bg-base-100/80">
<div class="p-4 space-y-4"> <div class="p-4 space-y-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -486,6 +610,73 @@
Aucun groupe de pièces à configurer pour ce type. Aucun groupe de pièces à configurer pour ce type.
</div> </div>
<div v-if="machinePreview.productGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Produits requis
</h5>
<div
v-for="group in machinePreview.productGroups"
:key="group.id"
:id="`product-group-${group.id}`"
class="border border-base-200 rounded-md p-3 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold">
{{ group.label }}
</p>
<p class="text-xs text-gray-500">
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
Couverture : {{ group.count }}
</span>
<span class="badge badge-ghost badge-sm">
Direct {{ group.completed }} / {{ group.total || 0 }}
</span>
</div>
</div>
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
<ul class="list-disc pl-4 space-y-1">
<li v-for="issue in group.issues" :key="issue.message">
{{ issue.message }}
</li>
</ul>
</div>
<ul v-if="group.entries?.length" class="space-y-2">
<li
v-for="entry in group.entries"
:key="entry.key"
class="flex items-start gap-3"
>
<component
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
class="w-4 h-4 mt-0.5"
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
aria-hidden="true"
/>
<div class="flex-1">
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
{{ entry.title }}
</p>
<p v-if="entry.subtitle" class="text-xs text-gray-500">
{{ entry.subtitle }}
</p>
</div>
</li>
</ul>
<p v-else class="text-xs text-gray-500">
Couverture assurée via composants ou pièces liés.
</p>
</div>
</div>
<div <div
v-if="machinePreview.issues.length && machinePreview.status !== 'ready'" v-if="machinePreview.issues.length && machinePreview.status !== 'ready'"
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning" class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
@@ -551,9 +742,11 @@ import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils' import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
@@ -566,6 +759,7 @@ const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi() const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants() const { composants, loadComposants, loading: composantsLoading } = useComposants()
const { pieces, loadPieces, loading: piecesLoading } = usePieces() const { pieces, loadPieces, loading: piecesLoading } = usePieces()
const { products, loadProducts, loading: productsLoading } = useProducts()
const toast = useToast() const toast = useToast()
const submitting = ref(false) const submitting = ref(false)
@@ -579,6 +773,7 @@ const newMachine = reactive({
const componentRequirementSelections = reactive({}) const componentRequirementSelections = reactive({})
const pieceRequirementSelections = reactive({}) const pieceRequirementSelections = reactive({})
const productRequirementSelections = reactive({})
const selectedMachineType = computed(() => { const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) { if (!newMachine.typeMachineId) {
@@ -604,7 +799,12 @@ const machineTypeDescription = (type) => {
} }
const componentCount = type.componentRequirements?.length ?? 0 const componentCount = type.componentRequirements?.length ?? 0
const pieceCount = type.pieceRequirements?.length ?? 0 const pieceCount = type.pieceRequirements?.length ?? 0
parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`) const productCount = type.productRequirements?.length ?? 0
parts.push(
`${componentCount} composant(s)`,
`${pieceCount} pièce(s)`,
`${productCount} produit(s)`
)
return parts.join(' • ') return parts.join(' • ')
} }
@@ -630,6 +830,17 @@ const pieceById = computed(() => {
const componentInventory = computed(() => composants.value || []) const componentInventory = computed(() => composants.value || [])
const pieceInventory = computed(() => pieces.value || []) const pieceInventory = computed(() => pieces.value || [])
const productInventory = computed(() => products.value || [])
const productById = computed(() => {
const map = new Map()
;(productInventory.value || []).forEach((product) => {
if (product?.id) {
map.set(product.id, product)
}
})
return map
})
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -904,6 +1115,11 @@ const componentOptionDescription = (component) => {
if (machineAssignments.length) { if (machineAssignments.length) {
parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
} }
const productTypeName = component.product?.typeProduct?.name
const productLabel = component.product?.name || component.product?.reference
if (productTypeName || productLabel) {
parts.push(`Produit: ${productTypeName || productLabel}`)
}
return parts.join(' • ') return parts.join(' • ')
} }
@@ -929,9 +1145,83 @@ const pieceOptionDescription = (piece) => {
if (componentAssignments.length) { if (componentAssignments.length) {
parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
} }
const productTypeName = piece.product?.typeProduct?.name
const productLabel = piece.product?.name || piece.product?.reference
if (productTypeName || productLabel) {
parts.push(`Produit: ${productTypeName || productLabel}`)
}
return parts.join(' • ') return parts.join(' • ')
} }
const getProductOptions = (requirement) => {
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
return productInventory.value.filter((product) => {
if (!product?.id) {
return false
}
if (!requirementTypeId) {
return true
}
const productTypeId =
product.typeProductId ||
product.typeProduct?.id ||
null
return productTypeId === requirementTypeId
})
}
const productOptionLabel = (product) => product?.name || product?.reference || 'Produit'
const productOptionDescription = (product) => {
if (!product) {
return ''
}
const parts = []
if (product.reference) {
parts.push(`Réf. ${product.reference}`)
}
if (product.constructeurs?.length) {
const label = product.constructeurs
.map((constructeur) => constructeur?.name)
.filter(Boolean)
.join(', ')
if (label) {
parts.push(`Fournisseurs: ${label}`)
}
}
if (product.supplierPrice !== undefined && product.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.join(' • ')
}
const getProductTypeIdFromComponent = (component) => {
if (!component || typeof component !== 'object') {
return null
}
return (
component.product?.typeProductId ||
component.product?.typeProduct?.id ||
component.productTypeId ||
null
)
}
const getProductTypeIdFromPiece = (piece) => {
if (!piece || typeof piece !== 'object') {
return null
}
return (
piece.product?.typeProductId ||
piece.product?.typeProduct?.id ||
piece.productTypeId ||
null
)
}
const setComponentRequirementComponent = (requirement, index, componentId) => { const setComponentRequirementComponent = (requirement, index, componentId) => {
const entries = getComponentRequirementEntries(requirement.id) const entries = getComponentRequirementEntries(requirement.id)
const entry = entries[index] const entry = entries[index]
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
} }
return pieceById.value.get(id) || null return pieceById.value.get(id) || null
} }
const findProductById = (id) => {
if (!id) {
return null
}
return productById.value.get(id) || null
}
const getStatusBadgeClass = (status) => { const getStatusBadgeClass = (status) => {
if (status === 'ready') { if (status === 'ready') {
return 'badge-success' return 'badge-success'
@@ -1003,6 +1300,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || [] const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || [] const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
const createComponentSelectionEntry = (requirement, source = null) => ({ const createComponentSelectionEntry = (requirement, source = null) => ({
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null, typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
@@ -1016,6 +1314,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
definition: {}, definition: {},
}) })
const createProductSelectionEntry = (requirement, source = null) => ({
typeProductId:
source?.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null,
productId: source?.productId || null,
})
const computeProductUsageFromSelections = (type) => {
const usage = new Map()
const increment = (typeProductId) => {
if (!typeProductId) {
return
}
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
}
for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.composantId) {
return
}
const component = findComponentById(entry.composantId)
const typeProductId = getProductTypeIdFromComponent(component)
increment(typeProductId)
})
}
for (const requirement of type.pieceRequirements || []) {
const entries = getPieceRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.pieceId) {
return
}
const piece = findPieceById(entry.pieceId)
const typeProductId = getProductTypeIdFromPiece(piece)
increment(typeProductId)
})
}
for (const requirement of type.productRequirements || []) {
const entries = getProductRequirementEntries(requirement.id)
entries.forEach((entry) => {
if (!entry?.productId) {
return
}
const product = findProductById(entry.productId)
const typeProductId =
product?.typeProductId ||
product?.typeProduct?.id ||
entry?.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
increment(typeProductId)
})
}
return usage
}
const buildProductRequirementStats = (type) => {
const usage = computeProductUsageFromSelections(type)
const stats = (type.productRequirements || []).map((requirement) => {
const typeProductId =
requirement.typeProductId ||
requirement.typeProduct?.id ||
null
const label =
requirement.label?.trim() ||
requirement.typeProduct?.name ||
requirement.typeProduct?.code ||
'Produit requis'
const typeName = requirement.typeProduct?.name || 'Non défini'
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const max = requirement.maxCount ?? null
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
const rawEntries = getProductRequirementEntries(requirement.id)
const normalizedEntries = rawEntries.map((entry, index) => {
const product = entry?.productId ? findProductById(entry.productId) : null
const subtitleParts = []
if (product?.reference) {
subtitleParts.push(`Réf. ${product.reference}`)
}
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) {
subtitleParts.push(`${price.toFixed(2)}`)
}
}
if (Array.isArray(product?.constructeurs) && product.constructeurs.length) {
const label = product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ')
if (label) {
subtitleParts.push(`Fournisseurs: ${label}`)
}
}
return {
key: `${requirement.id}-${index}`,
status: product ? 'complete' : 'pending',
title: product?.name || product?.reference || `Sélection #${index + 1}`,
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
}
})
const issues = []
if (count < min) {
issues.push({
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (max !== null && count > max) {
issues.push({
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (normalizedEntries.length > 0 && normalizedEntries.some((entry) => entry.status !== 'complete')) {
issues.push({
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
const completed = normalizedEntries.filter((entry) => entry.status === 'complete').length
const total = normalizedEntries.length
const status = issues.some((issue) => issue.kind === 'error')
? 'error'
: issues.some((issue) => issue.kind === 'warning')
? 'warning'
: 'ready'
return {
id: requirement.id,
requirement,
label,
typeName,
count,
min,
max,
completed,
total,
entries: normalizedEntries,
issues,
allowNewModels: requirement.allowNewModels ?? true,
status,
}
})
return { stats, usage }
}
const clearRequirementSelections = () => { const clearRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => { Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key] delete componentRequirementSelections[key]
@@ -1023,6 +1485,9 @@ const clearRequirementSelections = () => {
Object.keys(pieceRequirementSelections).forEach((key) => { Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key] delete pieceRequirementSelections[key]
}) })
Object.keys(productRequirementSelections).forEach((key) => {
delete productRequirementSelections[key]
})
} }
const addComponentSelectionEntry = (requirement) => { const addComponentSelectionEntry = (requirement) => {
@@ -1061,6 +1526,51 @@ const removePieceSelectionEntry = (requirementId, index) => {
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index) pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
} }
const addProductSelectionEntry = (requirement) => {
const entries = getProductRequirementEntries(requirement.id)
const max = requirement.maxCount ?? null
if (max !== null && entries.length >= max) {
toast.showError(`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`)
return
}
productRequirementSelections[requirement.id] = [
...entries,
createProductSelectionEntry(requirement),
]
}
const removeProductSelectionEntry = (requirementId, index) => {
const entries = getProductRequirementEntries(requirementId)
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setProductRequirementProduct = (requirement, index, productId) => {
const entries = getProductRequirementEntries(requirement.id)
const entry = entries[index]
if (!entry) {
return
}
const normalizedProductId = productId || null
entry.productId = normalizedProductId
if (normalizedProductId) {
const product = findProductById(normalizedProductId)
entry.typeProductId =
product?.typeProductId ||
product?.typeProduct?.id ||
entry.typeProductId ||
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
} else {
entry.typeProductId =
requirement?.typeProductId ||
requirement?.typeProduct?.id ||
null
}
}
const extractParentIdentifiers = (source) => { const extractParentIdentifiers = (source) => {
if (!isPlainObject(source)) { if (!isPlainObject(source)) {
return {} return {}
@@ -1113,6 +1623,7 @@ const validateRequirementSelections = (type) => {
const errors = [] const errors = []
const componentLinksPayload = [] const componentLinksPayload = []
const pieceLinksPayload = [] const pieceLinksPayload = []
const productLinksPayload = []
for (const requirement of type.componentRequirements || []) { for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id) const entries = getComponentRequirementEntries(requirement.id)
@@ -1216,6 +1727,58 @@ const validateRequirementSelections = (type) => {
}) })
} }
const { stats: productStats } = buildProductRequirementStats(type)
for (const requirement of type.productRequirements || []) {
const entries = getProductRequirementEntries(requirement.id)
const max = requirement.maxCount ?? null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
}
entries.forEach((entry) => {
if (!entry.productId) {
errors.push(`Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`)
return
}
const product = findProductById(entry.productId)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null
const productTypeId =
product.typeProductId ||
product.typeProduct?.id ||
entry.typeProductId ||
null
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
return
}
const payload = {
requirementId: requirement.id,
productId: entry.productId,
}
Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry))
productLinksPayload.push(payload)
})
}
productStats.forEach((stat) => {
stat.issues
.filter((issue) => issue.kind === 'error')
.forEach((issue) => {
errors.push(issue.message)
})
})
if (errors.length > 0) { if (errors.length > 0) {
return { valid: false, error: errors[0] } return { valid: false, error: errors[0] }
} }
@@ -1224,6 +1787,7 @@ const validateRequirementSelections = (type) => {
valid: true, valid: true,
componentLinks: componentLinksPayload, componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload, pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
} }
} }
@@ -1425,20 +1989,24 @@ const machinePreview = computed(() => {
issues, issues,
completed, completed,
total: entries.length, total: entries.length,
status status
} }
}) })
const { stats: productGroups } = buildProductRequirementStats(type)
const aggregatedIssues = [ const aggregatedIssues = [
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })), ...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))), ...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))) ...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
...productGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
] ]
const statuses = [ const statuses = [
baseStatus, baseStatus,
...componentGroups.map(group => group.status), ...componentGroups.map(group => group.status),
...pieceGroups.map(group => group.status) ...pieceGroups.map(group => group.status),
...productGroups.map(group => group.status),
] ]
const overallStatus = statuses.includes('error') const overallStatus = statuses.includes('error')
@@ -1455,11 +2023,14 @@ const machinePreview = computed(() => {
}, },
componentGroups, componentGroups,
pieceGroups, pieceGroups,
productGroups,
type: { type: {
name: type.name, name: type.name,
category: type.category || null, category: type.category || null,
hasStructuredDefinition: hasStructuredDefinition:
(type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0 (type.componentRequirements?.length || 0) > 0 ||
(type.pieceRequirements?.length || 0) > 0 ||
(type.productRequirements?.length || 0) > 0
}, },
status: overallStatus, status: overallStatus,
ready: overallStatus === 'ready', ready: overallStatus === 'ready',
@@ -1508,6 +2079,7 @@ const handleIssueClick = (issue) => {
const initializeRequirementSelections = (type) => { const initializeRequirementSelections = (type) => {
const componentRequirements = type.componentRequirements || [] const componentRequirements = type.componentRequirements || []
const pieceRequirements = type.pieceRequirements || [] const pieceRequirements = type.pieceRequirements || []
const productRequirements = type.productRequirements || []
componentRequirements.forEach((requirement) => { componentRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0) const min = requirement.minCount ?? (requirement.required ? 1 : 0)
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
pieceRequirementSelections[requirement.id] = [] pieceRequirementSelections[requirement.id] = []
} }
}) })
productRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
productRequirementSelections[requirement.id] = Array.from(
{ length: initialCount },
() => createProductSelectionEntry(requirement),
)
} else {
productRequirementSelections[requirement.id] = []
}
})
} }
const finalizeMachineCreation = async () => { const finalizeMachineCreation = async () => {
@@ -1553,10 +2138,14 @@ const finalizeMachineCreation = async () => {
typeMachineId: type.id typeMachineId: type.id
} }
const hasRequirements = (type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0 const hasRequirements =
(type.componentRequirements?.length || 0) > 0 ||
(type.pieceRequirements?.length || 0) > 0 ||
(type.productRequirements?.length || 0) > 0
let componentLinks = [] let componentLinks = []
let pieceLinks = [] let pieceLinks = []
let productLinks = []
if (hasRequirements) { if (hasRequirements) {
const validationResult = validateRequirementSelections(type) const validationResult = validateRequirementSelections(type)
@@ -1566,6 +2155,7 @@ const finalizeMachineCreation = async () => {
} }
componentLinks = validationResult.componentLinks componentLinks = validationResult.componentLinks
pieceLinks = validationResult.pieceLinks pieceLinks = validationResult.pieceLinks
productLinks = validationResult.productLinks
} }
const payload = { const payload = {
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
...(hasRequirements ...(hasRequirements
? { ? {
componentLinks, componentLinks,
pieceLinks pieceLinks,
productLinks
} }
: {}) : {})
} }
@@ -1621,7 +2212,8 @@ onMounted(async () => {
loadSites(), loadSites(),
loadMachineTypes(), loadMachineTypes(),
loadComposants(), loadComposants(),
loadPieces() loadPieces(),
loadProducts()
]) ])
}) })
</script> </script>

View File

@@ -93,6 +93,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Type de pièce</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -106,6 +107,7 @@
</td> </td>
<td>{{ piece.name || 'Pièce sans nom' }}</td> <td>{{ piece.name || 'Pièce sans nom' }}</td>
<td>{{ piece.reference || '—' }}</td> <td>{{ piece.reference || '—' }}</td>
<td>{{ resolvePieceType(piece) }}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -180,6 +182,17 @@ const resolvePreviewAlt = (piece: Record<string, any>) => {
return 'Aperçu du document' return 'Aperçu du document'
} }
const resolvePieceType = (piece: Record<string, any>) => {
const type = piece?.typePiece
if (type?.name) {
return type.name
}
if (piece?.typePieceLabel) {
return piece.typePieceLabel
}
return '—'
}
const resolveDeleteGuard = (piece: Record<string, any>) => { const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = [] const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) const machineLinks = Array.isArray(piece?.machineLinks)

View File

@@ -123,6 +123,36 @@
</div> </div>
</div> </div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="editionForm.productId"
:disabled="saving"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit valide est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
@@ -356,6 +386,7 @@ import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
@@ -366,7 +397,7 @@ import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
@@ -411,6 +442,7 @@ const editionForm = reactive({
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
productId: null as string | null,
}) })
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -542,6 +574,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { customFieldInputs.value.every((field) => {
if (!field.required) { if (!field.required) {
@@ -554,12 +622,15 @@ const requiredCustomFieldsFilled = computed(() =>
}), }),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() =>
piece.value && Boolean(
editionForm.name && piece.value &&
requiredCustomFieldsFilled.value && editionForm.name &&
!saving.value, requiredCustomFieldsFilled.value &&
)) (!requiresProductSelection.value || editionForm.productId) &&
!saving.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -610,6 +681,7 @@ watch(
currentPiece.constructeur ? [currentPiece.constructeur] : [], currentPiece.constructeur ? [currentPiece.constructeur] : [],
) )
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : '' editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
customFieldInputs.value = buildCustomFieldInputs( customFieldInputs.value = buildCustomFieldInputs(
currentType?.structure ?? null, currentType?.structure ?? null,
@@ -636,6 +708,11 @@ const submitEdition = async () => {
return return
} }
if (requiresProductSelection.value && !editionForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string' const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim() ? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined : editionForm.prix === null || editionForm.prix === undefined
@@ -650,6 +727,12 @@ const submitEdition = async () => {
payload.reference = reference ? reference : null payload.reference = reference ? reference : null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds) payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const selectedProductId =
typeof editionForm.productId === 'string'
? editionForm.productId.trim()
: ''
payload.productId = selectedProductId || null
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
@@ -841,7 +924,11 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue) return String(defaultValue)
} }
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : [] const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({ const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name, customFieldName: field.name,

View File

@@ -71,10 +71,10 @@
<span class="label-text">Fournisseur</span> <span class="label-text">Fournisseur</span>
</label> </label>
<ConstructeurSelect <ConstructeurSelect
v-model="creationForm.constructeurId" v-model="creationForm.constructeurIds"
class="w-full" class="w-full"
:disabled="submitting || !selectedType" :disabled="submitting || !selectedType"
placeholder="Rechercher un fournisseur..." placeholder="Rechercher un ou plusieurs fournisseurs..."
/> />
</div> </div>
</div> </div>
@@ -96,6 +96,36 @@
</div> </div>
</div> </div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<ProductSelect
v-model="creationForm.productId"
:disabled="submitting || !selectedType"
:type-product-id="primaryProductRequirement?.typeProductId || null"
helper-text="Un produit est requis pour cette pièce."
/>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
@@ -253,6 +283,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports' import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
@@ -261,7 +292,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
@@ -286,6 +317,7 @@ const creationForm = reactive({
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
productId: null as string | null,
}) })
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
@@ -332,6 +364,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
}) })
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
watch(selectedType, (type) => { watch(selectedType, (type) => {
if (!type) { if (!type) {
clearCreationForm() clearCreationForm()
@@ -343,6 +411,7 @@ watch(selectedType, (type) => {
} }
lastSuggestedName.value = creationForm.name lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure) customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
creationForm.productId = null
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
@@ -357,12 +426,15 @@ const requiredCustomFieldsFilled = computed(() =>
}), }),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() =>
selectedType.value && Boolean(
creationForm.name && selectedType.value &&
requiredCustomFieldsFilled.value && creationForm.name &&
!submitting.value, requiredCustomFieldsFilled.value &&
)) (!requiresProductSelection.value || creationForm.productId) &&
!submitting.value,
),
)
const toFieldString = (value: unknown): string => { const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -377,13 +449,18 @@ const toFieldString = (value: unknown): string => {
return '' return ''
} }
const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : [] const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
creationForm.productId = null
lastSuggestedName.value = '' lastSuggestedName.value = ''
} }
@@ -392,6 +469,12 @@ const submitCreation = async () => {
toast.showError('Sélectionnez une catégorie de pièce.') toast.showError('Sélectionnez une catégorie de pièce.')
return return
} }
if (requiresProductSelection.value && !creationForm.productId) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: creationForm.name.trim(), name: creationForm.name.trim(),
typePieceId: selectedType.value.id, typePieceId: selectedType.value.id,
@@ -406,6 +489,14 @@ const submitCreation = async () => {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds) payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
} }
const selectedProductId =
typeof creationForm.productId === 'string'
? creationForm.productId.trim()
: ''
if (selectedProductId) {
payload.productId = selectedProductId
}
const rawPrice = typeof creationForm.prix === 'string' const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim() ? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined : creationForm.prix === null || creationForm.prix === undefined

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -93,6 +93,38 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Produits requis -->
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Produits requis
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.productRequirements"
:key="requirement.id || requirement.typeProductId"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0) const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0) const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => { const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) { if (value === null || value === undefined) {

View File

@@ -70,7 +70,8 @@ const editedType = ref({
maintenanceFrequency: '', maintenanceFrequency: '',
customFields: [], customFields: [],
componentRequirements: [], componentRequirements: [],
pieceRequirements: [] pieceRequirements: [],
productRequirements: []
}) })
const parseOptions = (field = {}) => { const parseOptions = (field = {}) => {
@@ -140,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index })) .map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId)
.map((req, index) => ({
typeProductId: req.typeProductId,
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => { const saveChanges = async () => {
try { try {
saving.value = true saving.value = true
@@ -151,7 +167,8 @@ const saveChanges = async () => {
...currentEditedType, ...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields), customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements), componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements) pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
} }
const result = await updateMachineType(type.value.id, updatedType) const result = await updateMachineType(type.value.id, updatedType)
@@ -192,7 +209,8 @@ onMounted(async () => {
maintenanceFrequency: type.value.maintenanceFrequency || '', maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [], customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [], componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || [] pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
} }
} else { } else {
console.error('Failed to load type:', result.error) console.error('Failed to load type:', result.error)

View File

@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
import type { import type {
ComponentModelStructure, ComponentModelStructure,
PieceModelStructure, PieceModelStructure,
ProductModelStructure,
} from '~/shared/types/inventory'; } from '~/shared/types/inventory';
export type ModelCategory = 'COMPONENT' | 'PIECE'; export type ModelCategory = 'COMPONENT' | 'PIECE' | 'PRODUCT';
export type ModelTypeStructure = ComponentModelStructure | PieceModelStructure | null; export type ModelTypeStructure =
| ComponentModelStructure
| PieceModelStructure
| ProductModelStructure
| null;
export interface BaseModelTypePayload { export interface BaseModelTypePayload {
name: string; name: string;
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
structure?: PieceModelStructure | null; structure?: PieceModelStructure | null;
} }
export type ModelTypePayload = ComponentModelTypePayload | PieceModelTypePayload; export interface ProductModelTypePayload extends BaseModelTypePayload {
category: 'PRODUCT';
structure?: ProductModelStructure | null;
}
export type ModelTypePayload =
| ComponentModelTypePayload
| PieceModelTypePayload
| ProductModelTypePayload;
export interface ModelType extends BaseModelTypePayload { export interface ModelType extends BaseModelTypePayload {
id: string; id: string;

View File

@@ -3,12 +3,16 @@ import {
type ComponentModelCustomFieldType, type ComponentModelCustomFieldType,
type ComponentModelCustomField, type ComponentModelCustomField,
type ComponentModelPiece, type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure, type ComponentModelStructure,
type ComponentModelStructureNode, type ComponentModelStructureNode,
type PieceModelCustomField, type PieceModelCustomField,
type PieceModelProduct,
type PieceModelStructure, type PieceModelStructure,
type PieceModelStructureEditorField, type PieceModelStructureEditorField,
type PieceModelStructureForEditor, type PieceModelStructureForEditor,
type ProductModelStructure,
createEmptyProductModelStructure,
createEmptyPieceModelStructure, createEmptyPieceModelStructure,
} from './types/inventory' } from './types/inventory'
import { uniqueConstructeurIds } from './constructeurUtils' import { uniqueConstructeurIds } from './constructeurUtils'
@@ -20,6 +24,7 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export interface ModelStructurePreview { export interface ModelStructurePreview {
customFields: number customFields: number
pieces: number pieces: number
products: number
subcomponents: number subcomponents: number
} }
@@ -37,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
...base, ...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [], customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [], pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
subcomponents: Array.isArray((input as any).subcomponents) subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents ? (input as any).subcomponents
: Array.isArray((input as any).subComponents) : Array.isArray((input as any).subComponents)
@@ -240,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
.filter((piece): piece is ComponentModelPiece => !!piece) .filter((piece): piece is ComponentModelPiece => !!piece)
} }
const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -331,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
const result: ComponentModelStructure = { const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[], customFields: customFields as ComponentModelCustomField[],
pieces: sanitizePieces(source.pieces), pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents), subcomponents: hydrateSubcomponents(source.subcomponents),
} }
@@ -398,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
return payload return payload
}) as any }) as any
const backendProducts = sanitizeProducts(source.products).map((product) => {
const payload: Record<string, any> = {}
if ((product as any).familyCode) {
payload.familyCode = (product as any).familyCode
}
if (product.typeProductId) {
payload.typeProductId = product.typeProductId
}
if (product.role) {
payload.role = product.role
}
return payload
}) as any
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => { const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
const payload: Record<string, any> = {} const payload: Record<string, any> = {}
if (subcomponent.typeComposantId) { if (subcomponent.typeComposantId) {
@@ -423,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
const result: ComponentModelStructure = { const result: ComponentModelStructure = {
customFields: backendCustomFields, customFields: backendCustomFields,
pieces: backendPieces, pieces: backendPieces,
products: backendProducts,
subcomponents: backendSubcomponents, subcomponents: backendSubcomponents,
} }
@@ -545,6 +627,20 @@ const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
})) }))
} }
const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -569,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
return { return {
customFields: hydrateCustomFields(source.customFields), customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces), pieces: hydratePieces(source.pieces),
products: hydrateProducts(source.products),
subcomponents: hydrateSubcomponents( subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents, Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
), ),
@@ -619,6 +716,19 @@ const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
})) }))
} }
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => { const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
@@ -645,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
const raw = { const raw = {
customFields: mapComponentCustomFields(component.customFields), customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces), pieces: mapComponentPieces(component.pieces),
products: mapComponentProducts(component.products),
subcomponents: mapSubcomponents( subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents) Array.isArray(component?.subcomponents)
? component.subcomponents ? component.subcomponents
@@ -662,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => { export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subcomponents: 0 } return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
} }
return { return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0, customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0, pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
products: Array.isArray(structure.products) ? structure.products.length : 0,
subcomponents: Array.isArray(structure.subcomponents) subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length ? structure.subcomponents.length
: Array.isArray(structure.subComponents) : Array.isArray(structure.subComponents)
@@ -678,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
export const formatStructurePreview = (structure: any) => { export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure) const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subcomponents) { if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
return 'Structure vide' return 'Structure vide'
} }
const segments: string[] = [] const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`) if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`) if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.products) segments.push(`${stats.products} produit(s)`)
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`) if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ') return segments.join(' • ')
} }
@@ -741,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(), ...createEmptyPieceModelStructure(),
}) })
export const defaultProductStructure = (): ProductModelStructure => ({
...createEmptyProductModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => { const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure() const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) { if (!isPlainObject(input)) {
@@ -750,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const clone: PieceModelStructure = { const clone: PieceModelStructure = {
...base, ...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [], customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
} }
for (const [key, value] of Object.entries(input as Record<string, unknown>)) { for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (key === 'customFields') { if (key === 'customFields' || key === 'products') {
continue continue
} }
clone[key] = value clone[key] = value
@@ -771,6 +889,10 @@ export const clonePieceStructure = (input: any): PieceModelStructure => {
} }
} }
export const cloneProductStructure = (input: any): ProductModelStructure => {
return clonePieceStructure(input)
}
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => { const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
return [] return []
@@ -811,12 +933,18 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
.filter((field): field is PieceModelCustomField => !!field) .filter((field): field is PieceModelCustomField => !!field)
} }
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
return sanitizeProducts(products) as PieceModelProduct[]
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => { export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input) const source = clonePieceStructure(input)
const restEntries = Object.entries(source).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return { return {
...Object.fromEntries( ...Object.fromEntries(restEntries),
Object.entries(source).filter(([key]) => key !== 'customFields'), products: sanitizePieceProducts(source.products),
),
customFields: sanitizePieceCustomFields(source.customFields), customFields: sanitizePieceCustomFields(source.customFields),
} }
} }
@@ -844,8 +972,9 @@ export const hydratePieceStructureForEditor = (input: any): PieceModelStructureF
const source = clonePieceStructure(input) const source = clonePieceStructure(input)
const payload: PieceModelStructureForEditor = { const payload: PieceModelStructureForEditor = {
...Object.fromEntries( ...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'), Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
), ),
products: hydrateProducts(source.products) as PieceModelProduct[],
customFields: hydratePieceCustomFields(source.customFields), customFields: hydratePieceCustomFields(source.customFields),
} }
return payload return payload
@@ -859,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
const customFields = Array.isArray((structure as any).customFields) const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length ? (structure as any).customFields.length
: 0 : 0
const products = Array.isArray((structure as any).products)
? (structure as any).products.length
: 0
if (!customFields) { if (!customFields && !products) {
return 'Aucun champ personnalisé' return 'Aucun produit ni champ personnalisé'
} }
return `${customFields} champ(s) personnalisé(s)` const segments: string[] = []
if (products) {
segments.push(`${products} produit(s)`)
}
if (customFields) {
segments.push(`${customFields} champ(s) personnalisé(s)`)
}
return segments.join(' · ')
} }
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
normalizePieceStructureForSave(input)
export const hydrateProductStructureForEditor = (input: any) =>
hydratePieceStructureForEditor(input)
export const formatProductStructurePreview = (structure: any) =>
formatPieceStructurePreview(structure)

View File

@@ -20,18 +20,28 @@ export interface ComponentModelPiece {
role?: string role?: string
} }
export interface ComponentModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
}
export interface ComponentModelStructureNode { export interface ComponentModelStructureNode {
typeComposantId?: string typeComposantId?: string
typeComposantLabel?: string typeComposantLabel?: string
modelId?: string modelId?: string
familyCode?: string familyCode?: string
alias?: string alias?: string
products?: ComponentModelProduct[]
subcomponents: ComponentModelStructureNode[] subcomponents: ComponentModelStructureNode[]
} }
export interface ComponentModelStructure extends ComponentModelStructureNode { export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[] customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[] pieces: ComponentModelPiece[]
products: ComponentModelProduct[]
} }
export type PieceModelCustomFieldType = ComponentModelCustomFieldType export type PieceModelCustomFieldType = ComponentModelCustomFieldType
@@ -44,8 +54,17 @@ export interface PieceModelCustomField {
orderIndex?: number orderIndex?: number
} }
export interface PieceModelProduct {
typeProductId?: string
typeProductLabel?: string
reference?: string
familyCode?: string
role?: string
}
export interface PieceModelStructure { export interface PieceModelStructure {
customFields: PieceModelCustomField[] customFields: PieceModelCustomField[]
products?: PieceModelProduct[]
[key: string]: unknown [key: string]: unknown
} }
@@ -55,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
export interface PieceModelStructureForEditor { export interface PieceModelStructureForEditor {
customFields: PieceModelStructureEditorField[] customFields: PieceModelStructureEditorField[]
products?: PieceModelProduct[]
[key: string]: unknown [key: string]: unknown
} }
export type ProductModelCustomField = PieceModelCustomField
export type ProductModelStructure = PieceModelStructure
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date'] const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => { const isPlainObject = (value: unknown): value is Record<string, unknown> => {
@@ -252,9 +275,15 @@ export const componentModelStructureValidator = {
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({ export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [], customFields: [],
pieces: [], pieces: [],
products: [],
subcomponents: [], subcomponents: [],
}) })
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({ export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
customFields: [], customFields: [],
products: [],
})
export const createEmptyProductModelStructure = (): ProductModelStructure => ({
customFields: [],
}) })

View File

@@ -4,6 +4,23 @@ import {
formatConstructeurContact, formatConstructeurContact,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatCurrency = (value) => {
if (value === undefined || value === null || value === '') {
return null
}
const number = Number(value)
if (Number.isNaN(number)) {
return null
}
return currencyFormatter.format(number)
}
const formatSize = (size) => { const formatSize = (size) => {
if (size === undefined || size === null) { return '—' } if (size === undefined || size === null) { return '—' }
if (size === 0) { return '0 B' } if (size === 0) { return '0 B' }
@@ -55,6 +72,49 @@ const renderPrintDocuments = (documents = [], title, sectionClass = 'print-secti
` `
} }
const renderPrintProductSummary = (product, title = 'Produit catalogue', sectionClass = 'print-piece-section') => {
if (!product) { return '' }
const infoEntries = [
{ label: 'Nom', value: product.name || '—' },
{ label: 'Référence', value: product.reference || '—' },
{ label: 'Catégorie', value: product.typeName || '—' },
{
label: 'Prix indicatif',
value: product.supplierPrice || '—',
},
{
label: 'Fournisseur(s)',
value: product.constructeurs?.length
? product.constructeurs.map((constructeur) => constructeur.name).filter(Boolean).join(', ') || '—'
: '—',
},
]
const infoMarkup = infoEntries
.map((field) => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
.join('')
const customFieldsBlock = product.customFields?.length
? renderPrintCustomFields(product.customFields, 'Champs personnalisés du produit', 'print-subsection')
: ''
const documentsBlock = product.documents?.length
? renderPrintDocuments(product.documents, 'Documents du produit', 'print-subsection')
: ''
return `
<div class="${sectionClass}">
<h4>${title}</h4>
<div class="print-grid">
${infoMarkup}
</div>
${customFieldsBlock}
${documentsBlock}
</div>
`
}
const renderPrintPieces = ( const renderPrintPieces = (
pieces = [], pieces = [],
title = 'Pièces indépendantes', title = 'Pièces indépendantes',
@@ -94,6 +154,8 @@ const renderPrintPieces = (
.join('')}</ul></div>` .join('')}</ul></div>`
: '' : ''
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
return ` return `
<div class="print-piece-card"> <div class="print-piece-card">
<div class="print-piece-header"> <div class="print-piece-header">
@@ -122,6 +184,7 @@ const renderPrintPieces = (
: '—'}</span> : '—'}</span>
</div> </div>
</div> </div>
${productBlock}
${customFieldsBlock} ${customFieldsBlock}
${documentsBlock} ${documentsBlock}
</div> </div>
@@ -154,6 +217,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}` const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
const currentIndex = [...indexPath, idx + 1] const currentIndex = [...indexPath, idx + 1]
const indexLabel = currentIndex.join('.') const indexLabel = currentIndex.join('.')
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
return ` return `
<div class="${sectionClass}"> <div class="${sectionClass}">
<h3> <h3>
@@ -162,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
</h3> </h3>
${component.description ? `<p class="print-muted">${component.description}</p>` : ''} ${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''} ${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
${productBlock}
${renderPrintCustomFields( ${renderPrintCustomFields(
component.customFields, component.customFields,
'Champs personnalisés', 'Champs personnalisés',
@@ -233,7 +298,28 @@ const normalizeConstructeurList = (...sources) => {
.filter(Boolean) .filter(Boolean)
} }
const normalizeProduct = (product) => {
if (!product) { return null }
const constructeurs = normalizeConstructeurList(
product.constructeurs,
product.constructeur,
product.constructeurIds,
product.constructeurId,
)
return {
id: product.id || null,
name: product.name || 'Produit sans nom',
reference: product.reference || '',
supplierPrice: formatCurrency(product.supplierPrice),
typeName: product.typeProduct?.name || null,
constructeurs,
customFields: normalizeCustomFields(product.customFieldValues || []),
documents: normalizeDocuments(product.documents || []),
}
}
const normalizePiece = piece => { const normalizePiece = piece => {
const rawProduct = piece.product || null
const constructeurs = normalizeConstructeurList( const constructeurs = normalizeConstructeurList(
piece.constructeurs, piece.constructeurs,
piece.constructeur, piece.constructeur,
@@ -241,7 +327,12 @@ const normalizePiece = piece => {
piece.originalPiece?.constructeur, piece.originalPiece?.constructeur,
piece.constructeurIds, piece.constructeurIds,
piece.constructeurId, piece.constructeurId,
rawProduct?.constructeurs,
rawProduct?.constructeur,
rawProduct?.constructeurIds,
rawProduct?.constructeurId,
) )
const product = normalizeProduct(rawProduct)
return { return {
id: piece.id, id: piece.id,
@@ -252,11 +343,13 @@ const normalizePiece = piece => {
documents: normalizeDocuments(piece.documents || []), documents: normalizeDocuments(piece.documents || []),
constructeurs, constructeurs,
constructeur: constructeurs[0] || null, constructeur: constructeurs[0] || null,
product,
indexPath: piece.indexPath || null indexPath: piece.indexPath || null
} }
} }
const normalizeComponent = component => { const normalizeComponent = component => {
const rawProduct = component.product || null
const constructeurs = normalizeConstructeurList( const constructeurs = normalizeConstructeurList(
component.constructeurs, component.constructeurs,
component.constructeur, component.constructeur,
@@ -264,7 +357,12 @@ const normalizeComponent = component => {
component.originalComposant?.constructeur, component.originalComposant?.constructeur,
component.constructeurIds, component.constructeurIds,
component.constructeurId, component.constructeurId,
rawProduct?.constructeurs,
rawProduct?.constructeur,
rawProduct?.constructeurIds,
rawProduct?.constructeurId,
) )
const product = normalizeProduct(rawProduct)
return { return {
id: component.id, id: component.id,
@@ -276,6 +374,7 @@ const normalizeComponent = component => {
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent), subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
constructeurs, constructeurs,
constructeur: constructeurs[0] || null, constructeur: constructeurs[0] || null,
product,
} }
} }