import { reactive, ref } from 'vue' import { useApi } from '~/composables/useApi' import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions' import { useToast } from '~/composables/useToast' // --- Types --- export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date' export interface MachineCustomFieldEditorField { uid: string serverId?: string name: string type: MachineFieldType required: boolean optionsText: string orderIndex: number } interface InitialDef { id: string name: string type: string required: boolean options?: string[] orderIndex: number defaultValue?: unknown } interface Deps { machineId: string initialDefs: InitialDef[] onSaved: () => void | Promise } // --- Helpers --- let uidCounter = 0 const createUid = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } uidCounter += 1 return `mcf-${Date.now().toString(36)}-${uidCounter}` } const normalizeLineEndings = (value: string): string => value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({ uid: createUid(), serverId: def.id, name: def.name || '', type: (def.type || 'text') as MachineFieldType, required: Boolean(def.required), optionsText: normalizeLineEndings( Array.isArray(def.options) ? def.options.join('\n') : '', ), orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index, }) const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] => defs .map((def, index) => toEditorField(def, index)) .sort((a, b) => a.orderIndex - b.orderIndex) .map((field, index) => ({ ...field, orderIndex: index })) const buildSnapshot = (defs: InitialDef[]): Map => { const map = new Map() for (const def of defs) { map.set(def.id, def) } return map } const applyOrderIndex = ( list: MachineCustomFieldEditorField[], ): MachineCustomFieldEditorField[] => list.map((field, index) => ({ ...field, orderIndex: index })) const parseOptions = (optionsText: string): string[] => normalizeLineEndings(optionsText) .split('\n') .map(o => o.trim()) .filter(o => o.length > 0) // --- Composable --- export function useMachineCustomFieldDefs(deps: Deps) { const { apiCall } = useApi() const { showSuccess, showError } = useToast() const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions() // --- State --- const fields = ref(hydrateFields(deps.initialDefs)) const initialSnapshot = ref>(buildSnapshot(deps.initialDefs)) const saving = ref(false) // --- CRUD --- const addField = () => { const next = fields.value.slice() next.push({ uid: createUid(), name: '', type: 'text', required: false, optionsText: '', orderIndex: 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 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) => { const from = dragState.draggingIndex if (from === null) { resetDragState() return } if (from === index) { resetDragState() return } const list = fields.value.slice() if (from < 0 || index < 0 || from >= list.length || index >= list.length) { resetDragState() return } const [moved] = list.splice(from, 1) if (!moved) { resetDragState() return } list.splice(index, 0, moved) fields.value = applyOrderIndex(list) resetDragState() } const onDragEnd = () => { resetDragState() } const reorderClass = (index: number): string => { 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 '' } // --- Save --- const saveDefinitions = async () => { if (saving.value) return // Validate: remove empty-name fields before saving const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId) if (emptyNameFields.length > 0) { fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId)) } saving.value = true try { const snapshot = initialSnapshot.value const currentServerIds = new Set( fields.value.filter(f => f.serverId).map(f => f.serverId!), ) // DELETE removed fields const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id)) for (const id of deletedIds) { const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' }) if (!result.success) { showError('Erreur lors de la suppression d\'un champ personnalisé') await deps.onSaved() return } } let hasNewFields = false for (const field of fields.value) { const name = field.name.trim() if (!name) continue const options = field.type === 'select' ? parseOptions(field.optionsText) : [] if (!field.serverId) { // POST new field hasNewFields = true const body: Record = { name, type: field.type, required: field.required, options, orderIndex: field.orderIndex, machine: `/api/machines/${deps.machineId}`, } const result = await apiCall('/custom_fields', { method: 'POST', headers: { 'Content-Type': 'application/ld+json' }, body: JSON.stringify(body), }) if (!result.success) { showError('Erreur lors de la création d\'un champ personnalisé') await deps.onSaved() return } } else { // PATCH modified field const original = snapshot.get(field.serverId) const originalOptions = Array.isArray(original?.options) ? original.options.join('\n') : '' const currentOptions = field.type === 'select' ? field.optionsText : '' const changed = original?.name !== name || original?.type !== field.type || original?.required !== field.required || normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions) || original?.orderIndex !== field.orderIndex if (changed) { const body: Record = { name, type: field.type, required: field.required, options, orderIndex: field.orderIndex, } const result = await apiCall(`/custom_fields/${field.serverId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/merge-patch+json' }, body: JSON.stringify(body), }) if (!result.success) { showError('Erreur lors de la mise à jour d\'un champ personnalisé') await deps.onSaved() return } } } } // Initialize missing custom field values if new fields were created if (hasNewFields) { await apiCall(`/machines/${deps.machineId}/add-custom-fields`, { method: 'POST', headers: { 'Content-Type': 'application/ld+json' }, body: JSON.stringify({}), }) } showSuccess('Champs personnalisés sauvegardés avec succès') invalidateCustomFieldNames() await deps.onSaved() } catch { showError('Erreur inattendue lors de la sauvegarde des champs personnalisés') await deps.onSaved() } finally { saving.value = false } } // --- Reinit --- const reinit = (newDefs: InitialDef[]) => { fields.value = hydrateFields(newDefs) initialSnapshot.value = buildSnapshot(newDefs) } return { fields, saving, dragState, addField, removeField, onDragStart, onDragEnter, onDrop, onDragEnd, reorderClass, saveDefinitions, reinit, } }