refactor : merge Inventory_frontend submodule into frontend/ directory
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>
This commit is contained in:
16
frontend/app/shared/utils/apiHelpers.ts
Normal file
16
frontend/app/shared/utils/apiHelpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared API response helpers.
|
||||
*
|
||||
* Extracted from 10+ composables/components that each had an identical local
|
||||
* copy of extractCollection (parsing hydra:member / member / data / array).
|
||||
*/
|
||||
|
||||
export function extractCollection<T = any>(payload: unknown): T[] {
|
||||
if (Array.isArray(payload)) return payload as T[]
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (Array.isArray(p?.member)) return p!.member as T[]
|
||||
if (Array.isArray(p?.['hydra:member'])) return p!['hydra:member'] as T[]
|
||||
if (Array.isArray(p?.items)) return p!.items as T[]
|
||||
if (Array.isArray(p?.data)) return p!.data as T[]
|
||||
return []
|
||||
}
|
||||
220
frontend/app/shared/utils/assignmentUtils.ts
Normal file
220
frontend/app/shared/utils/assignmentUtils.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Entity assignment normalization and display utilities.
|
||||
*
|
||||
* Extracted from pages/machines/new.vue – these pure functions resolve
|
||||
* machine / component / piece assignments from nested API payloads.
|
||||
*/
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (value: unknown): value is AnyRecord =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const toTrimmedString = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const dedupeAssignments = (
|
||||
assignments: AnyRecord[],
|
||||
): AnyRecord[] => {
|
||||
const seen = new Set<string>()
|
||||
return assignments.filter((assignment) => {
|
||||
if (!assignment) return false
|
||||
const id = assignment.id != null ? String(assignment.id) : ''
|
||||
const name = assignment.name != null ? String(assignment.name) : ''
|
||||
const key = `${id}::${name}`
|
||||
if (!id && !name) return false
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeMachineAssignment = (input: unknown): AnyRecord | null => {
|
||||
if (!input) return null
|
||||
if (typeof input === 'string') {
|
||||
const name = toTrimmedString(input)
|
||||
return name ? { id: name, name } : null
|
||||
}
|
||||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||
const value = String(input)
|
||||
return { id: value, name: value }
|
||||
}
|
||||
|
||||
const container = (input as AnyRecord).machine || (input as AnyRecord).machineData || input
|
||||
if (!isPlainObject(container)) return null
|
||||
|
||||
const id =
|
||||
container.id ?? (input as AnyRecord).machineId ?? (input as AnyRecord).id ?? null
|
||||
const name =
|
||||
container.name ||
|
||||
(input as AnyRecord).machineName ||
|
||||
container.label ||
|
||||
container.title ||
|
||||
(typeof id === 'string' ? id : null) ||
|
||||
(typeof id === 'number' ? String(id) : null)
|
||||
|
||||
if (id == null && name == null) return null
|
||||
|
||||
return {
|
||||
id: id != null ? id : null,
|
||||
name: name != null ? name : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const collectMachineAssignments = (source: unknown): AnyRecord[] => {
|
||||
if (!isPlainObject(source)) return []
|
||||
|
||||
const candidates = [
|
||||
source.machines,
|
||||
source.machineLinks,
|
||||
source.machineAssignments,
|
||||
source.machinesAssignments,
|
||||
source.linkedMachines,
|
||||
]
|
||||
|
||||
const assignments: AnyRecord[] = []
|
||||
|
||||
candidates.forEach((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach((item) => {
|
||||
const normalized = normalizeMachineAssignment(item)
|
||||
if (normalized) assignments.push(normalized)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!assignments.length) {
|
||||
const direct = normalizeMachineAssignment(source.machine)
|
||||
if (direct) assignments.push(direct)
|
||||
}
|
||||
|
||||
if (!assignments.length) {
|
||||
const idCandidate = source.machineId ?? source.machineID ?? null
|
||||
const nameCandidate = source.machineName ?? null
|
||||
const normalized = normalizeMachineAssignment(nameCandidate || idCandidate)
|
||||
if (normalized) assignments.push(normalized)
|
||||
}
|
||||
|
||||
return dedupeAssignments(assignments)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeComponentAssignment = (input: unknown): AnyRecord | null => {
|
||||
if (!input) return null
|
||||
if (typeof input === 'string') {
|
||||
const value = toTrimmedString(input)
|
||||
return value ? { id: value, name: value } : null
|
||||
}
|
||||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||
const value = String(input)
|
||||
return { id: value, name: value }
|
||||
}
|
||||
|
||||
const container =
|
||||
(input as AnyRecord).component || (input as AnyRecord).composant || input
|
||||
if (!isPlainObject(container)) return null
|
||||
|
||||
const id =
|
||||
container.id ??
|
||||
(input as AnyRecord).componentId ??
|
||||
(input as AnyRecord).composantId ??
|
||||
(input as AnyRecord).id ??
|
||||
null
|
||||
const name =
|
||||
container.name ||
|
||||
(input as AnyRecord).componentName ||
|
||||
(input as AnyRecord).composantName ||
|
||||
container.label ||
|
||||
(typeof id === 'string' ? id : null) ||
|
||||
(typeof id === 'number' ? String(id) : null)
|
||||
|
||||
if (id == null && name == null) return null
|
||||
|
||||
return {
|
||||
id: id != null ? id : null,
|
||||
name: name != null ? name : null,
|
||||
}
|
||||
}
|
||||
|
||||
export const collectComponentAssignments = (source: unknown): AnyRecord[] => {
|
||||
if (!isPlainObject(source)) return []
|
||||
|
||||
const candidates = [
|
||||
source.components,
|
||||
source.composants,
|
||||
source.componentLinks,
|
||||
source.linkedComponents,
|
||||
]
|
||||
|
||||
const assignments: AnyRecord[] = []
|
||||
|
||||
candidates.forEach((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
list.forEach((item) => {
|
||||
const normalized = normalizeComponentAssignment(item)
|
||||
if (normalized) assignments.push(normalized)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!assignments.length) {
|
||||
const direct = normalizeComponentAssignment(source.component || source.composant)
|
||||
if (direct) assignments.push(direct)
|
||||
}
|
||||
|
||||
if (!assignments.length) {
|
||||
const idCandidate = source.componentId ?? source.composantId ?? null
|
||||
const normalized = normalizeComponentAssignment(idCandidate)
|
||||
if (normalized) assignments.push(normalized)
|
||||
}
|
||||
|
||||
return dedupeAssignments(assignments)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getComponentMachineAssignments = (component: unknown): AnyRecord[] =>
|
||||
collectMachineAssignments(component || {})
|
||||
|
||||
export const getPieceMachineAssignments = (piece: unknown): AnyRecord[] =>
|
||||
collectMachineAssignments(piece || {})
|
||||
|
||||
export const getPieceComponentAssignments = (piece: unknown): AnyRecord[] =>
|
||||
collectComponentAssignments(piece || {})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const formatAssignmentList = (assignments: AnyRecord[]): string => {
|
||||
if (!Array.isArray(assignments) || assignments.length === 0) return ''
|
||||
return assignments
|
||||
.map((assignment) => (assignment?.name || assignment?.id) as string)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
87
frontend/app/shared/utils/catalogDisplayUtils.ts
Normal file
87
frontend/app/shared/utils/catalogDisplayUtils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
/**
|
||||
* Selects the best document for thumbnail preview from an entity's documents array.
|
||||
* Default priority: PDF first, then images. Use `preferImages` to reverse.
|
||||
*/
|
||||
export const resolvePrimaryDocument = (entity: Record<string, any>, preferImages = false): any | null => {
|
||||
const documents = Array.isArray(entity?.documents) ? entity.documents : []
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) return normalized[0] ?? null
|
||||
const first = preferImages ? isImageDocument : isPdfDocument
|
||||
const second = preferImages ? isPdfDocument : isImageDocument
|
||||
const a = withPath.find((doc: any) => first(doc))
|
||||
if (a) return a
|
||||
const b = withPath.find((doc: any) => second(doc))
|
||||
if (b) return b
|
||||
return withPath[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds alt text for a document preview thumbnail.
|
||||
*/
|
||||
export const resolvePreviewAlt = (entity: Record<string, any>): string => {
|
||||
const parts = [entity?.name, entity?.reference].filter(Boolean)
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplier name resolution: extracts unique supplier names from entity relations.
|
||||
*/
|
||||
export const resolveSupplierNames = (entity: Record<string, any>, nestedKey?: string): string[] => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(entity?.constructeurs)
|
||||
collectConstructeurs(entity?.constructeur)
|
||||
collectFromLabel(entity?.constructeursLabel)
|
||||
collectFromLabel(entity?.supplierLabel)
|
||||
collectFromLabel(entity?.suppliers)
|
||||
|
||||
if (nestedKey && entity?.[nestedKey]) {
|
||||
const nested = entity[nestedKey]
|
||||
collectConstructeurs(nested?.constructeurs)
|
||||
collectConstructeurs(nested?.constructeur)
|
||||
collectFromLabel(nested?.constructeursLabel)
|
||||
collectFromLabel(nested?.supplierLabel)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
|
||||
export const buildSuppliersDisplay = (suppliers: string[]) => {
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
404
frontend/app/shared/utils/customFieldFormUtils.ts
Normal file
404
frontend/app/shared/utils/customFieldFormUtils.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Custom field form normalization, merge, and persistence utilities.
|
||||
*
|
||||
* Extracted from pages/component/create.vue, component/[id]/edit.vue,
|
||||
* pieces/create.vue, pieces/[id]/edit.vue, product/[id]/edit.vue.
|
||||
*
|
||||
* Every create/edit page was shipping its own copy of these helpers –
|
||||
* this module unifies them behind a single, entity-agnostic API.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface SaveCustomFieldDeps {
|
||||
customFieldInputs: { value: CustomFieldInput[] }
|
||||
upsertCustomFieldValue: (
|
||||
definitionId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => Promise<{ success: boolean; data?: any }>
|
||||
updateCustomFieldValue: (
|
||||
id: string,
|
||||
payload: { value: string },
|
||||
) => Promise<{ success: boolean }>
|
||||
toast: { showError: (msg: string) => void }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const toFieldString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
export const fieldKey = (field: CustomFieldInput, index: number): string =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field resolution helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) return field.name.trim()
|
||||
if (typeof field?.key === 'string' && field.key.trim()) return field.key.trim()
|
||||
if (typeof field?.label === 'string' && field.label.trim()) return field.label.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof field?.type === 'string'
|
||||
? field.type
|
||||
: typeof field?.value?.type === 'string'
|
||||
? field.value.type
|
||||
: ''
|
||||
const value = rawType.toLowerCase()
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
export const resolveRequiredFlag = (field: any): boolean => {
|
||||
if (typeof field?.required === 'boolean') return field.required
|
||||
const nested = field?.value?.required
|
||||
if (typeof nested === 'boolean') return nested
|
||||
if (typeof nested === 'string') {
|
||||
const normalized = nested.toLowerCase()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const resolveOptions = (field: any): string[] => {
|
||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source)) {
|
||||
const mapped = source
|
||||
.map((option: unknown) => {
|
||||
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((o) => o.length > 0)
|
||||
if (mapped.length) return mapped
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') return null
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) return field.defaultValue
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') return field.value
|
||||
if (field.default !== undefined && field.default !== null) return field.default
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if (field.value.defaultValue !== undefined && field.value.defaultValue !== null) return field.value.defaultValue
|
||||
if (field.value.value !== undefined && field.value.value !== null && typeof field.value.value !== 'object') return field.value.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) return ''
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) return ''
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return 'true'
|
||||
if (normalized === 'false' || normalized === '0') return 'false'
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize a single raw custom-field definition into CustomFieldInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') return null
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) return null
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = resolveRequiredFlag(rawField)
|
||||
const options = resolveOptions(rawField)
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string' ? rawField.customFieldValueId : null
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalize ALL custom-field definitions from a structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeCustomFieldInputs = (structure: any): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field: any, index: number) => normalizeCustomField(field, index))
|
||||
.filter((field: CustomFieldInput | null): field is CustomFieldInput => field !== null)
|
||||
.sort((a: CustomFieldInput, b: CustomFieldInput) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract stored value from a persisted custom-field entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const extractStoredCustomFieldValue = (entry: any): any => {
|
||||
if (entry === null || entry === undefined) return ''
|
||||
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') return entry
|
||||
if (typeof entry !== 'object') return String(entry)
|
||||
|
||||
const direct = entry.value
|
||||
if (direct !== undefined && direct !== null) {
|
||||
if (typeof direct === 'object') {
|
||||
if (direct === null) return ''
|
||||
if ('value' in direct && direct.value !== undefined && direct.value !== null) return direct.value
|
||||
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) return direct.defaultValue
|
||||
return ''
|
||||
}
|
||||
return direct
|
||||
}
|
||||
if (entry.defaultValue !== undefined && entry.defaultValue !== null) return entry.defaultValue
|
||||
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) return entry.customFieldValue.value
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build inputs for edit pages (merge definitions + stored values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildCustomFieldInputs = (
|
||||
structure: any,
|
||||
values: any[] | null | undefined,
|
||||
): CustomFieldInput[] => {
|
||||
const definitions = normalizeCustomFieldInputs(structure)
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const mapById = new Map<string, any>()
|
||||
const mapByName = new Map<string, any>()
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
||||
if (fieldId) mapById.set(fieldId, entry)
|
||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
||||
if (fieldName) mapByName.set(fieldName, entry)
|
||||
})
|
||||
|
||||
const matchedIds = new Set<string>()
|
||||
const matchedNames = new Set<string>()
|
||||
|
||||
const result = definitions
|
||||
.map((definition) => {
|
||||
const definitionId = definition.customFieldId || definition.id || null
|
||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
||||
|
||||
if (!matched) {
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: definition.customFieldId || definition.id,
|
||||
customFieldValueId: null,
|
||||
orderIndex: definition.orderIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
|
||||
if (matchedFieldId) matchedIds.add(matchedFieldId)
|
||||
const matchedFieldName = matched.customField?.name || matched.name || null
|
||||
if (matchedFieldName) matchedNames.add(matchedFieldName)
|
||||
|
||||
const resolvedValue = extractStoredCustomFieldValue(matched)
|
||||
return {
|
||||
...definition,
|
||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
||||
customFieldValueId: matched.id ?? null,
|
||||
value: formatDefaultValue(definition.type, resolvedValue),
|
||||
orderIndex: Math.min(
|
||||
definition.orderIndex ?? 0,
|
||||
typeof matched.customField?.orderIndex === 'number'
|
||||
? matched.customField.orderIndex
|
||||
: definition.orderIndex ?? 0,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Include values with embedded definitions that didn't match any structure definition
|
||||
valueList.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const cf = entry.customField
|
||||
if (!cf || typeof cf !== 'object') return
|
||||
const fieldId = cf.id || entry.customFieldId || null
|
||||
const fieldName = cf.name || entry.name || null
|
||||
if (fieldId && matchedIds.has(fieldId)) return
|
||||
if (fieldName && matchedNames.has(fieldName)) return
|
||||
|
||||
const name = resolveFieldName(cf)
|
||||
if (!name) return
|
||||
|
||||
const type = resolveFieldType(cf)
|
||||
const resolvedValue = extractStoredCustomFieldValue(entry)
|
||||
result.push({
|
||||
id: fieldId,
|
||||
name,
|
||||
type,
|
||||
required: resolveRequiredFlag(cf),
|
||||
options: resolveOptions(cf),
|
||||
value: formatDefaultValue(type, resolvedValue),
|
||||
customFieldId: fieldId,
|
||||
customFieldValueId: entry.id ?? null,
|
||||
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
|
||||
})
|
||||
})
|
||||
|
||||
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildCustomFieldMetadata = (field: CustomFieldInput): Record<string, unknown> => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
export const shouldPersistField = (field: CustomFieldInput): boolean => {
|
||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
export const formatValueForPersistence = (field: CustomFieldInput): string => {
|
||||
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
|
||||
export const requiredCustomFieldsFilled = (inputs: CustomFieldInput[]): boolean =>
|
||||
inputs.every((field) => {
|
||||
if (!field.required) return true
|
||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save custom-field values for an entity.
|
||||
*
|
||||
* @param entityType - API entity slug ('composant' | 'piece' | 'product')
|
||||
* @param entityId - ID of the created/updated entity
|
||||
* @param definitionSources - arrays of raw definition objects to build a name→id map
|
||||
* @param deps - injected composable references
|
||||
* @returns list of field names that failed to save (empty = all OK)
|
||||
*/
|
||||
export const saveCustomFieldValues = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
definitionSources: any[][],
|
||||
deps: SaveCustomFieldDeps,
|
||||
): Promise<string[]> => {
|
||||
if (!entityId) return []
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) return
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) definitionMap.set(name, id)
|
||||
})
|
||||
}
|
||||
|
||||
definitionSources.forEach(registerDefinitions)
|
||||
|
||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) return field.customFieldId
|
||||
if (field.id) return field.id
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
const failed: string[] = []
|
||||
|
||||
for (const field of deps.customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) continue
|
||||
|
||||
const definitionId = resolveDefinitionId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await deps.updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
deps.toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
failed.push(field.name)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await deps.upsertCustomFieldValue(
|
||||
definitionId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
deps.toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) field.customFieldValueId = createdValue.id
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
|
||||
return failed
|
||||
}
|
||||
440
frontend/app/shared/utils/customFieldUtils.ts
Normal file
440
frontend/app/shared/utils/customFieldUtils.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* 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 interface NormalizedCustomFieldEntry {
|
||||
customFieldValueId: unknown
|
||||
id: string | undefined
|
||||
customFieldId: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
optionsText: string
|
||||
defaultValue: unknown
|
||||
value: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
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: Record<string, unknown>[] = (Array.isArray(valueEntries) ? valueEntries : [])
|
||||
.map((entry): Record<string, unknown> | null => {
|
||||
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,
|
||||
} as Record<string, unknown>
|
||||
})
|
||||
.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),
|
||||
}))
|
||||
}
|
||||
19
frontend/app/shared/utils/deleteImpactUtils.ts
Normal file
19
frontend/app/shared/utils/deleteImpactUtils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const resolveDeleteImpact = (entity: Record<string, any>): string[] => {
|
||||
const impacts: string[] = []
|
||||
const machineLinks = Array.isArray(entity?.machineLinks) ? entity.machineLinks.length : entity?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(entity?.documents) ? entity.documents.length : entity?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(entity?.customFieldValues) ? entity.customFieldValues.length : entity?.customFieldValuesCount ?? 0
|
||||
if (machineLinks > 0) impacts.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
if (documents > 0) impacts.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
if (customFields > 0) impacts.push(`${customFields} valeur${customFields > 1 ? 's' : ''} de champs personnalisés`)
|
||||
return impacts
|
||||
}
|
||||
|
||||
export const buildDeleteMessage = (entityName: string, impacts: string[]): string => {
|
||||
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
|
||||
if (impacts.length) {
|
||||
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
|
||||
}
|
||||
lines.push('Cette action est irréversible.')
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
77
frontend/app/shared/utils/documentDisplayUtils.ts
Normal file
77
frontend/app/shared/utils/documentDisplayUtils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Document display & preview helpers for edit pages.
|
||||
*
|
||||
* Extracted from pages/component/[id]/edit.vue, pieces/[id]/edit.vue,
|
||||
* product/[id]/edit.vue – each had an identical copy of these utilities.
|
||||
*/
|
||||
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
export const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
export const formatSize = (size: number | null | undefined): string => {
|
||||
if (size === null || size === undefined) return '—'
|
||||
if (size === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const resolveUrl = (doc: any): string => doc?.fileUrl || doc?.path || ''
|
||||
|
||||
export const shouldInlinePdf = (doc: any): boolean => {
|
||||
if (!doc || !isPdfDocument(doc)) return false
|
||||
const url = resolveUrl(doc)
|
||||
if (!url) return false
|
||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const appendPdfViewerParams = (src: string): string => {
|
||||
if (!src) return ''
|
||||
if (src.startsWith('data:')) return src
|
||||
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
|
||||
export const documentPreviewSrc = (doc: any): string => {
|
||||
const url = resolveUrl(doc)
|
||||
if (!url) return ''
|
||||
if (isPdfDocument(doc)) return appendPdfViewerParams(url)
|
||||
return url
|
||||
}
|
||||
|
||||
export const documentThumbnailClass = (doc: any): string => {
|
||||
if (shouldInlinePdf(doc) || (isImageDocument(doc) && resolveUrl(doc))) return 'h-24 w-20'
|
||||
return 'h-16 w-16'
|
||||
}
|
||||
|
||||
export interface FileIconResult {
|
||||
component: unknown
|
||||
colorClass: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const documentIcon = (doc: any): FileIconResult =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
|
||||
export const downloadDocument = (doc: any): void => {
|
||||
// Prefer dedicated download endpoint
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
// Fallback for legacy data: URIs during migration
|
||||
const target = resolveUrl(doc)
|
||||
if (!target) return
|
||||
if (target.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = target
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
window.open(target, '_blank')
|
||||
}
|
||||
335
frontend/app/shared/utils/entityCustomFieldLogic.ts
Normal file
335
frontend/app/shared/utils/entityCustomFieldLogic.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Pure functions for custom field resolution, merging, and deduplication.
|
||||
*
|
||||
* Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
|
||||
* of identical custom field logic duplicated between them.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field key / identity helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
|
||||
const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
|
||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
||||
}
|
||||
|
||||
export function resolveOrderIndex(field: any): number {
|
||||
if (!field || typeof field !== 'object') return 0
|
||||
if (typeof field.orderIndex === 'number') return field.orderIndex
|
||||
if (field.customField && typeof field.customField.orderIndex === 'number') return field.customField.orderIndex
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveFieldKey(field: any, index: number): string {
|
||||
return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
|
||||
}
|
||||
|
||||
export function resolveFieldId(field: any): string | null {
|
||||
return field?.customFieldValueId ?? null
|
||||
}
|
||||
|
||||
export function resolveFieldName(field: any): string {
|
||||
return field?.name ?? 'Champ'
|
||||
}
|
||||
|
||||
export function resolveFieldType(field: any): string {
|
||||
return field?.type ?? 'text'
|
||||
}
|
||||
|
||||
export function resolveFieldOptions(field: any): string[] {
|
||||
return field?.options ?? []
|
||||
}
|
||||
|
||||
export function resolveFieldRequired(field: any): boolean {
|
||||
return !!field?.required
|
||||
}
|
||||
|
||||
export function resolveFieldReadOnly(field: any): boolean {
|
||||
return !!field?.readOnly
|
||||
}
|
||||
|
||||
export function resolveCustomFieldId(field: any): string | null {
|
||||
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
||||
}
|
||||
|
||||
export function buildCustomFieldMetadata(field: any) {
|
||||
return {
|
||||
customFieldName: resolveFieldName(field),
|
||||
customFieldType: resolveFieldType(field),
|
||||
customFieldRequired: resolveFieldRequired(field),
|
||||
customFieldOptions: resolveFieldOptions(field),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFieldDisplayValue(field: any): string {
|
||||
const type = resolveFieldType(field)
|
||||
const rawValue = field?.value ?? ''
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(rawValue).toLowerCase()
|
||||
if (normalized === 'true') return 'Oui'
|
||||
if (normalized === 'false') return 'Non'
|
||||
}
|
||||
return rawValue || 'Non défini'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom field ID resolution against candidate pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (existingId) return existingId
|
||||
|
||||
const name = resolveFieldName(field)
|
||||
if (!name || name === 'Champ') return null
|
||||
|
||||
const matches = candidateFields.filter((candidate) => {
|
||||
if (!candidate || typeof candidate !== 'object') return false
|
||||
const candidateId = candidate.id || candidate.customFieldId
|
||||
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) return true
|
||||
return typeof candidate.name === 'string' && candidate.name === name
|
||||
})
|
||||
|
||||
if (matches.length) {
|
||||
const withId = matches.find((c) => c?.id || c?.customFieldId) || matches[0]
|
||||
const id = withId?.id || withId?.customFieldId || null
|
||||
if (id) field.customFieldId = id
|
||||
if (!field.customField && typeof withId === 'object') field.customField = withId
|
||||
return id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractStructureCustomFields(structure: any): any[] {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const customFields = structure.customFields
|
||||
return Array.isArray(customFields) ? customFields : []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deduplication & merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
|
||||
const result: any[] = []
|
||||
const seenIds = new Set<string>()
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
|
||||
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedDefinitions.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
|
||||
// Deduplicate by name+type (primary) AND by id — a field with the same
|
||||
// name+type is the same field even when stored with different IDs.
|
||||
if (nameKey && seenNames.has(nameKey)) return
|
||||
if (id && seenIds.has(id)) return
|
||||
if (id) seenIds.add(id)
|
||||
if (nameKey) seenNames.add(nameKey)
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
result.push(field)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
|
||||
const definitionList = Array.isArray(definitions) ? definitions : []
|
||||
const valueList = Array.isArray(values) ? values : []
|
||||
|
||||
const valueMap = new Map<string, any>()
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
if (fieldId) valueMap.set(fieldId, entry)
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
if (nameKey) valueMap.set(nameKey, entry)
|
||||
})
|
||||
|
||||
const merged = definitionList.map((field) => {
|
||||
if (!field || typeof field !== 'object') return field
|
||||
|
||||
const fieldId = resolveCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
||||
const matchedValue = (fieldId ? valueMap.get(fieldId) : undefined) ?? (nameKey ? valueMap.get(nameKey) : undefined)
|
||||
|
||||
if (!matchedValue) {
|
||||
return { ...field, value: field?.value ?? '', orderIndex: resolveOrderIndex(field) }
|
||||
}
|
||||
|
||||
const resolvedOrder = Math.min(resolveOrderIndex(field), resolveOrderIndex(matchedValue.customField))
|
||||
return {
|
||||
...field,
|
||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
||||
customFieldId: matchedValue.customField?.id ?? matchedValue.customFieldId ?? fieldId ?? null,
|
||||
customField: matchedValue.customField ?? field.customField ?? null,
|
||||
value: matchedValue.value ?? field.value ?? '',
|
||||
orderIndex: resolvedOrder,
|
||||
}
|
||||
})
|
||||
|
||||
valueList.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return
|
||||
|
||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
||||
|
||||
const exists = merged.some((field) => {
|
||||
if (!field || typeof field !== 'object') return false
|
||||
if (field.customFieldValueId && field.customFieldValueId === entry.id) return true
|
||||
const existingId = resolveCustomFieldId(field)
|
||||
if (fieldId && existingId && existingId === fieldId) return true
|
||||
if (!fieldId && nameKey) {
|
||||
return fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
merged.push({
|
||||
customFieldValueId: entry.id ?? null,
|
||||
customFieldId: fieldId,
|
||||
name: entry.customField?.name ?? '',
|
||||
type: entry.customField?.type ?? 'text',
|
||||
required: entry.customField?.required ?? false,
|
||||
options: entry.customField?.options ?? [],
|
||||
value: entry.value ?? '',
|
||||
customField: entry.customField ?? null,
|
||||
orderIndex: resolveOrderIndex(entry.customField),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
export function dedupeMergedFields(fields: any[]): any[] {
|
||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
||||
return Array.isArray(fields)
|
||||
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
: []
|
||||
}
|
||||
|
||||
const seenById = new Map<string, any>()
|
||||
const seenByName = new Map<string, any>()
|
||||
const result: any[] = []
|
||||
|
||||
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
|
||||
orderedFields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') return
|
||||
|
||||
const rawName = resolveFieldName(field)
|
||||
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
|
||||
if (!normalizedName) return
|
||||
|
||||
field.name = normalizedName
|
||||
field.type = field.type || resolveFieldType(field)
|
||||
|
||||
const fieldId = resolveCustomFieldId(field)
|
||||
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
|
||||
|
||||
// Check duplicates by name+type first (same field can have different IDs)
|
||||
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
|
||||
|
||||
if (!existing) {
|
||||
field.orderIndex = resolveOrderIndex(field)
|
||||
if (fieldId) seenById.set(fieldId, field)
|
||||
if (nameKey) seenByName.set(nameKey, field)
|
||||
result.push(field)
|
||||
return
|
||||
}
|
||||
|
||||
const existingHasValue = existing.value !== undefined && existing.value !== null && String(existing.value).trim().length > 0
|
||||
const incomingHasValue = field.value !== undefined && field.value !== null && String(field.value).trim().length > 0
|
||||
|
||||
if (!existingHasValue && incomingHasValue) {
|
||||
Object.assign(existing, field)
|
||||
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
|
||||
if (fieldId) seenById.set(fieldId, existing)
|
||||
if (nameKey) seenByName.set(nameKey, existing)
|
||||
}
|
||||
})
|
||||
|
||||
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Definition sources builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
|
||||
const definitions: any[] = []
|
||||
const pushFields = (collection: any) => {
|
||||
if (Array.isArray(collection)) definitions.push(...collection)
|
||||
}
|
||||
|
||||
if (entityType === 'composant') {
|
||||
const type = entity.typeComposant || {}
|
||||
|
||||
pushFields(entity.customFields)
|
||||
pushFields(entity.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
|
||||
;[
|
||||
entity.definition?.structure,
|
||||
type.structure,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) definitions.push(...fields)
|
||||
})
|
||||
} else {
|
||||
const type = entity.typePiece || {}
|
||||
|
||||
pushFields(entity.customFields)
|
||||
pushFields(entity.definition?.customFields)
|
||||
pushFields(type.customFields)
|
||||
|
||||
;[
|
||||
entity.definition?.structure,
|
||||
type.structure,
|
||||
].forEach((structure) => {
|
||||
const fields = extractStructureCustomFields(structure)
|
||||
if (fields.length) definitions.push(...fields)
|
||||
})
|
||||
}
|
||||
|
||||
return deduplicateFieldDefinitions(definitions)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate fields builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
|
||||
const map = new Map<string, any>()
|
||||
const register = (collection: any[]) => {
|
||||
if (!Array.isArray(collection)) return
|
||||
collection.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') return
|
||||
const id = item.id || item.customFieldId
|
||||
const name = typeof item.name === 'string' ? item.name : null
|
||||
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
||||
if (!key || map.has(key)) return
|
||||
map.set(key, item)
|
||||
})
|
||||
}
|
||||
|
||||
register((entity.customFieldValues || []).map((value: any) => value?.customField))
|
||||
register(definitionSources)
|
||||
|
||||
return Array.from(map.values())
|
||||
}
|
||||
152
frontend/app/shared/utils/errorMessages.ts
Normal file
152
frontend/app/shared/utils/errorMessages.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
|
||||
* into user-friendly French messages for display in toasts/alerts.
|
||||
*/
|
||||
|
||||
const EXACT_MATCHES: Record<string, string> = {
|
||||
// UniqueConstraintSubscriber (HTTP 409)
|
||||
'nom duplique': 'Un élément avec ce nom existe déjà.',
|
||||
|
||||
// English backend messages → French
|
||||
'Machine not found.': 'Machine introuvable.',
|
||||
'Composant not found.': 'Composant introuvable.',
|
||||
'Piece not found.': 'Pièce introuvable.',
|
||||
'Product not found.': 'Produit introuvable.',
|
||||
'Site not found.': 'Site introuvable.',
|
||||
'Custom field not found.': 'Champ personnalisé introuvable.',
|
||||
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
|
||||
'Document not found.': 'Document introuvable.',
|
||||
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
|
||||
'Invalid document data.': 'Les données du document sont invalides.',
|
||||
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
|
||||
'Unsupported entity type.': 'Type d\'entité non supporté.',
|
||||
'Entity target is missing.': 'La cible de l\'entité est manquante.',
|
||||
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
|
||||
|
||||
// Symfony validator messages
|
||||
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
|
||||
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
|
||||
'This value is already used.': 'Cette valeur est déjà utilisée.',
|
||||
'This field is missing.': 'Un champ obligatoire est manquant.',
|
||||
|
||||
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
|
||||
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
|
||||
'Bad Request': 'Requête invalide.',
|
||||
'Not Found': 'Ressource introuvable.',
|
||||
'Conflict': 'Un élément similaire existe déjà.',
|
||||
'Unprocessable Entity': 'Données invalides.',
|
||||
'Unprocessable Content': 'Données invalides.',
|
||||
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
|
||||
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
|
||||
}
|
||||
|
||||
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
|
||||
// Database / Doctrine errors
|
||||
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
|
||||
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
|
||||
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
|
||||
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
|
||||
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
|
||||
|
||||
// Symfony / API Platform internal messages
|
||||
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
|
||||
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
|
||||
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
|
||||
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
|
||||
[/No route found/i, 'Ressource introuvable.'],
|
||||
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
|
||||
]
|
||||
|
||||
/**
|
||||
* Detects if a message contains technical jargon that should not be shown to users.
|
||||
*/
|
||||
function containsTechnicalJargon(message: string): boolean {
|
||||
const patterns = [
|
||||
/stack trace/i,
|
||||
/exception/i,
|
||||
/\bat\s+[\w\\]+::/,
|
||||
/vendor\//,
|
||||
/\.php/,
|
||||
/doctrine/i,
|
||||
/symfony/i,
|
||||
/SQLSTATE/i,
|
||||
/PDOException/i,
|
||||
/DBALException/i,
|
||||
/RuntimeException/i,
|
||||
/TypeError/i,
|
||||
/LogicException/i,
|
||||
/InvalidArgumentException/i,
|
||||
/UnexpectedValueException/i,
|
||||
/constraint.*violation/i,
|
||||
/entity.*manager/i,
|
||||
/Hydra error/i,
|
||||
]
|
||||
return patterns.some((p) => p.test(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a raw backend error message into a user-friendly French message.
|
||||
*
|
||||
* Usage:
|
||||
* import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
* showError(humanizeError(rawMessage))
|
||||
*/
|
||||
export function humanizeError(rawMessage: string | undefined | null): string {
|
||||
if (!rawMessage) return 'Une erreur est survenue.'
|
||||
|
||||
const trimmed = rawMessage.trim()
|
||||
if (!trimmed) return 'Une erreur est survenue.'
|
||||
|
||||
// 1. Exact match
|
||||
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
|
||||
|
||||
// 2. "Erreur XXX: StatusText" pattern — translate the status text
|
||||
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
|
||||
if (httpMatch) {
|
||||
const statusText = httpMatch[2]!.trim()
|
||||
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
|
||||
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
|
||||
}
|
||||
|
||||
// 3. Regex patterns for technical errors
|
||||
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
|
||||
if (pattern.test(trimmed)) return replacement
|
||||
}
|
||||
|
||||
// 4. If it contains technical jargon, replace with generic message
|
||||
if (containsTechnicalJargon(trimmed)) {
|
||||
return 'Une erreur est survenue. Veuillez réessayer.'
|
||||
}
|
||||
|
||||
// 5. Already user-friendly — return as-is
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the best error message from various backend response formats.
|
||||
* Handles: { message }, { error }, { detail }, { "hydra:description" }
|
||||
*/
|
||||
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
|
||||
if (!errorData || typeof errorData !== 'object') return null
|
||||
|
||||
// Symfony validator violations — priorité max (message propre sans préfixe champ)
|
||||
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
|
||||
const first = errorData.violations[0] as Record<string, unknown>
|
||||
if (typeof first?.message === 'string') return first.message
|
||||
}
|
||||
|
||||
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
|
||||
if (typeof errorData.error === 'string') return errorData.error
|
||||
|
||||
// Custom controllers format
|
||||
if (typeof errorData.message === 'string') return errorData.message
|
||||
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
|
||||
|
||||
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
|
||||
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
|
||||
if (typeof errorData.detail === 'string') return errorData.detail
|
||||
|
||||
return null
|
||||
}
|
||||
79
frontend/app/shared/utils/historyDisplayUtils.ts
Normal file
79
frontend/app/shared/utils/historyDisplayUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* History display utilities for edit pages.
|
||||
*
|
||||
* Extracted from pages/component/[id]/edit.vue, pieces/[id]/edit.vue,
|
||||
* product/[id]/edit.vue – each had an identical copy.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const historyActionLabel = (action: string): string => {
|
||||
if (action === 'create') return 'Création'
|
||||
if (action === 'delete') return 'Suppression'
|
||||
if (action === 'restore') return 'Restauration'
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
export const formatHistoryDate = (value: string): string => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
export const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '—'
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) return `${name} (#${id})`
|
||||
if (name) return name
|
||||
if (id) return `#${id}`
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiffChange {
|
||||
from?: unknown
|
||||
to?: unknown
|
||||
}
|
||||
|
||||
export interface HistoryDiffEntry {
|
||||
field: string
|
||||
label: string
|
||||
fromLabel: string
|
||||
toLabel: string
|
||||
}
|
||||
|
||||
export const historyDiffEntries = (
|
||||
entry: { diff?: Record<string, DiffChange> | null },
|
||||
fieldLabels: Record<string, string>,
|
||||
): HistoryDiffEntry[] => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => ({
|
||||
field,
|
||||
label: fieldLabels[field] ?? field,
|
||||
fromLabel: formatHistoryValue(change?.from),
|
||||
toLabel: formatHistoryValue(change?.to),
|
||||
}))
|
||||
}
|
||||
104
frontend/app/shared/utils/pieceProductSelectionUtils.ts
Normal file
104
frontend/app/shared/utils/pieceProductSelectionUtils.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||
|
||||
/**
|
||||
* Extract the products array from a piece model structure, defaulting to [].
|
||||
*/
|
||||
export const getStructureProducts = (structure: PieceModelStructure | null): PieceModelProduct[] =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
/**
|
||||
* Build a human-readable label for a single product requirement.
|
||||
*/
|
||||
export const describeProductRequirement = (requirement: PieceModelProduct, index: number): string => {
|
||||
if (!requirement) {
|
||||
return `Produit ${index + 1}`
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (requirement.role) {
|
||||
parts.push(requirement.role)
|
||||
}
|
||||
if (requirement.typeProductLabel) {
|
||||
parts.push(requirement.typeProductLabel)
|
||||
} else if (requirement.typeProductId) {
|
||||
parts.push(`Catégorie #${requirement.typeProductId}`)
|
||||
}
|
||||
if (requirement.familyCode) {
|
||||
parts.push(`Famille ${requirement.familyCode}`)
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push(`Produit ${index + 1}`)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build description strings for every product requirement in a structure.
|
||||
*/
|
||||
export const buildProductRequirementDescriptions = (
|
||||
products: PieceModelProduct[],
|
||||
): string[] =>
|
||||
products.map((requirement, index) => describeProductRequirement(requirement, index))
|
||||
|
||||
/**
|
||||
* Build the entry objects used to render product selection inputs.
|
||||
*/
|
||||
export const buildProductRequirementEntries = (
|
||||
products: PieceModelProduct[],
|
||||
keyPrefix: string,
|
||||
) =>
|
||||
products.map((requirement, index) => ({
|
||||
index,
|
||||
key: `${keyPrefix}-${index}-${requirement?.typeProductId || 'any'}`,
|
||||
label: describeProductRequirement(requirement, index),
|
||||
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Resize the selections array to match the expected count, preserving existing values.
|
||||
*/
|
||||
export const resizeProductSelections = (
|
||||
current: (string | null)[],
|
||||
count: number,
|
||||
): (string | null)[] =>
|
||||
Array.from({ length: count }, (_, index) => current[index] ?? null)
|
||||
|
||||
/**
|
||||
* Return true when all required product slots have a non-empty string value,
|
||||
* or when no product selection is required.
|
||||
*/
|
||||
export const areProductSelectionsFilled = (
|
||||
requiresSelection: boolean,
|
||||
entries: { index: number }[],
|
||||
selections: (string | null)[],
|
||||
): boolean =>
|
||||
!requiresSelection ||
|
||||
entries.every((entry) => {
|
||||
const value = selections[entry.index]
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Set a single product selection by index, returning a new array.
|
||||
*/
|
||||
export const applyProductSelection = (
|
||||
current: (string | null)[],
|
||||
index: number,
|
||||
value: string | null,
|
||||
): (string | null)[] => {
|
||||
const normalized = typeof value === 'string' ? value : null
|
||||
const next = [...current]
|
||||
next[index] = normalized
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract normalized product IDs from the current selections based on requirement entries.
|
||||
*/
|
||||
export const collectNormalizedProductIds = (
|
||||
entries: { index: number }[],
|
||||
selections: (string | null)[],
|
||||
): string[] =>
|
||||
entries
|
||||
.map((entry) => selections[entry.index])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
333
frontend/app/shared/utils/productDisplayUtils.ts
Normal file
333
frontend/app/shared/utils/productDisplayUtils.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Product resolution and display utilities.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue – these functions resolve product
|
||||
* references from deeply nested API payloads and build display objects.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProductDisplay {
|
||||
name: string
|
||||
reference: string | null
|
||||
category: string | null
|
||||
suppliers: string | null
|
||||
price: string | null
|
||||
}
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (value: unknown): value is AnyRecord =>
|
||||
Object.prototype.toString.call(value) === '[object Object]'
|
||||
|
||||
export const resolveIdentifier = (...candidates: unknown[]): string | null => {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined && candidate !== null && candidate !== '') {
|
||||
return candidate as string
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supplier / price labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getProductSuppliersLabel = (product: AnyRecord | null): string | null => {
|
||||
if (!product) return null
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? (product.constructeurs as AnyRecord[]).map((c) => c?.name as string).filter(Boolean)
|
||||
: []
|
||||
return suppliers.length > 0 ? suppliers.join(', ') : null
|
||||
}
|
||||
|
||||
export const getProductPriceLabel = (product: AnyRecord | null): string | null => {
|
||||
if (!product) return null
|
||||
const priceValue =
|
||||
(product.supplierPrice ?? product.prix ?? product.price ?? null) as string | number | null
|
||||
if (priceValue === undefined || priceValue === null) return null
|
||||
const numeric = Number(priceValue)
|
||||
if (Number.isNaN(numeric)) return null
|
||||
return `${numeric.toFixed(2)} €`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveProductReference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveProductReference = (
|
||||
source: AnyRecord | null | undefined,
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
): { product: AnyRecord | null; productId: string | null } => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return { product: null, productId: null }
|
||||
}
|
||||
|
||||
const candidateKeys: (string | null)[] = [
|
||||
null,
|
||||
'productLink',
|
||||
'machinePieceLink',
|
||||
'machineComponentLink',
|
||||
'machineProductLink',
|
||||
'originalPiece',
|
||||
'originalComposant',
|
||||
'link',
|
||||
'overrides',
|
||||
'machineComponentLinkOverrides',
|
||||
'requirement',
|
||||
'selection',
|
||||
'entry',
|
||||
]
|
||||
|
||||
let product: AnyRecord | null = null
|
||||
let productId: string | null = null
|
||||
|
||||
const inspect = (container: unknown) => {
|
||||
if (!container || typeof container !== 'object') return
|
||||
const c = container as AnyRecord
|
||||
if (!product && c.product && typeof c.product === 'object') {
|
||||
product = c.product as AnyRecord
|
||||
}
|
||||
if (!productId) {
|
||||
const candidate =
|
||||
(c.productId as string) ||
|
||||
(c.product && typeof c.product === 'object'
|
||||
? ((c.product as AnyRecord).id as string) || ((c.product as AnyRecord).productId as string)
|
||||
: null) ||
|
||||
null
|
||||
if (candidate) productId = candidate
|
||||
}
|
||||
}
|
||||
|
||||
candidateKeys.forEach((key) => {
|
||||
if (key === null) inspect(source)
|
||||
else inspect((source as AnyRecord)[key])
|
||||
})
|
||||
|
||||
if (!product && productId) {
|
||||
product = findProductById(productId) || null
|
||||
}
|
||||
|
||||
if (!product && !productId && source.productName) {
|
||||
const suppliersLabel =
|
||||
typeof source.constructeursLabel === 'string'
|
||||
? source.constructeursLabel
|
||||
: typeof source.productSuppliers === 'string'
|
||||
? source.productSuppliers
|
||||
: null
|
||||
|
||||
return {
|
||||
product: {
|
||||
name: source.productName,
|
||||
reference: source.productReference || null,
|
||||
typeProduct: source.productCategory ? { name: source.productCategory } : null,
|
||||
constructeurs: suppliersLabel
|
||||
? (suppliersLabel as string)
|
||||
.split(',')
|
||||
.map((name: string) => name.trim())
|
||||
.filter((name: string) => name.length > 0)
|
||||
.map((name: string) => ({ name }))
|
||||
: undefined,
|
||||
supplierPrice: source.productPrice ?? source.productPriceLabel ?? source.price ?? null,
|
||||
} as AnyRecord,
|
||||
productId: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (productId && product && product.id && product.id !== productId) {
|
||||
const resolved = findProductById(productId)
|
||||
if (resolved) product = resolved
|
||||
}
|
||||
|
||||
return { product: product || null, productId: productId || null }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getProductDisplay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getProductDisplay = (
|
||||
source: AnyRecord | null | undefined,
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
): ProductDisplay | null => {
|
||||
if (!source || typeof source !== 'object') return null
|
||||
|
||||
const { product, productId } = resolveProductReference(source, findProductById)
|
||||
|
||||
if (product) {
|
||||
return {
|
||||
name: (product.name as string) || (product.reference as string) || 'Produit catalogue',
|
||||
reference: (product.reference as string) || null,
|
||||
category: (product.typeProduct as AnyRecord)?.name as string || null,
|
||||
suppliers: getProductSuppliersLabel(product),
|
||||
price: getProductPriceLabel(product),
|
||||
}
|
||||
}
|
||||
|
||||
let fallbackName =
|
||||
(source.productName ||
|
||||
source.productLabel ||
|
||||
source.typeProductLabel ||
|
||||
(source.typeProduct as AnyRecord)?.name ||
|
||||
(productId ? `Produit ${productId}` : null)) as string | null
|
||||
let fallbackReference = (source.productReference || source.reference || null) as string | null
|
||||
let fallbackCategory =
|
||||
(source.productCategory ||
|
||||
source.typeProductLabel ||
|
||||
(source.typeProduct as AnyRecord)?.name ||
|
||||
null) as string | null
|
||||
let fallbackSuppliers =
|
||||
(source.productSuppliers ||
|
||||
source.constructeursLabel ||
|
||||
source.supplierLabel ||
|
||||
null) as string | null
|
||||
let fallbackPrice =
|
||||
(source.productPriceLabel ||
|
||||
source.productPrice ||
|
||||
source.priceLabel ||
|
||||
source.price ||
|
||||
null) as string | number | null
|
||||
|
||||
const structuralCandidates = [
|
||||
source.products,
|
||||
(source.definition as AnyRecord)?.products,
|
||||
((source.definition as AnyRecord)?.structure as AnyRecord)?.products,
|
||||
(source.structure as AnyRecord)?.products,
|
||||
(source.requirement as AnyRecord)?.products,
|
||||
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
|
||||
(source.typeComposant as AnyRecord)?.products,
|
||||
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,
|
||||
(source.originalComposant as AnyRecord)?.products,
|
||||
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products,
|
||||
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
|
||||
(source.originalComponent as AnyRecord)?.products,
|
||||
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.products,
|
||||
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
|
||||
]
|
||||
|
||||
const structuralProducts = structuralCandidates
|
||||
.flatMap((candidate) => {
|
||||
if (Array.isArray(candidate)) return candidate
|
||||
if (candidate && typeof candidate === 'object' && Array.isArray((candidate as AnyRecord).products)) {
|
||||
return (candidate as AnyRecord).products as unknown[]
|
||||
}
|
||||
return []
|
||||
})
|
||||
.filter((entry) => entry && typeof entry === 'object')
|
||||
|
||||
const structuralProduct = structuralProducts.length ? (structuralProducts[0] as AnyRecord) : null
|
||||
|
||||
const structuralFamilyCode =
|
||||
(structuralProduct && typeof structuralProduct.familyCode === 'string'
|
||||
? structuralProduct.familyCode
|
||||
: null) ||
|
||||
(typeof source.familyCode === 'string' ? source.familyCode : null)
|
||||
|
||||
if (!fallbackName && structuralProduct) {
|
||||
fallbackName =
|
||||
(structuralProduct.typeProductLabel as string) ||
|
||||
((structuralProduct.typeProduct as AnyRecord)?.name as string) ||
|
||||
(structuralProduct.reference as string) ||
|
||||
(structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
|
||||
null
|
||||
}
|
||||
|
||||
if (!fallbackReference && structuralProduct?.reference) {
|
||||
fallbackReference = structuralProduct.reference as string
|
||||
}
|
||||
|
||||
if (!fallbackCategory) {
|
||||
fallbackCategory =
|
||||
(structuralProduct?.typeProductLabel as string) ||
|
||||
((structuralProduct?.typeProduct as AnyRecord)?.name as string) ||
|
||||
(structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
|
||||
null
|
||||
}
|
||||
|
||||
if (!fallbackSuppliers && structuralProduct?.supplierLabel) {
|
||||
fallbackSuppliers = structuralProduct.supplierLabel as string
|
||||
}
|
||||
|
||||
if (!fallbackSuppliers && Array.isArray(structuralProduct?.constructeurs)) {
|
||||
const supplierNames = (structuralProduct!.constructeurs as AnyRecord[])
|
||||
.map((c) => c?.name as string)
|
||||
.filter((name) => typeof name === 'string' && name.trim().length > 0)
|
||||
if (supplierNames.length) fallbackSuppliers = supplierNames.join(', ')
|
||||
}
|
||||
|
||||
if (!fallbackPrice && structuralProduct?.priceLabel) fallbackPrice = structuralProduct.priceLabel as string
|
||||
if (!fallbackPrice && structuralProduct?.price) fallbackPrice = structuralProduct.price as string | number
|
||||
|
||||
if (fallbackName || fallbackReference || fallbackCategory || fallbackSuppliers || fallbackPrice) {
|
||||
return {
|
||||
name: fallbackName || 'Produit catalogue',
|
||||
reference: fallbackReference,
|
||||
category: fallbackCategory,
|
||||
suppliers: fallbackSuppliers,
|
||||
price:
|
||||
typeof fallbackPrice === 'number'
|
||||
? `${fallbackPrice.toFixed(2)} €`
|
||||
: (fallbackPrice as string) || null,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parent link identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const extractParentLinkIdentifiers = (source: AnyRecord | null | undefined): AnyRecord => {
|
||||
if (!source || typeof source !== 'object') return {}
|
||||
|
||||
const identifiers: AnyRecord = {}
|
||||
|
||||
const idKeys = [
|
||||
'parentRequirementId',
|
||||
'parentComponentRequirementId',
|
||||
'parentPieceRequirementId',
|
||||
'parentMachineComponentRequirementId',
|
||||
'parentMachinePieceRequirementId',
|
||||
'parentLinkId',
|
||||
'parentComponentLinkId',
|
||||
'parentPieceLinkId',
|
||||
'parentComponentId',
|
||||
'parentPieceId',
|
||||
]
|
||||
|
||||
idKeys.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const value = source[key]
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
identifiers[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const objectKeys = [
|
||||
'parentRequirement',
|
||||
'parentComponentRequirement',
|
||||
'parentPieceRequirement',
|
||||
'parentMachineComponentRequirement',
|
||||
'parentMachinePieceRequirement',
|
||||
]
|
||||
|
||||
objectKeys.forEach((key) => {
|
||||
const value = source[key]
|
||||
if (isPlainObject(value) && value.id !== undefined && value.id !== null && value.id !== '') {
|
||||
const idKey = `${key}Id`
|
||||
if (!Object.prototype.hasOwnProperty.call(identifiers, idKey)) {
|
||||
identifiers[idKey] = value.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return identifiers
|
||||
}
|
||||
258
frontend/app/shared/utils/structureAssignmentHelpers.ts
Normal file
258
frontend/app/shared/utils/structureAssignmentHelpers.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Pure helper functions for building, validating and serializing
|
||||
* component structure assignment trees.
|
||||
*
|
||||
* Extracted from useComponentCreate composable to keep file sizes manageable.
|
||||
*/
|
||||
|
||||
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructure,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractSubcomponents(
|
||||
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
|
||||
): ComponentModelStructureNode[] {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
return []
|
||||
}
|
||||
const raw = Array.isArray((definition as any).subcomponents)
|
||||
? (definition as any).subcomponents
|
||||
: Array.isArray((definition as any).subComponents)
|
||||
? (definition as any).subComponents
|
||||
: []
|
||||
return raw.filter(
|
||||
(item: unknown): item is ComponentModelStructureNode =>
|
||||
!!item && typeof item === 'object',
|
||||
)
|
||||
}
|
||||
|
||||
export function extractPiecesFromNode(
|
||||
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
|
||||
): ComponentModelPiece[] {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
return []
|
||||
}
|
||||
const raw = Array.isArray((definition as any).pieces)
|
||||
? (definition as any).pieces
|
||||
: []
|
||||
return raw.filter(
|
||||
(item: unknown): item is ComponentModelPiece =>
|
||||
!!item && typeof item === 'object',
|
||||
)
|
||||
}
|
||||
|
||||
export function extractProductsFromNode(
|
||||
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
|
||||
): ComponentModelProduct[] {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
return []
|
||||
}
|
||||
const raw = Array.isArray((definition as any).products)
|
||||
? (definition as any).products
|
||||
: []
|
||||
return raw.filter(
|
||||
(item: unknown): item is ComponentModelProduct =>
|
||||
!!item && typeof item === 'object',
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment tree building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildAssignmentNode(
|
||||
definition: ComponentModelStructureNode | ComponentModelStructure,
|
||||
path: string,
|
||||
): StructureAssignmentNode {
|
||||
const pieces = extractPiecesFromNode(definition).map((piece, index) => ({
|
||||
path: `${path}:piece-${index}`,
|
||||
definition: piece,
|
||||
selectedPieceId: '',
|
||||
}))
|
||||
|
||||
const products = extractProductsFromNode(definition).map((product, index) => ({
|
||||
path: `${path}:product-${index}`,
|
||||
definition: product,
|
||||
selectedProductId: '',
|
||||
}))
|
||||
|
||||
const subcomponents = extractSubcomponents(definition).map(
|
||||
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
|
||||
)
|
||||
|
||||
return {
|
||||
path,
|
||||
definition,
|
||||
selectedComponentId: '',
|
||||
pieces,
|
||||
products,
|
||||
subcomponents,
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeStructureAssignments(
|
||||
structure: ComponentModelStructure | null,
|
||||
): StructureAssignmentNode | null {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return null
|
||||
}
|
||||
return buildAssignmentNode(structure, 'root')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function hasAssignments(node: StructureAssignmentNode | null): boolean {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
|
||||
return true
|
||||
}
|
||||
return node.subcomponents.some((child) => hasAssignments(child))
|
||||
}
|
||||
|
||||
export function isAssignmentNodeComplete(
|
||||
node: StructureAssignmentNode,
|
||||
isRootNode = false,
|
||||
): boolean {
|
||||
const piecesComplete = node.pieces.every(
|
||||
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
|
||||
)
|
||||
const productsComplete = node.products.every(
|
||||
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
|
||||
)
|
||||
const subcomponentsComplete = node.subcomponents.every(
|
||||
(child) =>
|
||||
!!child.selectedComponentId
|
||||
&& child.selectedComponentId.length > 0
|
||||
&& isAssignmentNodeComplete(child, false),
|
||||
)
|
||||
return (
|
||||
piecesComplete
|
||||
&& productsComplete
|
||||
&& subcomponentsComplete
|
||||
&& (isRootNode || !!node.selectedComponentId)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function stripNullish(input: Record<string, any>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter(
|
||||
([, value]) => value !== null && value !== undefined && value !== '',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeStructureDefinition(
|
||||
definition: ComponentModelStructureNode,
|
||||
) {
|
||||
return stripNullish({
|
||||
alias: definition.alias ?? null,
|
||||
typeComposantId: definition.typeComposantId ?? null,
|
||||
typeComposantLabel: definition.typeComposantLabel ?? null,
|
||||
modelId: definition.modelId ?? null,
|
||||
familyCode: (definition as any).familyCode ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizePieceDefinition(definition: ComponentModelPiece) {
|
||||
return stripNullish({
|
||||
role: (definition as any).role ?? null,
|
||||
typePieceId: definition.typePieceId ?? null,
|
||||
typePieceLabel: definition.typePieceLabel ?? null,
|
||||
reference: definition.reference ?? null,
|
||||
familyCode: (definition as any).familyCode ?? null,
|
||||
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeProductDefinition(definition: ComponentModelProduct) {
|
||||
return stripNullish({
|
||||
role: (definition as any).role ?? null,
|
||||
typeProductId: definition.typeProductId ?? null,
|
||||
typeProductLabel: (definition as any).typeProductLabel ?? null,
|
||||
reference: (definition as any).reference ?? null,
|
||||
familyCode: (definition as any).familyCode ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function serializeStructureAssignments(
|
||||
root: StructureAssignmentNode | null,
|
||||
) {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serializeNode = (
|
||||
assignment: StructureAssignmentNode,
|
||||
isRootNode = false,
|
||||
): Record<string, any> => {
|
||||
const serializedPieces = assignment.pieces
|
||||
.filter((piece) => !!piece.selectedPieceId)
|
||||
.map((piece) =>
|
||||
stripNullish({
|
||||
path: piece.path,
|
||||
definition: sanitizePieceDefinition(piece.definition),
|
||||
selectedPieceId: piece.selectedPieceId,
|
||||
}),
|
||||
)
|
||||
|
||||
const serializedProducts = assignment.products
|
||||
.filter((product) => !!product.selectedProductId)
|
||||
.map((product) =>
|
||||
stripNullish({
|
||||
path: product.path,
|
||||
definition: sanitizeProductDefinition(product.definition),
|
||||
selectedProductId: product.selectedProductId,
|
||||
}),
|
||||
)
|
||||
|
||||
const serializedSubcomponents = assignment.subcomponents
|
||||
.map((child) => serializeNode(child, false))
|
||||
.filter((child) => Object.keys(child).length > 0)
|
||||
|
||||
const base: Record<string, any> = {
|
||||
path: assignment.path,
|
||||
definition: sanitizeStructureDefinition(assignment.definition),
|
||||
}
|
||||
|
||||
if (!isRootNode) {
|
||||
base.selectedComponentId = assignment.selectedComponentId
|
||||
}
|
||||
if (serializedPieces.length) {
|
||||
base.pieces = serializedPieces
|
||||
}
|
||||
if (serializedProducts.length) {
|
||||
base.products = serializedProducts
|
||||
}
|
||||
if (serializedSubcomponents.length) {
|
||||
base.subcomponents = serializedSubcomponents
|
||||
}
|
||||
|
||||
return stripNullish(base)
|
||||
}
|
||||
|
||||
const serializedRoot = serializeNode(root, true)
|
||||
if (
|
||||
(!serializedRoot.pieces || serializedRoot.pieces.length === 0)
|
||||
&& (!serializedRoot.products || serializedRoot.products.length === 0)
|
||||
&& (!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return serializedRoot
|
||||
}
|
||||
262
frontend/app/shared/utils/structureAssignmentLabels.ts
Normal file
262
frontend/app/shared/utils/structureAssignmentLabels.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Type definitions and pure label/description helpers for structure assignments.
|
||||
*
|
||||
* Extracted from composables/useStructureAssignmentFetch.ts to keep files
|
||||
* under 500 lines. These are stateless utilities that do not depend on Vue
|
||||
* reactivity or API fetching.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ComponentOption {
|
||||
id: string
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
typeComposantId?: string | null
|
||||
typeComposant?: {
|
||||
id: string
|
||||
name?: string | null
|
||||
code?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface PieceOption {
|
||||
id: string
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: {
|
||||
id: string
|
||||
name?: string | null
|
||||
code?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ProductOption {
|
||||
id: string
|
||||
name?: string | null
|
||||
reference?: string | null
|
||||
typeProductId?: string | null
|
||||
typeProduct?: {
|
||||
id: string
|
||||
name?: string | null
|
||||
code?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment node types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StructurePieceAssignment {
|
||||
path: string
|
||||
definition: ComponentModelPiece
|
||||
selectedPieceId: string
|
||||
}
|
||||
|
||||
export interface StructureProductAssignment {
|
||||
path: string
|
||||
definition: ComponentModelProduct
|
||||
selectedProductId: string
|
||||
}
|
||||
|
||||
export interface StructureAssignmentNode {
|
||||
path: string
|
||||
definition: ComponentModelStructureNode
|
||||
selectedComponentId: string
|
||||
pieces: StructurePieceAssignment[]
|
||||
products: StructureProductAssignment[]
|
||||
subcomponents: StructureAssignmentNode[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component label helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const componentOptionLabel = (component?: ComponentOption | null): string => {
|
||||
if (!component) {
|
||||
return 'Composant sans nom'
|
||||
}
|
||||
const name = component.name || 'Composant sans nom'
|
||||
return component.reference ? `${name} — ${component.reference}` : name
|
||||
}
|
||||
|
||||
export const componentOptionDescription = (component?: ComponentOption | null): string => {
|
||||
if (!component) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
const typeLabel =
|
||||
component.typeComposant?.name || component.typeComposant?.code || null
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel)
|
||||
}
|
||||
if (component.reference) {
|
||||
parts.push(`Ref. ${component.reference}`)
|
||||
}
|
||||
return parts.join(' \u2022 ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Piece label helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const pieceOptionLabel = (piece?: PieceOption | null): string => {
|
||||
if (!piece) {
|
||||
return 'Pièce'
|
||||
}
|
||||
const name = piece.name || 'Pièce'
|
||||
return piece.reference ? `${name} — ${piece.reference}` : name
|
||||
}
|
||||
|
||||
export const pieceOptionDescription = (piece?: PieceOption | null): string => {
|
||||
if (!piece) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
const typeLabel =
|
||||
piece.typePiece?.name || piece.typePiece?.code || null
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel)
|
||||
}
|
||||
if (piece.reference) {
|
||||
parts.push(`Ref. ${piece.reference}`)
|
||||
}
|
||||
return parts.join(' \u2022 ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Product label helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const productOptionLabel = (product?: ProductOption | null): string => {
|
||||
if (!product) {
|
||||
return 'Produit'
|
||||
}
|
||||
const name = product.name || 'Produit'
|
||||
return product.reference ? `${name} — ${product.reference}` : name
|
||||
}
|
||||
|
||||
export const productOptionDescription = (product?: ProductOption | null): string => {
|
||||
if (!product) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
const typeLabel =
|
||||
product.typeProduct?.name || product.typeProduct?.code || null
|
||||
if (typeLabel) {
|
||||
parts.push(typeLabel)
|
||||
}
|
||||
if (product.reference) {
|
||||
parts.push(`Ref. ${product.reference}`)
|
||||
}
|
||||
return parts.join(' \u2022 ')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requirement description helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const describePieceRequirement = (
|
||||
assignment: StructurePieceAssignment,
|
||||
options: PieceOption[],
|
||||
pieceTypeLabelMap: Record<string, string>,
|
||||
): string => {
|
||||
const definition = assignment.definition
|
||||
const parts: string[] = []
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
if (trimmed && !parts.includes(trimmed)) {
|
||||
parts.push(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPiece = options[0] || null
|
||||
const fallbackType = fallbackPiece?.typePiece || null
|
||||
|
||||
addPart(definition.role)
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel
|
||||
|| definition.typePiece?.name
|
||||
|| (definition.typePieceId ? pieceTypeLabelMap[definition.typePieceId] : null)
|
||||
|| fallbackType?.name
|
||||
addPart(explicitLabel)
|
||||
|
||||
const family =
|
||||
definition.familyCode
|
||||
|| definition.typePiece?.code
|
||||
|| fallbackType?.code
|
||||
|| null
|
||||
if (family) {
|
||||
addPart(`Famille ${family}`)
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
addPart(fallbackType?.name)
|
||||
if (fallbackType?.code) {
|
||||
addPart(`Famille ${fallbackType.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0 && definition.typePieceId) {
|
||||
addPart(`#${definition.typePieceId}`)
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' \u2022 ') : 'Pi\u00e8ce du squelette'
|
||||
}
|
||||
|
||||
export const describeProductRequirement = (
|
||||
assignment: StructureProductAssignment,
|
||||
options: ProductOption[],
|
||||
productTypeLabelMap: Record<string, string>,
|
||||
): string => {
|
||||
const definition = assignment.definition
|
||||
const parts: string[] = []
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
if (trimmed && !parts.includes(trimmed)) {
|
||||
parts.push(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackProduct = options[0] || null
|
||||
const fallbackType = fallbackProduct?.typeProduct || null
|
||||
|
||||
addPart(definition.role)
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel
|
||||
|| definition.typeProduct?.name
|
||||
|| (definition.typeProductId ? productTypeLabelMap[definition.typeProductId] : null)
|
||||
|| fallbackType?.name
|
||||
addPart(explicitLabel)
|
||||
|
||||
const family =
|
||||
definition.familyCode
|
||||
|| definition.typeProduct?.code
|
||||
|| fallbackType?.code
|
||||
|| null
|
||||
if (family) {
|
||||
addPart(`Famille ${family}`)
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
addPart(fallbackType?.name)
|
||||
if (fallbackType?.code) {
|
||||
addPart(`Famille ${fallbackType.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0 && definition.typeProductId) {
|
||||
addPart(`#${definition.typeProductId}`)
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' \u2022 ') : 'Produit du squelette'
|
||||
}
|
||||
157
frontend/app/shared/utils/structureDisplayUtils.ts
Normal file
157
frontend/app/shared/utils/structureDisplayUtils.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shared helpers for displaying component/machine structure skeleton details.
|
||||
*
|
||||
* Extracted from pages/component/create.vue and pages/component/[id]/edit.vue
|
||||
* where these functions were duplicated verbatim.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StructureLike = Record<string, any> | null
|
||||
|
||||
export const getStructureCustomFields = (structure: StructureLike): any[] => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
|
||||
export const getStructurePieces = (structure: StructureLike): any[] => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
export const getStructureProducts = (structure: StructureLike): any[] => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
export const getStructureSubcomponents = (structure: StructureLike): any[] => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
}
|
||||
const legacy = (structure as any)?.subComponents
|
||||
return Array.isArray(legacy) ? legacy : []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolvePieceLabel = (
|
||||
piece: Record<string, any>,
|
||||
labelMap: Record<string, string> = {},
|
||||
): string => {
|
||||
const parts: string[] = []
|
||||
if (piece.role) {
|
||||
parts.push(piece.role)
|
||||
}
|
||||
if (piece.typePiece?.name) {
|
||||
parts.push(piece.typePiece.name)
|
||||
} else if (piece.typePieceLabel) {
|
||||
parts.push(piece.typePieceLabel)
|
||||
} else if (piece.typePieceId && labelMap[piece.typePieceId]) {
|
||||
parts.push(labelMap[piece.typePieceId]!)
|
||||
} else if (piece.typePiece?.code) {
|
||||
parts.push(`Famille ${piece.typePiece.code}`)
|
||||
} else if (piece.familyCode) {
|
||||
parts.push(`Famille ${piece.familyCode}`)
|
||||
} else if (piece.typePieceId) {
|
||||
parts.push(`#${piece.typePieceId}`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||
}
|
||||
|
||||
export const resolveProductLabel = (
|
||||
product: Record<string, any>,
|
||||
labelMap: Record<string, string> = {},
|
||||
): string => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
parts.push(product.role)
|
||||
}
|
||||
if (product.typeProduct?.name) {
|
||||
parts.push(product.typeProduct.name)
|
||||
} else if (product.typeProductLabel) {
|
||||
parts.push(product.typeProductLabel)
|
||||
} else if (product.typeProductId && labelMap[product.typeProductId]) {
|
||||
parts.push(labelMap[product.typeProductId]!)
|
||||
} else if (product.typeProduct?.code) {
|
||||
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||
} else if (product.familyCode) {
|
||||
parts.push(`Catégorie ${product.familyCode}`)
|
||||
} else if (product.typeProductId) {
|
||||
parts.push(`#${product.typeProductId}`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Produit'
|
||||
}
|
||||
|
||||
export const resolveSubcomponentLabel = (node: Record<string, any>): string => {
|
||||
const parts: string[] = []
|
||||
if (node.alias) {
|
||||
parts.push(node.alias)
|
||||
}
|
||||
if (node.typeComposant?.name) {
|
||||
parts.push(node.typeComposant.name)
|
||||
} else if (node.typeComposantLabel) {
|
||||
parts.push(node.typeComposantLabel)
|
||||
} else if (node.familyCode) {
|
||||
parts.push(node.familyCode)
|
||||
} else if (node.typeComposantId) {
|
||||
parts.push(`#${node.typeComposantId}`)
|
||||
}
|
||||
|
||||
const childCount = Array.isArray(node.subcomponents)
|
||||
? node.subcomponents.length
|
||||
: Array.isArray(node.subComponents)
|
||||
? node.subComponents.length
|
||||
: 0
|
||||
if (childCount) {
|
||||
parts.push(`${childCount} sous-composant(s)`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic model type name fetcher (replaces fetchPieceTypeNames / fetchProductTypeNames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const fetchModelTypeNames = async (
|
||||
ids: string[],
|
||||
existingMap: Record<string, string>,
|
||||
get: (url: string) => Promise<{ success?: boolean; data?: any }>,
|
||||
): Promise<Record<string, string>> => {
|
||||
const missing = ids.filter((id) => id && !existingMap[id])
|
||||
if (!missing.length) {
|
||||
return {}
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const additions: Record<string, string> = {}
|
||||
results.forEach((result, index) => {
|
||||
const key = missing[index]
|
||||
if (!key || result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
additions[key] = name
|
||||
}
|
||||
})
|
||||
return additions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type label map builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildTypeLabelMap = (
|
||||
types: any[],
|
||||
fetchedOverrides: Record<string, string> = {},
|
||||
): Record<string, string> => ({
|
||||
...Object.fromEntries(
|
||||
(types || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedOverrides,
|
||||
})
|
||||
113
frontend/app/shared/utils/structureSelectionUtils.ts
Normal file
113
frontend/app/shared/utils/structureSelectionUtils.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export type SelectionEntry = {
|
||||
id: string
|
||||
path: string
|
||||
requirementLabel: string
|
||||
resolvedName: string
|
||||
quantity?: number
|
||||
slotId?: string
|
||||
_definition?: Record<string, any>
|
||||
}
|
||||
|
||||
export type StructureSelectionResult = {
|
||||
pieces: SelectionEntry[]
|
||||
products: SelectionEntry[]
|
||||
components: SelectionEntry[]
|
||||
}
|
||||
|
||||
type CatalogMap = Map<string, { name?: string, [key: string]: any }>
|
||||
|
||||
type LabelResolvers = {
|
||||
resolvePieceLabel: (definition: Record<string, any>) => string
|
||||
resolveProductLabel: (definition: Record<string, any>) => string
|
||||
resolveSubcomponentLabel: (definition: Record<string, any>) => string
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
export function collectStructureSelections(
|
||||
root: any,
|
||||
catalogs: {
|
||||
pieceCatalogMap: CatalogMap
|
||||
productCatalogMap: CatalogMap
|
||||
componentCatalogMap: CatalogMap
|
||||
},
|
||||
resolvers: LabelResolvers,
|
||||
): StructureSelectionResult {
|
||||
const piecesSelected: SelectionEntry[] = []
|
||||
const productsSelected: SelectionEntry[] = []
|
||||
const componentsSelected: SelectionEntry[] = []
|
||||
|
||||
if (!root || typeof root !== 'object') {
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
|
||||
const visitNode = (node: any, fallbackPath = 'racine') => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
|
||||
|
||||
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
|
||||
nodePieces.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedPieceId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogPiece = catalogs.pieceCatalogMap.get(selectedId)
|
||||
piecesSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
|
||||
requirementLabel: resolvers.resolvePieceLabel(definition),
|
||||
resolvedName: catalogPiece?.name || selectedId,
|
||||
quantity: typeof definition?.quantity === 'number' ? definition.quantity : undefined,
|
||||
slotId: isNonEmptyString(entry?.slotId) ? entry.slotId : undefined,
|
||||
_definition: definition,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeProducts = Array.isArray(node.products) ? node.products : []
|
||||
nodeProducts.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedProductId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogProduct = catalogs.productCatalogMap.get(selectedId)
|
||||
productsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
|
||||
requirementLabel: resolvers.resolveProductLabel(definition),
|
||||
resolvedName: catalogProduct?.name || selectedId,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeChildren = Array.isArray(node.subcomponents)
|
||||
? node.subcomponents
|
||||
: Array.isArray(node.subComponents)
|
||||
? node.subComponents
|
||||
: []
|
||||
|
||||
nodeChildren.forEach((child: any, index: number) => {
|
||||
const selectedId = child?.selectedComponentId
|
||||
if (isNonEmptyString(selectedId)) {
|
||||
const definition = child?.definition ?? child
|
||||
const catalogComponent = catalogs.componentCatalogMap.get(selectedId)
|
||||
componentsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
|
||||
requirementLabel: resolvers.resolveSubcomponentLabel(definition),
|
||||
resolvedName: catalogComponent?.name || selectedId,
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
|
||||
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
Reference in New Issue
Block a user