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