Enable no-console (warn, allow error), @typescript-eslint/no-unused-vars (warn, ignore _ prefix), and @typescript-eslint/no-explicit-any (warn). Fix all 26 no-unused-vars violations across 9 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1168 lines
38 KiB
Vue
1168 lines
38 KiB
Vue
<template>
|
||
<div :class="containerClass">
|
||
<div class="border border-base-200 rounded-lg bg-base-100 shadow-sm">
|
||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
|
||
<div class="flex-1 min-w-[220px] space-y-2">
|
||
<label class="label">
|
||
<span class="label-text text-xs font-semibold">
|
||
{{ isRoot ? 'Composant racine de la catégorie' : 'Famille de composant' }}
|
||
</span>
|
||
</label>
|
||
<template v-if="isRoot">
|
||
<p class="text-[11px] text-gray-500">
|
||
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
|
||
</p>
|
||
</template>
|
||
<template v-else-if="!lockType">
|
||
<select
|
||
v-model="node.typeComposantId"
|
||
class="select select-bordered select-sm w-full"
|
||
:disabled="isLocked"
|
||
@change="handleComponentTypeSelect(node)"
|
||
>
|
||
<option value="">
|
||
Sélectionner une famille de composant
|
||
</option>
|
||
<option
|
||
v-for="type in componentTypes"
|
||
:key="type.id"
|
||
:value="type.id"
|
||
>
|
||
{{ formatComponentTypeOption(type) }}
|
||
</option>
|
||
</select>
|
||
<p class="text-[11px] text-gray-500">
|
||
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||
</p>
|
||
<div v-if="!isRoot" class="form-control mt-2">
|
||
<label class="label py-1">
|
||
<span class="label-text text-[11px]">Alias (optionnel)</span>
|
||
</label>
|
||
<input
|
||
v-model="node.alias"
|
||
type="text"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Alias du sous-composant"
|
||
:disabled="isLocked"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||
{{ lockedTypeDisplay }}
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<button
|
||
v-if="!isRoot && !isLocked"
|
||
type="button"
|
||
class="btn btn-error btn-xs btn-square"
|
||
@click="emit('remove')"
|
||
>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
|
||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="px-4 py-4 space-y-5">
|
||
<section v-if="isRoot" class="space-y-3">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<h4 :class="headingClass">
|
||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||
</h4>
|
||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||
Ajouter
|
||
</button>
|
||
</div>
|
||
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
||
Aucun champ n'a encore été défini.
|
||
</p>
|
||
<div v-else class="space-y-2">
|
||
<div
|
||
v-for="(field, index) in node.customFields"
|
||
:key="`field-${index}`"
|
||
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
|
||
:class="customFieldReorderClass(index)"
|
||
draggable="true"
|
||
@dragstart="onCustomFieldDragStart(index, $event)"
|
||
@dragenter="onCustomFieldDragEnter(index)"
|
||
@dragover.prevent
|
||
@drop="onCustomFieldDrop(index)"
|
||
@dragend="onCustomFieldDragEnd"
|
||
>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||
title="Réorganiser"
|
||
draggable="true"
|
||
@dragstart.stop="onCustomFieldDragStart(index, $event)"
|
||
>
|
||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div class="flex-1 space-y-2">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||
<input
|
||
v-model="field.name"
|
||
type="text"
|
||
class="input input-bordered input-xs"
|
||
placeholder="Nom du champ"
|
||
:disabled="isCustomFieldLocked(index)"
|
||
/>
|
||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||
<option value="text">Texte</option>
|
||
<option value="number">Nombre</option>
|
||
<option value="select">Liste</option>
|
||
<option value="boolean">Oui/Non</option>
|
||
<option value="date">Date</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs">
|
||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
|
||
Obligatoire
|
||
</div>
|
||
<textarea
|
||
v-if="field.type === 'select'"
|
||
v-model="field.optionsText"
|
||
class="textarea textarea-bordered textarea-xs h-20"
|
||
placeholder="Option 1 Option 2"
|
||
:disabled="isCustomFieldLocked(index)"
|
||
></textarea>
|
||
</div>
|
||
<button
|
||
v-if="!isCustomFieldLocked(index)"
|
||
type="button"
|
||
class="btn btn-error btn-xs btn-square"
|
||
@click="removeCustomField(index)"
|
||
>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||
disabled
|
||
>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 v-if="!restrictedMode" 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"
|
||
:disabled="isProductLocked(index)"
|
||
@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 v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||
<button
|
||
type="button"
|
||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||
disabled
|
||
>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="isRoot" class="space-y-3">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<h4 :class="headingClass">
|
||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||
</h4>
|
||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||
Ajouter
|
||
</button>
|
||
</div>
|
||
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
|
||
Aucune pièce définie.
|
||
</p>
|
||
<div v-else class="space-y-2">
|
||
<div
|
||
v-for="(piece, index) in node.pieces"
|
||
:key="`piece-${index}`"
|
||
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
|
||
:class="pieceReorderClass(index)"
|
||
@dragenter="onPieceDragEnter(index)"
|
||
@dragover="onPieceDragOver"
|
||
@drop="onPieceDrop(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="onPieceDragStart(index, $event)"
|
||
@dragend="onPieceDragEnd"
|
||
>
|
||
<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"><span class="label-text">Famille de pièce</span></label>
|
||
<div>
|
||
<select
|
||
v-model="piece.typePieceId"
|
||
class="select select-bordered select-xs"
|
||
:disabled="isPieceLocked(index)"
|
||
@change="handlePieceTypeSelect(piece)"
|
||
>
|
||
<option value="">
|
||
Sélectionner une famille
|
||
</option>
|
||
<option
|
||
v-for="type in pieceTypes"
|
||
:key="type.id"
|
||
:value="type.id"
|
||
>
|
||
{{ formatPieceTypeOption(type) }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<p class="mt-1 text-[11px] text-gray-500">
|
||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
|
||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<h4 :class="headingClass">Sous-composants</h4>
|
||
<button
|
||
v-if="canManageSubcomponents && !restrictedMode"
|
||
type="button"
|
||
class="btn btn-outline btn-xs"
|
||
@click="addSubComponent"
|
||
>
|
||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||
Ajouter
|
||
</button>
|
||
</div>
|
||
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500">
|
||
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
|
||
</p>
|
||
<p v-if="!hasSubcomponents" class="text-xs text-gray-500">
|
||
Aucun sous-composant défini.
|
||
</p>
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="(subComponent, index) in node.subcomponents"
|
||
:key="`sub-${index}`"
|
||
class="relative pl-8 transition-shadow rounded-lg"
|
||
:class="subcomponentReorderClass(index)"
|
||
@dragenter="onSubcomponentDragEnter(index)"
|
||
@dragover="onSubcomponentDragOver"
|
||
@drop="onSubcomponentDrop(index)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="absolute left-0 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||
draggable="true"
|
||
title="Réorganiser"
|
||
@dragstart="onSubcomponentDragStart(index, $event)"
|
||
@dragend="onSubcomponentDragEnd"
|
||
>
|
||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||
</button>
|
||
<StructureNodeEditor
|
||
:node="subComponent"
|
||
:depth="depth + 1"
|
||
:component-types="componentTypes"
|
||
:piece-types="pieceTypes"
|
||
:product-types="productTypes"
|
||
:allow-subcomponents="childAllowSubcomponents"
|
||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||
:restricted-mode="restrictedMode"
|
||
:is-locked="isSubcomponentLocked(index)"
|
||
@remove="removeSubComponent(index)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
|
||
|
||
defineOptions({ name: 'StructureNodeEditor' })
|
||
|
||
type ModelTypeOption = {
|
||
id: string
|
||
name: string
|
||
code?: string | null
|
||
}
|
||
|
||
type EditableStructureNode = ComponentModelStructureNode & {
|
||
customFields?: any[]
|
||
pieces?: ComponentModelPiece[]
|
||
products?: ComponentModelProduct[]
|
||
}
|
||
|
||
const props = withDefaults(defineProps<{
|
||
node: EditableStructureNode
|
||
depth?: number
|
||
componentTypes?: ModelTypeOption[]
|
||
pieceTypes?: ModelTypeOption[]
|
||
productTypes?: ModelTypeOption[]
|
||
isRoot?: boolean
|
||
lockType?: boolean
|
||
lockedTypeLabel?: string
|
||
allowSubcomponents?: boolean
|
||
maxSubcomponentDepth?: number
|
||
restrictedMode?: boolean
|
||
isLocked?: boolean
|
||
}>(), {
|
||
depth: 0,
|
||
componentTypes: () => [],
|
||
pieceTypes: () => [],
|
||
productTypes: () => [],
|
||
isRoot: false,
|
||
lockType: false,
|
||
lockedTypeLabel: '',
|
||
allowSubcomponents: true,
|
||
maxSubcomponentDepth: Infinity,
|
||
restrictedMode: false,
|
||
isLocked: false,
|
||
})
|
||
|
||
const emit = defineEmits(['remove'])
|
||
|
||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
||
const initialPieceIndices = ref<Set<number>>(new Set())
|
||
const initialProductIndices = ref<Set<number>>(new Set())
|
||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
||
|
||
const initializeLockedIndices = () => {
|
||
if (props.restrictedMode) {
|
||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
||
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
|
||
|
||
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
|
||
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
|
||
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
|
||
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
|
||
}
|
||
}
|
||
|
||
initializeLockedIndices()
|
||
|
||
const isCustomFieldLocked = (index: number): boolean => {
|
||
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
|
||
}
|
||
|
||
const isPieceLocked = (index: number): boolean => {
|
||
return props.restrictedMode === true && initialPieceIndices.value.has(index)
|
||
}
|
||
|
||
const isProductLocked = (index: number): boolean => {
|
||
return props.restrictedMode === true && initialProductIndices.value.has(index)
|
||
}
|
||
|
||
const isSubcomponentLocked = (index: number): boolean => {
|
||
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
|
||
}
|
||
|
||
const isLocked = computed(() => props.isLocked === true)
|
||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||
|
||
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,
|
||
)
|
||
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
|
||
const canManageSubcomponents = computed(
|
||
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
|
||
)
|
||
const childAllowSubcomponents = computed(
|
||
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
|
||
)
|
||
const hasSubcomponents = computed(
|
||
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
|
||
)
|
||
|
||
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
|
||
const containerClass = computed(() => {
|
||
const level = currentDepth.value
|
||
const index = Math.min(level, depthClasses.length - 1)
|
||
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
|
||
})
|
||
|
||
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
|
||
const lockedTypeDisplay = computed(() => {
|
||
if (props.lockedTypeLabel) {
|
||
return props.lockedTypeLabel
|
||
}
|
||
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
|
||
})
|
||
|
||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||
type?.name ?? ''
|
||
|
||
const componentTypeMap = computed(() => {
|
||
const map = new Map<string, ModelTypeOption>()
|
||
componentTypes.value.forEach((type) => {
|
||
if (type && typeof type.id === 'string') {
|
||
map.set(type.id, type)
|
||
}
|
||
})
|
||
return map
|
||
})
|
||
|
||
const componentTypeCodeMap = computed(() => {
|
||
const map = new Map<string, ModelTypeOption>()
|
||
componentTypes.value.forEach((type) => {
|
||
const code = typeof type?.code === 'string' ? type.code.trim() : ''
|
||
if (code) {
|
||
map.set(code, type)
|
||
}
|
||
})
|
||
return map
|
||
})
|
||
|
||
const pieceTypeMap = computed(() => {
|
||
const map = new Map<string, ModelTypeOption>()
|
||
pieceTypes.value.forEach((type) => {
|
||
if (type && typeof type.id === 'string') {
|
||
map.set(type.id, type)
|
||
}
|
||
})
|
||
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))
|
||
}
|
||
|
||
const getPieceTypeLabel = (id?: string) => {
|
||
if (!id) return ''
|
||
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 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] = []
|
||
}
|
||
}
|
||
}
|
||
|
||
const syncComponentType = (component: EditableStructureNode) => {
|
||
if (!component) {
|
||
return
|
||
}
|
||
if (props.isRoot) {
|
||
component.typeComposantId = ''
|
||
component.typeComposantLabel = ''
|
||
component.familyCode = ''
|
||
if (component.alias) {
|
||
component.alias = ''
|
||
}
|
||
return
|
||
}
|
||
if (props.lockType && props.isRoot) {
|
||
if (props.lockedTypeLabel) {
|
||
component.typeComposantLabel = props.lockedTypeLabel
|
||
if (!component.alias || component.alias === component.typeComposantLabel) {
|
||
component.alias = props.lockedTypeLabel
|
||
}
|
||
}
|
||
if (component.typeComposantId) {
|
||
const option = componentTypeMap.value.get(component.typeComposantId)
|
||
component.familyCode = option?.code ?? component.familyCode
|
||
}
|
||
return
|
||
}
|
||
const id = typeof component.typeComposantId === 'string'
|
||
? component.typeComposantId
|
||
: ''
|
||
|
||
if (!id) {
|
||
const code =
|
||
typeof component.familyCode === 'string' && component.familyCode
|
||
? component.familyCode
|
||
: ''
|
||
if (code) {
|
||
const codeMatch = componentTypeCodeMap.value.get(code)
|
||
if (codeMatch?.id) {
|
||
component.typeComposantId = codeMatch.id
|
||
component.typeComposantLabel = formatModelTypeOption(codeMatch)
|
||
component.familyCode = codeMatch.code ?? component.familyCode
|
||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||
component.alias = codeMatch.name || component.typeComposantLabel
|
||
}
|
||
return
|
||
}
|
||
}
|
||
component.typeComposantLabel = ''
|
||
component.familyCode = ''
|
||
return
|
||
}
|
||
|
||
const option = componentTypeMap.value.get(id)
|
||
if (!option) {
|
||
component.typeComposantLabel = ''
|
||
component.familyCode = ''
|
||
return
|
||
}
|
||
|
||
component.typeComposantLabel = formatModelTypeOption(option)
|
||
component.familyCode = option.code ?? component.familyCode
|
||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||
component.alias = option.name || component.typeComposantLabel
|
||
}
|
||
}
|
||
|
||
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
|
||
if (!piece) return
|
||
|
||
if (piece.typePieceId) {
|
||
const option = pieceTypeMap.value.get(piece.typePieceId)
|
||
if (option) {
|
||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (piece.typePieceLabel) {
|
||
const normalized = piece.typePieceLabel.trim().toLowerCase()
|
||
if (normalized) {
|
||
const match = pieceTypes.value.find((type) => {
|
||
const formatted = formatPieceTypeOption(type).toLowerCase()
|
||
const name = (type?.name ?? '').toLowerCase()
|
||
const code = (type?.code ?? '').toLowerCase()
|
||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||
})
|
||
if (match) {
|
||
piece.typePieceId = match.id
|
||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
pieces.forEach((piece) => {
|
||
updatePieceTypeLabel(piece)
|
||
})
|
||
}
|
||
|
||
const syncProductLabels = (products?: any[]) => {
|
||
if (!Array.isArray(products)) {
|
||
return
|
||
}
|
||
products.forEach((product) => {
|
||
updateProductTypeLabel(product)
|
||
})
|
||
}
|
||
|
||
const handleComponentTypeSelect = (component: any) => {
|
||
syncComponentType(component)
|
||
}
|
||
|
||
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
|
||
if (!piece) {
|
||
return
|
||
}
|
||
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
||
if (!id) {
|
||
piece.typePieceLabel = ''
|
||
return
|
||
}
|
||
const option = pieceTypeMap.value.get(id)
|
||
if (!option) {
|
||
piece.typePieceId = ''
|
||
piece.typePieceLabel = ''
|
||
return
|
||
}
|
||
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,
|
||
})
|
||
|
||
const reindexCustomFields = () => {
|
||
if (!Array.isArray(props.node.customFields)) {
|
||
return
|
||
}
|
||
props.node.customFields.forEach((field: any, index: number) => {
|
||
if (!field || typeof field !== 'object') {
|
||
return
|
||
}
|
||
field.orderIndex = index
|
||
})
|
||
}
|
||
|
||
const resetCustomFieldDragState = () => {
|
||
customFieldDragState.value.draggingIndex = null
|
||
customFieldDragState.value.dropTargetIndex = null
|
||
}
|
||
|
||
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
|
||
customFieldDragState.value.draggingIndex = index
|
||
customFieldDragState.value.dropTargetIndex = index
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
}
|
||
|
||
const onCustomFieldDragEnter = (index: number) => {
|
||
if (customFieldDragState.value.draggingIndex === null) {
|
||
return
|
||
}
|
||
customFieldDragState.value.dropTargetIndex = index
|
||
}
|
||
|
||
const onCustomFieldDrop = (index: number) => {
|
||
if (!Array.isArray(props.node.customFields)) {
|
||
resetCustomFieldDragState()
|
||
return
|
||
}
|
||
const from = customFieldDragState.value.draggingIndex
|
||
const to = index
|
||
if (from === null || to === null) {
|
||
resetCustomFieldDragState()
|
||
return
|
||
}
|
||
moveItemInPlace(props.node.customFields, from, to)
|
||
reindexCustomFields()
|
||
resetCustomFieldDragState()
|
||
}
|
||
|
||
const onCustomFieldDragEnd = () => {
|
||
resetCustomFieldDragState()
|
||
}
|
||
|
||
const customFieldReorderClass = (index: number) => {
|
||
if (customFieldDragState.value.draggingIndex === index) {
|
||
return 'border-dashed border-primary'
|
||
}
|
||
if (
|
||
customFieldDragState.value.draggingIndex !== null &&
|
||
customFieldDragState.value.dropTargetIndex === index &&
|
||
customFieldDragState.value.draggingIndex !== index
|
||
) {
|
||
return 'border-primary border-dashed bg-primary/5'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
const addCustomField = () => {
|
||
ensureArray('customFields')
|
||
const fields = props.node.customFields!
|
||
const nextIndex = fields.length
|
||
fields.push({
|
||
name: '',
|
||
type: 'text',
|
||
required: false,
|
||
optionsText: '',
|
||
options: [],
|
||
orderIndex: nextIndex,
|
||
})
|
||
reindexCustomFields()
|
||
}
|
||
|
||
const removeCustomField = (index: number) => {
|
||
if (!Array.isArray(props.node.customFields)) return
|
||
props.node.customFields.splice(index, 1)
|
||
reindexCustomFields()
|
||
}
|
||
|
||
const addPiece = () => {
|
||
ensureArray('pieces')
|
||
props.node.pieces!.push({
|
||
typePieceId: '',
|
||
typePieceLabel: '',
|
||
reference: '',
|
||
familyCode: '',
|
||
role: '',
|
||
})
|
||
}
|
||
|
||
const removePiece = (index: number) => {
|
||
if (!Array.isArray(props.node.pieces)) return
|
||
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
|
||
}
|
||
ensureArray('subcomponents')
|
||
props.node.subcomponents.push({
|
||
typeComposantId: '',
|
||
typeComposantLabel: '',
|
||
modelId: '',
|
||
familyCode: '',
|
||
alias: '',
|
||
subcomponents: [],
|
||
})
|
||
}
|
||
|
||
const removeSubComponent = (index: number) => {
|
||
if (!Array.isArray(props.node.subcomponents)) return
|
||
props.node.subcomponents.splice(index, 1)
|
||
}
|
||
|
||
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)
|
||
|
||
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
|
||
if (from === to) {
|
||
return
|
||
}
|
||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||
return
|
||
}
|
||
const updated = list.slice()
|
||
const [item] = updated.splice(from, 1)
|
||
if (item === undefined) return
|
||
updated.splice(to, 0, item)
|
||
list.splice(0, list.length, ...updated)
|
||
}
|
||
|
||
const resetPieceDragState = () => {
|
||
draggingPieceIndex.value = null
|
||
pieceDropTargetIndex.value = null
|
||
}
|
||
|
||
const resetProductDragState = () => {
|
||
draggingProductIndex.value = null
|
||
productDropTargetIndex.value = null
|
||
}
|
||
|
||
const onPieceDragStart = (index: number, event: DragEvent) => {
|
||
draggingPieceIndex.value = index
|
||
pieceDropTargetIndex.value = index
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
}
|
||
|
||
const onPieceDragEnter = (index: number) => {
|
||
if (draggingPieceIndex.value === null) {
|
||
return
|
||
}
|
||
pieceDropTargetIndex.value = index
|
||
}
|
||
|
||
const onPieceDragOver = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
}
|
||
|
||
const onPieceDrop = (index: number) => {
|
||
if (!Array.isArray(props.node.pieces)) {
|
||
resetPieceDragState()
|
||
return
|
||
}
|
||
const from = draggingPieceIndex.value
|
||
const to = index
|
||
if (from === null || to === null) {
|
||
resetPieceDragState()
|
||
return
|
||
}
|
||
moveItemInPlace(props.node.pieces, from, to)
|
||
resetPieceDragState()
|
||
}
|
||
|
||
const onPieceDragEnd = () => {
|
||
resetPieceDragState()
|
||
}
|
||
|
||
const pieceReorderClass = (index: number) => {
|
||
if (draggingPieceIndex.value === index) {
|
||
return 'border-dashed border-primary'
|
||
}
|
||
if (
|
||
draggingPieceIndex.value !== null &&
|
||
pieceDropTargetIndex.value === index &&
|
||
draggingPieceIndex.value !== index
|
||
) {
|
||
return 'border-primary border-dashed bg-primary/5'
|
||
}
|
||
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
|
||
}
|
||
|
||
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
|
||
draggingSubcomponentIndex.value = index
|
||
subcomponentDropTargetIndex.value = index
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
}
|
||
|
||
const onSubcomponentDragEnter = (index: number) => {
|
||
if (draggingSubcomponentIndex.value === null) {
|
||
return
|
||
}
|
||
subcomponentDropTargetIndex.value = index
|
||
}
|
||
|
||
const onSubcomponentDragOver = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
}
|
||
|
||
const onSubcomponentDrop = (index: number) => {
|
||
if (!Array.isArray(props.node.subcomponents)) {
|
||
resetSubcomponentDragState()
|
||
return
|
||
}
|
||
const from = draggingSubcomponentIndex.value
|
||
const to = index
|
||
if (from === null || to === null) {
|
||
resetSubcomponentDragState()
|
||
return
|
||
}
|
||
moveItemInPlace(props.node.subcomponents, from, to)
|
||
resetSubcomponentDragState()
|
||
}
|
||
|
||
const onSubcomponentDragEnd = () => {
|
||
resetSubcomponentDragState()
|
||
}
|
||
|
||
const subcomponentReorderClass = (index: number) => {
|
||
if (draggingSubcomponentIndex.value === index) {
|
||
return 'ring-2 ring-primary'
|
||
}
|
||
if (
|
||
draggingSubcomponentIndex.value !== null &&
|
||
subcomponentDropTargetIndex.value === index &&
|
||
draggingSubcomponentIndex.value !== index
|
||
) {
|
||
return 'ring-2 ring-primary/70'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
watch(
|
||
canManageSubcomponents,
|
||
(allowed) => {
|
||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(componentTypes, () => {
|
||
syncComponentType(props.node)
|
||
}, { deep: true, immediate: true })
|
||
|
||
watch(
|
||
() => props.node.typeComposantId,
|
||
() => {
|
||
syncComponentType(props.node)
|
||
},
|
||
)
|
||
|
||
watch(pieceTypes, () => {
|
||
syncPieceLabels(props.node?.pieces)
|
||
}, { deep: true, immediate: true })
|
||
|
||
watch(
|
||
() => props.node.pieces,
|
||
(value) => {
|
||
syncPieceLabels(value)
|
||
},
|
||
{ 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) => {
|
||
if (!Array.isArray(value)) {
|
||
return
|
||
}
|
||
value.sort((a: any, b: any) => {
|
||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||
return left - right
|
||
})
|
||
reindexCustomFields()
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
watch(
|
||
() => [props.lockedTypeLabel, props.lockType],
|
||
() => {
|
||
if (props.lockType && props.isRoot) {
|
||
const label = props.lockedTypeLabel || lockedTypeDisplay.value
|
||
props.node.typeComposantLabel = label
|
||
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
|
||
props.node.alias = label
|
||
}
|
||
if (props.node.typeComposantId) {
|
||
const option = componentTypeMap.value.get(props.node.typeComposantId)
|
||
props.node.familyCode = option?.code ?? props.node.familyCode
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
</script>
|