Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
8.8 KiB
TypeScript
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,
|
|
}
|
|
}
|