/** * Custom field normalization, merging and display utilities. * * Extracted from pages/machine/[id].vue to be reusable across * machine detail, component, piece and product views. */ // --------------------------------------------------------------------------- // Primitive helpers // --------------------------------------------------------------------------- export const coerceValueForType = (type: string, rawValue: unknown): string => { if (rawValue === undefined || rawValue === null || rawValue === '') { return '' } if (type === 'boolean') { const normalized = String(rawValue).toLowerCase() if (normalized === 'true' || normalized === '1') return 'true' if (normalized === 'false' || normalized === '0') return 'false' return '' } return String(rawValue) } export const formatCustomFieldValue = (field: Record | null | undefined): string => { if (!field) return 'Non défini' const value = (field.value ?? field.defaultValue ?? '') as string if (value === '' || value === null || value === undefined) return 'Non défini' if (field.type === 'boolean') { const normalized = String(value).toLowerCase() if (normalized === 'true' || normalized === '1') return 'Oui' if (normalized === 'false' || normalized === '0') return 'Non' } return String(value) } export const shouldDisplayCustomField = (field: Record | null | undefined): boolean => { if (!field) return false if (field.readOnly) return true if (field.type === 'boolean') return field.value !== undefined && field.value !== null const value = field.value if (value === null || value === undefined) return false if (typeof value === 'string') return value.trim().length > 0 return true } // --------------------------------------------------------------------------- // Definition extraction helpers // --------------------------------------------------------------------------- export const extractDefinitionName = (definition: Record = {}): string => { if (typeof definition?.name === 'string' && (definition.name as string).trim()) { return (definition.name as string).trim() } if (typeof definition?.key === 'string' && (definition.key as string).trim()) { return (definition.key as string).trim() } if (typeof definition?.label === 'string' && (definition.label as string).trim()) { return (definition.label as string).trim() } return '' } export const extractDefinitionType = ( definition: Record = {}, fallback = 'text', ): string => { const allowed = ['text', 'number', 'select', 'boolean', 'date'] const rawType = typeof definition?.type === 'string' ? definition.type : typeof (definition?.value as Record)?.type === 'string' ? (definition.value as Record).type as string : typeof fallback === 'string' ? fallback : 'text' const normalized = (rawType as string).toLowerCase() return allowed.includes(normalized) ? normalized : 'text' } export const extractDefinitionRequired = ( definition: Record = {}, fallback = false, ): boolean => { if (typeof definition?.required === 'boolean') return definition.required const nested = (definition?.value as Record)?.required if (typeof nested === 'boolean') return nested if (typeof nested === 'string') { const normalized = nested.toLowerCase() if (normalized === 'true' || normalized === '1') return true if (normalized === 'false' || normalized === '0') return false } return !!fallback } const extractOptionList = (input: unknown): string[] | undefined => { if (!Array.isArray(input)) return undefined const mapped = input .map((option) => { if (option === null || option === undefined) return '' if (typeof option === 'string') return option.trim() if (typeof option === 'object') { const record = (option || {}) as Record for (const key of ['value', 'label', 'name']) { const candidate = record[key] if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim() } } const fallback = String(option).trim() return fallback === '[object Object]' ? '' : fallback }) .filter((option) => option.length > 0) return mapped.length ? mapped : undefined } export const extractDefinitionOptions = (definition: Record = {}): string[] => { const sources = [ definition?.options, (definition?.value as Record)?.options, (definition?.value as Record)?.choices, ] for (const source of sources) { const list = extractOptionList(source) if (list) return list } return [] } export const extractDefinitionDefaultValue = (definition: Record = {}): unknown => { const candidates = [ definition?.defaultValue, (definition?.value as Record)?.defaultValue, (definition?.value as Record)?.value, definition?.value, definition?.default, ] for (const candidate of candidates) { if (candidate === undefined || candidate === null || candidate === '') continue if (typeof candidate === 'object') { if (candidate === null) continue const nestedDefault = (candidate as Record).defaultValue !== undefined && (candidate as Record).defaultValue !== null ? (candidate as Record).defaultValue : (candidate as Record).value if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') { return nestedDefault } continue } return candidate } return undefined } // --------------------------------------------------------------------------- // Normalization // --------------------------------------------------------------------------- export interface NormalizedCustomFieldDefinition { id?: string customFieldId?: string name: string type: string required: boolean options: string[] defaultValue?: unknown readOnly: boolean orderIndex: number } export const normalizeCustomFieldDefinitionEntry = ( definition: Record = {}, fallbackIndex = 0, ): NormalizedCustomFieldDefinition | null => { const name = extractDefinitionName(definition) if (!name) return null const type = extractDefinitionType(definition) const required = extractDefinitionRequired(definition) const options = extractDefinitionOptions(definition) const defaultValue = extractDefinitionDefaultValue(definition) const id = typeof definition?.id === 'string' ? definition.id : undefined const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex } } export const normalizeExistingCustomFieldDefinitions = ( fields: unknown, ): NormalizedCustomFieldDefinition[] => { if (!Array.isArray(fields)) return [] return fields .map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record, index)) .filter((d): d is NormalizedCustomFieldDefinition => d !== null) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) } // --------------------------------------------------------------------------- // Custom field value normalization // --------------------------------------------------------------------------- export const normalizeCustomFieldValueEntry = (entry: Record = {}): Record | null => { if (!entry || typeof entry !== 'object') return null const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry) if (!normalizedDefinition) return null const value = coerceValueForType( normalizedDefinition.type, (entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string, ) return { id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null, customFieldId: (entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null, customField: { id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null, name: normalizedDefinition.name, type: normalizedDefinition.type, required: normalizedDefinition.required, options: normalizedDefinition.options, defaultValue: normalizedDefinition.defaultValue ?? '', }, value, } } // --------------------------------------------------------------------------- // Merge & dedup // --------------------------------------------------------------------------- export const mergeCustomFieldValuesWithDefinitions = ( valueEntries: Record[] = [], ...definitionSources: unknown[][] ): Record[] => { const normalizedValues = (Array.isArray(valueEntries) ? valueEntries : []) .map((entry) => { if (!entry || typeof entry !== 'object') return null const normalizedDefinition = normalizeCustomFieldDefinitionEntry( ((entry as Record).customField || entry) as Record, ) if (!normalizedDefinition) return null const value = coerceValueForType( normalizedDefinition.type, ((entry as Record)?.value ?? (entry as Record)?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string, ) return { customFieldValueId: (entry as Record)?.id ?? (entry as Record)?.customFieldValueId ?? null, id: normalizedDefinition.id, customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null, name: normalizedDefinition.name, type: normalizedDefinition.type, required: normalizedDefinition.required, options: normalizedDefinition.options, optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '', defaultValue: normalizedDefinition.defaultValue ?? '', value, readOnly: !!(entry as Record)?.readOnly, } }) .filter((entry): entry is Record => entry !== null) const result = [...normalizedValues] const keyFor = (item: Record) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}` const existingMap = new Map>() result.forEach((item) => { const key = keyFor(item) if (key) existingMap.set(key, item) const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null if (fallbackKey) existingMap.set(fallbackKey, item) }) const definitions = definitionSources .flatMap((source) => (Array.isArray(source) ? source : [])) .map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record)) .filter((d): d is NormalizedCustomFieldDefinition => d !== null) definitions.forEach((normalizedDefinition) => { const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}` if (!key) return if (normalizedDefinition.id) { const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}` if (existingMap.has(fallbackKey)) { const existingFallback = existingMap.get(fallbackKey) if (existingFallback) { existingFallback.id = existingFallback.id || normalizedDefinition.id existingFallback.customFieldId = normalizedDefinition.id existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly existingMap.delete(fallbackKey) existingMap.set(normalizedDefinition.id, existingFallback) existingMap.set(fallbackKey, existingFallback) return } } } const existing = existingMap.get(key) || (normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null) if (existing) { existing.name = existing.name || normalizedDefinition.name existing.type = existing.type || normalizedDefinition.type existing.required = (existing.required as boolean) || normalizedDefinition.required if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) { existing.options = normalizedDefinition.options } if (!existing.defaultValue && normalizedDefinition.defaultValue) { existing.defaultValue = String(normalizedDefinition.defaultValue) if (!existing.value) { existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue) } } existing.customFieldId = existing.customFieldId || normalizedDefinition.id existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly if (!existing.optionsText && normalizedDefinition.options?.length) { existing.optionsText = normalizedDefinition.options.join('\n') } if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing) if (normalizedDefinition.name) { existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing) } return } const entry: Record = { customFieldValueId: null, id: normalizedDefinition.id, customFieldId: normalizedDefinition.id, name: normalizedDefinition.name, type: normalizedDefinition.type, required: normalizedDefinition.required, options: normalizedDefinition.options, optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '', defaultValue: normalizedDefinition.defaultValue ?? '', value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string), readOnly: false, } result.push(entry) existingMap.set(key, entry) const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null if (fallbackKey) existingMap.set(fallbackKey, entry) }) return result } export const dedupeCustomFieldEntries = (fields: Record[]): Record[] => { if (!Array.isArray(fields) || fields.length <= 1) { return Array.isArray(fields) ? fields : [] } const seen = new Set() const result: Record[] = [] for (const field of fields) { if (!field) continue field.type = field.type || 'text' let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : '' if (!normalizedName && (field.customField as Record)?.name) { normalizedName = String((field.customField as Record).name).trim() field.name = normalizedName } else if (typeof field.name === 'string') { field.name = normalizedName } const key = (field.customFieldId as string) || (field.id as string) || (normalizedName ? `${normalizedName}::${field.type || 'text'}` : null) if (!key && !normalizedName) continue if (key && seen.has(key)) continue if (!normalizedName) continue if (key) seen.add(key) if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`) result.push(field) } return result } // --------------------------------------------------------------------------- // Summarize for display // --------------------------------------------------------------------------- export const summarizeCustomFields = ( fields: Record[] = [], ): { key: string; label: string; value: string }[] => { const seen = new Set() return fields .slice() .sort((a, b) => { const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0 const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0 return (left as number) - (right as number) }) .filter(shouldDisplayCustomField) .filter((field) => { const key = (field.customFieldId || field.id || field.name) as string if (!key) return true if (seen.has(key)) return false seen.add(key) return true }) .map((field, index) => ({ key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`, label: (field.name as string) || 'Champ', value: formatCustomFieldValue(field), })) }