import { computed, onMounted, reactive, ref, watch } from 'vue' import type { PieceModelCustomField, PieceModelCustomFieldType, PieceModelProduct, PieceModelStructure, PieceModelStructureEditorField, } from '~/shared/types/inventory' import { normalizePieceStructureForSave } from '~/shared/modelUtils' import { useProductTypes } from '~/composables/useProductTypes' export type EditorField = PieceModelStructureEditorField & { uid: string } export type EditorProduct = { uid: string typeProductId: string typeProductLabel: string familyCode: string } interface Deps { props: { modelValue?: PieceModelStructure | null } emit: (event: 'update:modelValue', value: PieceModelStructure) => void } // --- Pure helpers --- const ensureArray = (value: T[] | null | undefined): T[] => Array.isArray(value) ? value : [] const normalizeLineEndings = (value: string): string => value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') const safeClone = (value: T, fallback: T): T => { try { return JSON.parse(JSON.stringify(value ?? fallback)) as T } catch { return JSON.parse(JSON.stringify(fallback)) as T } } const extractRest = (structure?: PieceModelStructure | null): Record => { if (!structure || typeof structure !== 'object') { return {} } const entries = Object.entries(structure).filter( ([key]) => key !== 'customFields' && key !== 'products', ) return safeClone(Object.fromEntries(entries), {}) } let uidCounter = 0 const createUid = (scope: 'field' | 'product'): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } uidCounter += 1 return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}` } // --- Hydration --- const toEditorField = ( input: Partial | null | undefined, index: number, ): EditorField => { const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text' const optionsText = normalizeLineEndings( typeof input?.optionsText === 'string' ? input.optionsText : Array.isArray(input?.options) ? input.options.join('\n') : '', ) return { uid: createUid('field'), name: typeof input?.name === 'string' ? input.name : '', type: baseType as PieceModelCustomFieldType, required: Boolean(input?.required), optionsText, orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index, } } const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => { const source = ensureArray(structure?.customFields) return source .map((field, index) => toEditorField(field, index)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .map((field, index) => ({ ...field, orderIndex: index })) } const toEditorProduct = ( input: Partial | null | undefined, ): EditorProduct => ({ uid: createUid('product'), typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '', typeProductLabel: typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '', familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '', }) const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => { const source = Array.isArray(structure?.products) ? structure?.products : [] return source.map(product => toEditorProduct(product)) } // --- Payload --- const applyOrderIndex = (list: EditorField[]): EditorField[] => list.map((field, index) => ({ ...field, orderIndex: index, })) const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => { const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : '' const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : '' if (!typeProductId && !familyCode) { return null } const payload: PieceModelProduct = {} if (typeProductId) { payload.typeProductId = typeProductId } if (familyCode) { payload.familyCode = familyCode } if (product.typeProductLabel) { payload.typeProductLabel = product.typeProductLabel } return payload } const buildPayload = ( fieldsSource: EditorField[], productsSource: EditorProduct[], restSource: Record, ): PieceModelStructure => { const normalizedFields = fieldsSource .map((field, index) => { const name = field.name.trim() if (!name) { return null } const type = (field.type || 'text') as PieceModelCustomFieldType const required = Boolean(field.required) const payload: PieceModelCustomField = { name, type, required, orderIndex: index, } if (type === 'select') { const options = normalizeLineEndings(field.optionsText) .split('\n') .map(option => option.trim()) .filter(option => option.length > 0) if (options.length > 0) { payload.options = options } } return payload }) .filter((field): field is PieceModelCustomField => Boolean(field)) const normalizedProducts = productsSource .map(product => normalizeProductEntry(product)) .filter((product): product is PieceModelProduct => Boolean(product)) const draft: PieceModelStructure = { ...safeClone(restSource, {}), products: normalizedProducts, customFields: normalizedFields, } return normalizePieceStructureForSave(draft) } const serializeStructure = (structure?: PieceModelStructure | null): string => { return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] })) } // --- Composable --- export function usePieceStructureEditorLogic(deps: Deps) { const { props, emit } = deps const { productTypes, loadProductTypes } = useProductTypes() // --- State --- const fields = ref(hydrateFields(props.modelValue)) const products = ref(hydrateProducts(props.modelValue)) const restState = ref>(extractRest(props.modelValue)) // --- Product types --- const productTypeOptions = computed(() => productTypes.value ?? []) const productTypeMap = computed(() => { const map = new Map() productTypeOptions.value.forEach((type: any) => { if (type?.id) { map.set(type.id, type) } }) return map }) const formatProductTypeOption = (type: any) => { if (!type) { return '' } const parts: string[] = [] if (type.code) { parts.push(type.code) } if (type.name) { parts.push(type.name) } return parts.length ? parts.join(' • ') : type.id || '' } const updateProductTypeMetadata = (product: EditorProduct) => { const option = product.typeProductId ? productTypeMap.value.get(product.typeProductId) : null product.typeProductLabel = option?.name ?? '' } const handleProductTypeSelect = (product: EditorProduct) => { const option = product.typeProductId ? productTypeMap.value.get(product.typeProductId) : null product.typeProductLabel = option?.name ?? '' if (option?.code) { product.familyCode = option.code } } // --- CRUD --- const createEmptyProduct = (): EditorProduct => ({ uid: createUid('product'), typeProductId: '', typeProductLabel: '', familyCode: '', }) const addProduct = () => { products.value.push(createEmptyProduct()) } const removeProduct = (index: number) => { products.value = products.value.filter((_, idx) => idx !== index) } const createEmptyField = (orderIndex: number): EditorField => ({ uid: createUid('field'), name: '', type: 'text', required: false, optionsText: '', orderIndex, }) const addField = () => { const next = fields.value.slice() next.push(createEmptyField(next.length)) fields.value = applyOrderIndex(next) } const removeField = (index: number) => { const next = fields.value.filter((_, i) => i !== index) fields.value = applyOrderIndex(next) } // --- Drag & drop --- const dragState = reactive({ draggingIndex: null as number | null, dropTargetIndex: null as number | null, }) const resetDragState = () => { dragState.draggingIndex = null dragState.dropTargetIndex = null } const reorderFields = (from: number, to: number) => { if (from === to) { resetDragState() return } const list = fields.value.slice() if (from < 0 || to < 0 || from >= list.length || to >= list.length) { resetDragState() return } const [moved] = list.splice(from, 1) if (!moved) { resetDragState() return } list.splice(to, 0, moved) fields.value = applyOrderIndex(list) resetDragState() } const onDragStart = (index: number, event: DragEvent) => { dragState.draggingIndex = index dragState.dropTargetIndex = index if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' } } const onDragEnter = (index: number) => { if (dragState.draggingIndex === null) { return } dragState.dropTargetIndex = index } const onDrop = (index: number) => { if (dragState.draggingIndex === null) { resetDragState() return } reorderFields(dragState.draggingIndex, index) } const onDragEnd = () => { resetDragState() } const reorderClass = (index: number) => { if (dragState.draggingIndex === index) { return 'border-dashed border-primary bg-primary/5' } if ( dragState.draggingIndex !== null && dragState.dropTargetIndex === index && dragState.draggingIndex !== index ) { return 'border-primary border-dashed bg-primary/10' } return '' } // --- Emit --- let lastEmitted = serializeStructure(props.modelValue) const emitUpdate = () => { const payload = buildPayload(fields.value, products.value, restState.value) const serialized = JSON.stringify(payload) if (serialized !== lastEmitted) { lastEmitted = serialized emit('update:modelValue', payload) } } // --- Watchers --- watch(fields, emitUpdate, { deep: true }) watch(products, emitUpdate, { deep: true }) watch(productTypeOptions, () => { products.value.forEach(product => updateProductTypeMetadata(product)) }) watch( () => props.modelValue, (value) => { const incomingSerialized = serializeStructure(value) if (incomingSerialized === lastEmitted) { return } restState.value = extractRest(value) fields.value = hydrateFields(value) products.value = hydrateProducts(value) products.value.forEach(product => updateProductTypeMetadata(product)) lastEmitted = incomingSerialized }, { deep: true }, ) // --- Lifecycle --- onMounted(async () => { if (!productTypeOptions.value.length) { await loadProductTypes() } products.value.forEach(product => updateProductTypeMetadata(product)) }) return { fields, products, productTypeOptions, formatProductTypeOption, handleProductTypeSelect, addProduct, removeProduct, addField, removeField, reorderClass, onDragStart, onDragEnter, onDrop, onDragEnd, } }