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

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