diff --git a/app/components/StructureNodeEditor.vue b/app/components/StructureNodeEditor.vue
index 8ae67d7..2ad5fc3 100644
--- a/app/components/StructureNodeEditor.vue
+++ b/app/components/StructureNodeEditor.vue
@@ -356,26 +356,13 @@
diff --git a/app/composables/useStructureNodeCrud.ts b/app/composables/useStructureNodeCrud.ts
new file mode 100644
index 0000000..b5188f4
--- /dev/null
+++ b/app/composables/useStructureNodeCrud.ts
@@ -0,0 +1,205 @@
+import { ref } from 'vue'
+import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
+
+export interface StructureNodeCrudDeps {
+ node: EditableStructureNode
+ restrictedMode: boolean
+ canManageSubcomponents: () => boolean
+}
+
+export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
+ // --- Lock state ---
+ const initialCustomFieldIndices = ref>(new Set())
+ const initialPieceIndices = ref>(new Set())
+ const initialProductIndices = ref>(new Set())
+ const initialSubcomponentIndices = ref>(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)
+ }
+
+ // --- Helpers ---
+ 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] = []
+ }
+ }
+ }
+
+ // --- Custom field reindex ---
+ 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
+ })
+ }
+
+ // --- Drag reorder ---
+ const customFieldDrag = useDragReorder(
+ () => props.node.customFields,
+ { onReorder: reindexCustomFields },
+ )
+
+ const pieceDrag = useDragReorder(() => props.node.pieces)
+ const productDrag = useDragReorder(() => props.node.products)
+ const subcomponentDrag = useDragReorder(
+ () => props.node.subcomponents,
+ { draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
+ )
+
+ // --- CRUD functions ---
+ 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 (!props.canManageSubcomponents()) {
+ 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)
+ }
+
+ return {
+ // Lock checks
+ isCustomFieldLocked,
+ isPieceLocked,
+ isProductLocked,
+ isSubcomponentLocked,
+ // Helpers exposed for watchers
+ reindexCustomFields,
+ // CRUD
+ addCustomField,
+ removeCustomField,
+ addPiece,
+ removePiece,
+ addProduct,
+ removeProduct,
+ addSubComponent,
+ removeSubComponent,
+ // Drag reorder — custom fields
+ onCustomFieldDragStart: customFieldDrag.onDragStart,
+ onCustomFieldDragEnter: customFieldDrag.onDragEnter,
+ onCustomFieldDrop: customFieldDrag.onDrop,
+ onCustomFieldDragEnd: customFieldDrag.onDragEnd,
+ customFieldReorderClass: customFieldDrag.reorderClass,
+ // Drag reorder — pieces
+ onPieceDragStart: pieceDrag.onDragStart,
+ onPieceDragEnter: pieceDrag.onDragEnter,
+ onPieceDragOver: pieceDrag.onDragOver,
+ onPieceDrop: pieceDrag.onDrop,
+ onPieceDragEnd: pieceDrag.onDragEnd,
+ pieceReorderClass: pieceDrag.reorderClass,
+ // Drag reorder — products
+ onProductDragStart: productDrag.onDragStart,
+ onProductDragEnter: productDrag.onDragEnter,
+ onProductDragOver: productDrag.onDragOver,
+ onProductDrop: productDrag.onDrop,
+ onProductDragEnd: productDrag.onDragEnd,
+ productReorderClass: productDrag.reorderClass,
+ // Drag reorder — subcomponents
+ onSubcomponentDragStart: subcomponentDrag.onDragStart,
+ onSubcomponentDragEnter: subcomponentDrag.onDragEnter,
+ onSubcomponentDragOver: subcomponentDrag.onDragOver,
+ onSubcomponentDrop: subcomponentDrag.onDrop,
+ onSubcomponentDragEnd: subcomponentDrag.onDragEnd,
+ subcomponentReorderClass: subcomponentDrag.reorderClass,
+ }
+}
diff --git a/app/composables/useStructureNodeLogic.ts b/app/composables/useStructureNodeLogic.ts
new file mode 100644
index 0000000..225c416
--- /dev/null
+++ b/app/composables/useStructureNodeLogic.ts
@@ -0,0 +1,462 @@
+import { computed, watch } from 'vue'
+import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
+import { useStructureNodeCrud } from '~/composables/useStructureNodeCrud'
+
+export type ModelTypeOption = {
+ id: string
+ name: string
+ code?: string | null
+}
+
+export type EditableStructureNode = ComponentModelStructureNode & {
+ customFields?: any[]
+ pieces?: ComponentModelPiece[]
+ products?: ComponentModelProduct[]
+}
+
+export interface StructureNodeLogicDeps {
+ node: EditableStructureNode
+ depth: number
+ componentTypes: ModelTypeOption[]
+ pieceTypes: ModelTypeOption[]
+ productTypes: ModelTypeOption[]
+ isRoot: boolean
+ lockType: boolean
+ lockedTypeLabel: string
+ allowSubcomponents: boolean
+ maxSubcomponentDepth: number
+ restrictedMode: boolean
+ isLocked: boolean
+}
+
+export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
+ // --- Computed props ---
+ 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'))
+
+ // --- Type maps ---
+ const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
+ type?.name ?? ''
+
+ const componentTypeMap = computed(() => {
+ const map = new Map()
+ componentTypes.value.forEach((type) => {
+ if (type && typeof type.id === 'string') {
+ map.set(type.id, type)
+ }
+ })
+ return map
+ })
+
+ const componentTypeCodeMap = computed(() => {
+ const map = new Map()
+ 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()
+ pieceTypes.value.forEach((type) => {
+ if (type && typeof type.id === 'string') {
+ map.set(type.id, type)
+ }
+ })
+ return map
+ })
+
+ const productTypeMap = computed(() => {
+ const map = new Map()
+ productTypes.value.forEach((type) => {
+ if (type && typeof type.id === 'string') {
+ map.set(type.id, type)
+ }
+ })
+ return map
+ })
+
+ // --- Label getters ---
+ 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 formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
+ formatModelTypeOption(type)
+
+ const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
+ formatModelTypeOption(type)
+
+ const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
+ formatModelTypeOption(type)
+
+ const lockedTypeDisplay = computed(() => {
+ if (props.lockedTypeLabel) {
+ return props.lockedTypeLabel
+ }
+ return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
+ })
+
+ // --- Sync functions ---
+ const syncComponentType = (component: EditableStructureNode) => {
+ if (!component) {
+ return
+ }
+ if (props.isRoot) {
+ component.typeComposantId = ''
+ component.typeComposantLabel = ''
+ component.familyCode = ''
+ if (component.alias) {
+ component.alias = ''
+ }
+ 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) => {
+ 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) => {
+ 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)
+ })
+ }
+
+ // --- Handler functions ---
+ const handleComponentTypeSelect = (component: any) => {
+ syncComponentType(component)
+ }
+
+ const handlePieceTypeSelect = (piece: ComponentModelPiece & Record) => {
+ 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) => {
+ 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 ?? ''
+ }
+
+ // --- CRUD & Lock (delegated to useStructureNodeCrud) ---
+ const crud = useStructureNodeCrud({
+ node: props.node,
+ restrictedMode: props.restrictedMode,
+ canManageSubcomponents: () => canManageSubcomponents.value,
+ })
+
+ // --- Watchers ---
+ 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
+ })
+ crud.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 },
+ )
+
+ return {
+ // Lock checks
+ isCustomFieldLocked: crud.isCustomFieldLocked,
+ isPieceLocked: crud.isPieceLocked,
+ isProductLocked: crud.isProductLocked,
+ isSubcomponentLocked: crud.isSubcomponentLocked,
+ // Computed state
+ isLocked,
+ restrictedMode,
+ componentTypes,
+ pieceTypes,
+ productTypes,
+ canManageSubcomponents,
+ childAllowSubcomponents,
+ hasSubcomponents,
+ containerClass,
+ headingClass,
+ lockedTypeDisplay,
+ // Label getters & formatters
+ getComponentTypeLabel,
+ getPieceTypeLabel,
+ formatComponentTypeOption,
+ formatPieceTypeOption,
+ formatProductTypeOption,
+ // Handlers
+ handleComponentTypeSelect,
+ handlePieceTypeSelect,
+ handleProductTypeSelect,
+ // CRUD
+ addCustomField: crud.addCustomField,
+ removeCustomField: crud.removeCustomField,
+ addPiece: crud.addPiece,
+ removePiece: crud.removePiece,
+ addProduct: crud.addProduct,
+ removeProduct: crud.removeProduct,
+ addSubComponent: crud.addSubComponent,
+ removeSubComponent: crud.removeSubComponent,
+ // Drag reorder — custom fields
+ onCustomFieldDragStart: crud.onCustomFieldDragStart,
+ onCustomFieldDragEnter: crud.onCustomFieldDragEnter,
+ onCustomFieldDrop: crud.onCustomFieldDrop,
+ onCustomFieldDragEnd: crud.onCustomFieldDragEnd,
+ customFieldReorderClass: crud.customFieldReorderClass,
+ // Drag reorder — pieces
+ onPieceDragStart: crud.onPieceDragStart,
+ onPieceDragEnter: crud.onPieceDragEnter,
+ onPieceDragOver: crud.onPieceDragOver,
+ onPieceDrop: crud.onPieceDrop,
+ onPieceDragEnd: crud.onPieceDragEnd,
+ pieceReorderClass: crud.pieceReorderClass,
+ // Drag reorder — products
+ onProductDragStart: crud.onProductDragStart,
+ onProductDragEnter: crud.onProductDragEnter,
+ onProductDragOver: crud.onProductDragOver,
+ onProductDrop: crud.onProductDrop,
+ onProductDragEnd: crud.onProductDragEnd,
+ productReorderClass: crud.productReorderClass,
+ // Drag reorder — subcomponents
+ onSubcomponentDragStart: crud.onSubcomponentDragStart,
+ onSubcomponentDragEnter: crud.onSubcomponentDragEnter,
+ onSubcomponentDragOver: crud.onSubcomponentDragOver,
+ onSubcomponentDrop: crud.onSubcomponentDrop,
+ onSubcomponentDragEnd: crud.onSubcomponentDragEnd,
+ subcomponentReorderClass: crud.subcomponentReorderClass,
+ }
+}