441 lines
12 KiB
TypeScript
441 lines
12 KiB
TypeScript
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 = <T,>(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 = <T,>(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<string, unknown> => {
|
|
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<PieceModelStructureEditorField> | 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,
|
|
defaultValue:
|
|
input?.defaultValue !== undefined && input.defaultValue !== null && input.defaultValue !== ''
|
|
? String(input.defaultValue)
|
|
: null,
|
|
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
|
|
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
|
|
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<PieceModelProduct> | 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<string, unknown>,
|
|
): PieceModelStructure => {
|
|
const normalizedFields = fieldsSource
|
|
.map<PieceModelCustomField | null>((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 (field.id) {
|
|
payload.id = field.id
|
|
}
|
|
if (field.customFieldId) {
|
|
payload.customFieldId = field.customFieldId
|
|
}
|
|
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
|
|
payload.defaultValue = String(field.defaultValue)
|
|
}
|
|
|
|
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<EditorField[]>(hydrateFields(props.modelValue))
|
|
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
|
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
|
|
|
|
|
// --- Product types ---
|
|
|
|
const productTypeOptions = computed(() => productTypes.value ?? [])
|
|
|
|
const productTypeMap = computed(() => {
|
|
const map = new Map<string, any>()
|
|
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,
|
|
}
|
|
}
|