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