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