Files
Inventory_frontend/app/composables/useMachineCustomFieldDefs.ts
matthieu 98f5d983b3 feat(machine) : add custom field definition editor on machine detail page
Adds UI to create, edit, reorder and delete custom field definitions
directly from the machine detail page in edit mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:34:35 +01:00

328 lines
8.8 KiB
TypeScript

import { reactive, ref } from 'vue'
import { useApi } from '~/composables/useApi'
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<void>
}
// --- 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<string, InitialDef> => {
const map = new Map<string, InitialDef>()
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()
// --- State ---
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
const initialSnapshot = ref<Map<string, InitialDef>>(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<string, unknown> = {
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<string, unknown> = {
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')
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,
}
}