From 3b24dc128ab2a483e203fedb723109f9ca4d289b Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 8 Mar 2026 17:09:02 +0100 Subject: [PATCH] refactor(frontend) : extract PieceModelStructureEditor logic into composable Co-Authored-By: Claude Opus 4.6 --- app/components/PieceModelStructureEditor.vue | 408 +--------------- .../usePieceStructureEditorLogic.ts | 444 ++++++++++++++++++ 2 files changed, 465 insertions(+), 387 deletions(-) create mode 100644 app/composables/usePieceStructureEditorLogic.ts diff --git a/app/components/PieceModelStructureEditor.vue b/app/components/PieceModelStructureEditor.vue index fcbc484..0ddeee3 100644 --- a/app/components/PieceModelStructureEditor.vue +++ b/app/components/PieceModelStructureEditor.vue @@ -173,30 +173,14 @@ diff --git a/app/composables/usePieceStructureEditorLogic.ts b/app/composables/usePieceStructureEditorLogic.ts new file mode 100644 index 0000000..b8d6929 --- /dev/null +++ b/app/composables/usePieceStructureEditorLogic.ts @@ -0,0 +1,444 @@ +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 + restrictedMode?: boolean + } + 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)) + + const initialFieldUids = ref>(new Set(fields.value.map(f => f.uid))) + const initialProductUids = ref>(new Set(products.value.map(p => p.uid))) + + // --- 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 + } + } + + // --- Locked state --- + + const isFieldLocked = (field: EditorField): boolean => { + return props.restrictedMode === true && initialFieldUids.value.has(field.uid) + } + + const isProductLocked = (product: EditorProduct): boolean => { + return props.restrictedMode === true && initialProductUids.value.has(product.uid) + } + + const restrictedMode = computed(() => props.restrictedMode === true) + + // --- 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 + initialFieldUids.value = new Set(fields.value.map(f => f.uid)) + initialProductUids.value = new Set(products.value.map(p => p.uid)) + }, + { deep: true }, + ) + + // --- Lifecycle --- + + onMounted(async () => { + if (!productTypeOptions.value.length) { + await loadProductTypes() + } + products.value.forEach(product => updateProductTypeMetadata(product)) + }) + + return { + fields, + products, + productTypeOptions, + restrictedMode, + isFieldLocked, + isProductLocked, + formatProductTypeOption, + handleProductTypeSelect, + addProduct, + removeProduct, + addField, + removeField, + reorderClass, + onDragStart, + onDragEnter, + onDrop, + onDragEnd, + } +}