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:
Matthieu
2026-04-01 14:17:57 +02:00
226 changed files with 56920 additions and 4 deletions

View 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 []
}

View 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(', ')
}

View 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(', ') : '' }
}

View 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
}

View 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),
}))
}

View 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')
}

View 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')
}

View 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())
}

View 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
}

View 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),
}))
}

View 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())

View 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
}

View 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
}

View 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'
}

View 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,
})

View 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 }
}