refacto(F1.1): extract utility modules from machine/[id].vue
Extract ~1300 LOC of reusable logic into dedicated modules: - shared/utils/customFieldUtils.ts: field normalization, merge, dedup, display - shared/utils/productDisplayUtils.ts: product resolution and display helpers - composables/useMachineHierarchy.ts: hierarchy tree builder from links - composables/useMachinePrint.ts: print selection and execution logic These extractions prepare the ground for wiring [id].vue to import from these modules instead of inlining all logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
426
app/shared/utils/customFieldUtils.ts
Normal file
426
app/shared/utils/customFieldUtils.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 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<string, unknown> | 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<string, unknown> | 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, unknown> = {}): 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<string, unknown> = {},
|
||||
fallback = 'text',
|
||||
): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof definition?.type === 'string'
|
||||
? definition.type
|
||||
: typeof (definition?.value as Record<string, unknown>)?.type === 'string'
|
||||
? (definition.value as Record<string, unknown>).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<string, unknown> = {},
|
||||
fallback = false,
|
||||
): boolean => {
|
||||
if (typeof definition?.required === 'boolean') return definition.required
|
||||
const nested = (definition?.value as Record<string, unknown>)?.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<string, unknown>
|
||||
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, unknown> = {}): string[] => {
|
||||
const sources = [
|
||||
definition?.options,
|
||||
(definition?.value as Record<string, unknown>)?.options,
|
||||
(definition?.value as Record<string, unknown>)?.choices,
|
||||
]
|
||||
for (const source of sources) {
|
||||
const list = extractOptionList(source)
|
||||
if (list) return list
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const extractDefinitionDefaultValue = (definition: Record<string, unknown> = {}): unknown => {
|
||||
const candidates = [
|
||||
definition?.defaultValue,
|
||||
(definition?.value as Record<string, unknown>)?.defaultValue,
|
||||
(definition?.value as Record<string, unknown>)?.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<string, unknown>).defaultValue !== undefined &&
|
||||
(candidate as Record<string, unknown>).defaultValue !== null
|
||||
? (candidate as Record<string, unknown>).defaultValue
|
||||
: (candidate as Record<string, unknown>).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<string, unknown> = {},
|
||||
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<string, unknown>, 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<string, unknown> = {}): Record<string, unknown> | 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<string, unknown>[] = [],
|
||||
...definitionSources: unknown[][]
|
||||
): Record<string, unknown>[] => {
|
||||
const normalizedValues = (Array.isArray(valueEntries) ? valueEntries : [])
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(
|
||||
((entry as Record<string, unknown>).customField || entry) as Record<string, unknown>,
|
||||
)
|
||||
if (!normalizedDefinition) return null
|
||||
|
||||
const value = coerceValueForType(
|
||||
normalizedDefinition.type,
|
||||
((entry as Record<string, unknown>)?.value ??
|
||||
(entry as Record<string, unknown>)?.defaultValue ??
|
||||
normalizedDefinition.defaultValue ??
|
||||
'') as string,
|
||||
)
|
||||
|
||||
return {
|
||||
customFieldValueId: (entry as Record<string, unknown>)?.id ?? (entry as Record<string, unknown>)?.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<string, unknown>)?.readOnly,
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is Record<string, unknown> => entry !== null)
|
||||
|
||||
const result = [...normalizedValues]
|
||||
const keyFor = (item: Record<string, unknown>) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}`
|
||||
const existingMap = new Map<string, Record<string, unknown>>()
|
||||
|
||||
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<string, unknown>))
|
||||
.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<string, unknown> = {
|
||||
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<string, unknown>[]): Record<string, unknown>[] => {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: Record<string, unknown>[] = []
|
||||
|
||||
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<string, unknown>)?.name) {
|
||||
normalizedName = String((field.customField as Record<string, unknown>).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<string, unknown>[] = [],
|
||||
): { key: string; label: string; value: string }[] => {
|
||||
const seen = new Set<string>()
|
||||
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),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user