diff --git a/app/composables/useMachineHierarchy.ts b/app/composables/useMachineHierarchy.ts new file mode 100644 index 0000000..8d883d1 --- /dev/null +++ b/app/composables/useMachineHierarchy.ts @@ -0,0 +1,292 @@ +/** + * Builds a component/piece hierarchy tree from flat machine link arrays. + * + * Extracted from pages/machine/[id].vue to keep the page orchestrator lean. + */ + +import { resolveIdentifier, resolveProductReference, getProductDisplay } from '~/shared/utils/productDisplayUtils' +import { resolveConstructeurs, uniqueConstructeurIds } from '~/shared/constructeurUtils' + +type AnyRecord = Record + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const collectConstructeurs = ( + allConstructeurs: AnyRecord[], + ...sources: unknown[] +): AnyRecord[] => { + const ids = uniqueConstructeurIds(...sources) + if (!ids.length) return [] + + const pools = sources + .flatMap((source) => { + if (Array.isArray(source)) return [source] + if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]] + return [] + }) + .filter(Boolean) as AnyRecord[][] + + return resolveConstructeurs(ids, ...pools, allConstructeurs) +} + +// --------------------------------------------------------------------------- +// Link array resolution +// --------------------------------------------------------------------------- + +export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => { + if (!source || typeof source !== 'object') return null + for (const key of keys) { + const value = (source as AnyRecord)[key] + if (Array.isArray(value)) return value + } + return null +} + +// --------------------------------------------------------------------------- +// Merge trees (for incremental updates) +// --------------------------------------------------------------------------- + +export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] { + if (!existing.length) { + return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] })) + } + if (!updates.length) { + return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] })) + } + + const updateMap = new Map( + updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]), + ) + const merged = existing.map((piece) => { + const update = updateMap.get(piece.id) + if (!update) return piece + return { ...piece, ...update, customFields: update.customFields ?? piece.customFields } + }) + + updates.forEach((update) => { + if (!existing.some((piece) => piece.id === update.id)) merged.push(update) + }) + + return merged +} + +export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] { + if (!existing.length) { + return updates.map((component) => ({ + ...component, + constructeurs: component.constructeurs || [], + pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({ + ...piece, + constructeurs: piece.constructeurs || [], + })), + subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]), + })) + } + if (!updates.length) return existing + + const updateMap = new Map( + updates.map((component) => [ + component.id, + { + ...component, + constructeurs: component.constructeurs || [], + pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({ + ...piece, + constructeurs: piece.constructeurs || [], + })), + subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]), + }, + ]), + ) + const merged = existing.map((component) => { + const update = updateMap.get(component.id) + if (!update) { + return { + ...component, + constructeurs: component.constructeurs || [], + pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []), + subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []), + } + } + return { + ...component, + ...update, + customFields: update.customFields ?? component.customFields, + pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]), + subComponents: mergeComponentTrees( + (component.subComponents || []) as AnyRecord[], + (update.subComponents || []) as AnyRecord[], + ), + } + }) + + updates.forEach((update) => { + if (!existing.some((component) => component.id === update.id)) merged.push(update) + }) + + return merged +} + +// --------------------------------------------------------------------------- +// Build hierarchy from links +// --------------------------------------------------------------------------- + +export const buildMachineHierarchyFromLinks = ( + componentLinks: AnyRecord[] = [], + pieceLinks: AnyRecord[] = [], + findProductById: (id: string) => AnyRecord | null, + allConstructeurs: AnyRecord[] = [], +): { components: AnyRecord[]; machinePieces: AnyRecord[] } => { + const normalizeComponentLinkId = (link: AnyRecord) => + resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId) + + const normalizePieceLinkId = (link: AnyRecord) => + resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId) + + const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => { + if (!link || typeof link !== 'object') return null + + const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord + const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null + + const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null + + const machinePieceLinkId = normalizePieceLinkId(link) + const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId) + + const basePiece: AnyRecord = { + ...appliedPiece, + id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`, + pieceId, + name: link.overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce', + reference: link.overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null, + prix: link.overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null, + constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null, + constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null, + documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [], + typePiece: appliedPiece.typePiece || requirement?.typePiece || null, + typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null, + typeMachinePieceRequirement: requirement, + typeMachinePieceRequirementId: requirement?.id || null, + requirementId: requirement?.id || null, + overrides: link.overrides || null, + originalPiece, + machinePieceLink: link, + machinePieceLinkId, + linkId: machinePieceLinkId, + parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId), + parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId), + parentComponentName, + parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId), + parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId), + parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId), + parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId), + parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId), + definition: appliedPiece.definition || originalPiece?.definition || {}, + customFields: appliedPiece.customFields || [], + skeletonOnly: !pieceId, + } + + const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id) + const resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null + + const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId) + + return { + ...basePiece, + constructeurs, + constructeur: constructeurs[0] || basePiece.constructeur || null, + constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null, + productId: resolvedProductId || appliedPiece.productId || null, + product: resolvedProduct || appliedPiece.product || null, + __productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById), + } + } + + const createComponentNode = (link: AnyRecord): AnyRecord | null => { + if (!link || typeof link !== 'object') return null + + const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord + const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null + + const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null + + const machineComponentLinkId = normalizeComponentLinkId(link) + const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId) + + const componentName = (link.overrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string + + const pieces = Array.isArray(link.pieceLinks) + ? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[] + : [] + + const subComponents = Array.isArray(link.childLinks) + ? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[] + : [] + + const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id) + const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null + + const baseComponent: AnyRecord = { + ...appliedComponent, + id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`, + composantId, + name: componentName, + reference: link.overrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null, + prix: link.overrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null, + constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null, + constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null, + documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [], + typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null, + typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null, + typeMachineComponentRequirement: requirement, + typeMachineComponentRequirementId: requirement?.id || null, + requirementId: requirement?.id || null, + overrides: link.overrides || null, + machineComponentLinkOverrides: link.overrides || null, + definitionOverrides: link.overrides || null, + originalComposant: originalComponent, + machineComponentLink: link, + machineComponentLinkId, + componentLinkId: machineComponentLinkId, + parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId), + parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId), + parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId), + parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId), + parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId), + definition: appliedComponent.definition || originalComponent?.definition || {}, + customFields: appliedComponent.customFields || [], + pieces, + subComponents, + subcomponents: subComponents, + sousComposants: subComponents, + skeletonOnly: !composantId, + } + + const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId) + + return { + ...baseComponent, + constructeurs, + constructeur: constructeurs[0] || baseComponent.constructeur || null, + constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null, + productId: resolvedProductId || appliedComponent.productId || null, + product: resolvedProduct || appliedComponent.product || null, + __productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById), + } + } + + const rootComponents = (Array.isArray(componentLinks) ? componentLinks : []) + .filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId)) + .map(createComponentNode) + .filter(Boolean) as AnyRecord[] + + const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : []) + .filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId)) + .map((link) => createPieceNode(link, null)) + .filter(Boolean) as AnyRecord[] + + return { components: rootComponents, machinePieces } +} diff --git a/app/composables/useMachinePrint.ts b/app/composables/useMachinePrint.ts new file mode 100644 index 0000000..cd9252b --- /dev/null +++ b/app/composables/useMachinePrint.ts @@ -0,0 +1,163 @@ +/** + * Machine print selection and execution logic. + * + * Extracted from pages/machine/[id].vue. + */ + +import { ref, reactive, nextTick } from 'vue' +import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport' +import { resolveIdentifier } from '~/shared/utils/productDisplayUtils' + +type AnyRecord = Record + +export interface PrintSelection { + machine: { info: boolean; customFields: boolean; documents: boolean } + components: Record + pieces: Record +} + +export function useMachinePrint() { + const printModalOpen = ref(false) + const printSelection = reactive({ + machine: { info: true, customFields: true, documents: true }, + components: {}, + pieces: {}, + }) + + const ensurePrintSelectionEntries = ( + components: AnyRecord[], + machinePieces: AnyRecord[], + ) => { + printSelection.machine.info ??= true + printSelection.machine.customFields ??= true + printSelection.machine.documents ??= true + + const ensureComponent = (component: AnyRecord) => { + if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) { + printSelection.components[component.id as string] = true + } + ;((component.pieces || []) as AnyRecord[]).forEach((piece) => { + if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) { + printSelection.pieces[piece.id as string] = true + } + }) + ;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent) + } + + components.forEach(ensureComponent) + machinePieces.forEach((piece) => { + if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) { + printSelection.pieces[piece.id as string] = true + } + }) + } + + const setAllPrintSelection = ( + value: boolean, + components: AnyRecord[], + machinePieces: AnyRecord[], + ) => { + ensurePrintSelectionEntries(components, machinePieces) + printSelection.machine.info = value + printSelection.machine.customFields = value + printSelection.machine.documents = value + Object.keys(printSelection.components).forEach((key) => { + printSelection.components[key] = value + }) + Object.keys(printSelection.pieces).forEach((key) => { + printSelection.pieces[key] = value + }) + } + + const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => { + ensurePrintSelectionEntries(components, machinePieces) + printModalOpen.value = true + } + + const closePrintModal = () => { + printModalOpen.value = false + } + + const printMachine = ( + machine: AnyRecord, + machineName: string, + machineReference: string, + machinePieces: AnyRecord[], + components: AnyRecord[], + currentSelection: PrintSelection = printSelection, + ) => { + if (typeof window === 'undefined') return + + const context = buildMachinePrintContext({ + machine, + machineName, + machineReference, + machinePieces, + components, + selection: currentSelection, + }) + const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')) + .map((node) => node.outerHTML) + .join('') + + const htmlContent = buildMachinePrintHtml(context, styles) + + const iframe = document.createElement('iframe') + iframe.style.position = 'fixed' + iframe.style.right = '0' + iframe.style.bottom = '0' + iframe.style.width = '0' + iframe.style.height = '0' + iframe.style.border = '0' + iframe.setAttribute('title', 'print-frame') + document.body.appendChild(iframe) + + const iframeWindow = iframe.contentWindow + const iframeDocument = iframe.contentDocument || iframeWindow?.document + if (!iframeDocument || !iframeWindow) { + iframe.remove() + return + } + + iframeDocument.open() + iframeDocument.write(htmlContent) + iframeDocument.close() + + const triggerPrint = () => { + iframeWindow.focus() + iframeWindow.print() + setTimeout(() => { + iframe.remove() + }, 1000) + } + + if (iframeDocument.readyState === 'complete') { + setTimeout(triggerPrint, 50) + } else { + iframe.onload = () => setTimeout(triggerPrint, 50) + } + } + + const handlePrintConfirm = async ( + machine: AnyRecord, + machineName: string, + machineReference: string, + machinePieces: AnyRecord[], + components: AnyRecord[], + ) => { + closePrintModal() + await nextTick() + printMachine(machine, machineName, machineReference, machinePieces, components, printSelection) + } + + return { + printModalOpen, + printSelection, + ensurePrintSelectionEntries, + setAllPrintSelection, + openPrintModal, + closePrintModal, + printMachine, + handlePrintConfirm, + } +} diff --git a/app/shared/utils/customFieldUtils.ts b/app/shared/utils/customFieldUtils.ts new file mode 100644 index 0000000..7500edc --- /dev/null +++ b/app/shared/utils/customFieldUtils.ts @@ -0,0 +1,426 @@ +/** + * Custom field normalization, merging and display utilities. + * + * Extracted from pages/machine/[id].vue to be reusable across + * machine detail, component, piece and product views. + */ + +// --------------------------------------------------------------------------- +// Primitive helpers +// --------------------------------------------------------------------------- + +export const coerceValueForType = (type: string, rawValue: unknown): string => { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return '' + } + if (type === 'boolean') { + const normalized = String(rawValue).toLowerCase() + if (normalized === 'true' || normalized === '1') return 'true' + if (normalized === 'false' || normalized === '0') return 'false' + return '' + } + return String(rawValue) +} + +export const formatCustomFieldValue = (field: Record | null | undefined): string => { + if (!field) return 'Non défini' + + const value = (field.value ?? field.defaultValue ?? '') as string + if (value === '' || value === null || value === undefined) return 'Non défini' + + if (field.type === 'boolean') { + const normalized = String(value).toLowerCase() + if (normalized === 'true' || normalized === '1') return 'Oui' + if (normalized === 'false' || normalized === '0') return 'Non' + } + + return String(value) +} + +export const shouldDisplayCustomField = (field: Record | null | undefined): boolean => { + if (!field) return false + if (field.readOnly) return true + if (field.type === 'boolean') return field.value !== undefined && field.value !== null + + const value = field.value + if (value === null || value === undefined) return false + if (typeof value === 'string') return value.trim().length > 0 + return true +} + +// --------------------------------------------------------------------------- +// Definition extraction helpers +// --------------------------------------------------------------------------- + +export const extractDefinitionName = (definition: Record = {}): string => { + if (typeof definition?.name === 'string' && (definition.name as string).trim()) { + return (definition.name as string).trim() + } + if (typeof definition?.key === 'string' && (definition.key as string).trim()) { + return (definition.key as string).trim() + } + if (typeof definition?.label === 'string' && (definition.label as string).trim()) { + return (definition.label as string).trim() + } + return '' +} + +export const extractDefinitionType = ( + definition: Record = {}, + fallback = 'text', +): string => { + const allowed = ['text', 'number', 'select', 'boolean', 'date'] + const rawType = + typeof definition?.type === 'string' + ? definition.type + : typeof (definition?.value as Record)?.type === 'string' + ? (definition.value as Record).type as string + : typeof fallback === 'string' + ? fallback + : 'text' + const normalized = (rawType as string).toLowerCase() + return allowed.includes(normalized) ? normalized : 'text' +} + +export const extractDefinitionRequired = ( + definition: Record = {}, + fallback = false, +): boolean => { + if (typeof definition?.required === 'boolean') return definition.required + const nested = (definition?.value as Record)?.required + if (typeof nested === 'boolean') return nested + if (typeof nested === 'string') { + const normalized = nested.toLowerCase() + if (normalized === 'true' || normalized === '1') return true + if (normalized === 'false' || normalized === '0') return false + } + return !!fallback +} + +const extractOptionList = (input: unknown): string[] | undefined => { + if (!Array.isArray(input)) return undefined + const mapped = input + .map((option) => { + if (option === null || option === undefined) return '' + if (typeof option === 'string') return option.trim() + if (typeof option === 'object') { + const record = (option || {}) as Record + for (const key of ['value', 'label', 'name']) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim() + } + } + const fallback = String(option).trim() + return fallback === '[object Object]' ? '' : fallback + }) + .filter((option) => option.length > 0) + return mapped.length ? mapped : undefined +} + +export const extractDefinitionOptions = (definition: Record = {}): string[] => { + const sources = [ + definition?.options, + (definition?.value as Record)?.options, + (definition?.value as Record)?.choices, + ] + for (const source of sources) { + const list = extractOptionList(source) + if (list) return list + } + return [] +} + +export const extractDefinitionDefaultValue = (definition: Record = {}): unknown => { + const candidates = [ + definition?.defaultValue, + (definition?.value as Record)?.defaultValue, + (definition?.value as Record)?.value, + definition?.value, + definition?.default, + ] + for (const candidate of candidates) { + if (candidate === undefined || candidate === null || candidate === '') continue + if (typeof candidate === 'object') { + if (candidate === null) continue + const nestedDefault = + (candidate as Record).defaultValue !== undefined && + (candidate as Record).defaultValue !== null + ? (candidate as Record).defaultValue + : (candidate as Record).value + if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') { + return nestedDefault + } + continue + } + return candidate + } + return undefined +} + +// --------------------------------------------------------------------------- +// Normalization +// --------------------------------------------------------------------------- + +export interface NormalizedCustomFieldDefinition { + id?: string + customFieldId?: string + name: string + type: string + required: boolean + options: string[] + defaultValue?: unknown + readOnly: boolean + orderIndex: number +} + +export const normalizeCustomFieldDefinitionEntry = ( + definition: Record = {}, + fallbackIndex = 0, +): NormalizedCustomFieldDefinition | null => { + const name = extractDefinitionName(definition) + if (!name) return null + const type = extractDefinitionType(definition) + const required = extractDefinitionRequired(definition) + const options = extractDefinitionOptions(definition) + const defaultValue = extractDefinitionDefaultValue(definition) + const id = typeof definition?.id === 'string' ? definition.id : undefined + const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id + const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex + return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex } +} + +export const normalizeExistingCustomFieldDefinitions = ( + fields: unknown, +): NormalizedCustomFieldDefinition[] => { + if (!Array.isArray(fields)) return [] + return fields + .map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record, index)) + .filter((d): d is NormalizedCustomFieldDefinition => d !== null) + .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) +} + +// --------------------------------------------------------------------------- +// Custom field value normalization +// --------------------------------------------------------------------------- + +export const normalizeCustomFieldValueEntry = (entry: Record = {}): Record | null => { + if (!entry || typeof entry !== 'object') return null + const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry) + if (!normalizedDefinition) return null + + const value = coerceValueForType( + normalizedDefinition.type, + (entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string, + ) + + return { + id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null, + customFieldId: + (entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null, + customField: { + id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null, + name: normalizedDefinition.name, + type: normalizedDefinition.type, + required: normalizedDefinition.required, + options: normalizedDefinition.options, + defaultValue: normalizedDefinition.defaultValue ?? '', + }, + value, + } +} + +// --------------------------------------------------------------------------- +// Merge & dedup +// --------------------------------------------------------------------------- + +export const mergeCustomFieldValuesWithDefinitions = ( + valueEntries: Record[] = [], + ...definitionSources: unknown[][] +): Record[] => { + const normalizedValues = (Array.isArray(valueEntries) ? valueEntries : []) + .map((entry) => { + if (!entry || typeof entry !== 'object') return null + const normalizedDefinition = normalizeCustomFieldDefinitionEntry( + ((entry as Record).customField || entry) as Record, + ) + if (!normalizedDefinition) return null + + const value = coerceValueForType( + normalizedDefinition.type, + ((entry as Record)?.value ?? + (entry as Record)?.defaultValue ?? + normalizedDefinition.defaultValue ?? + '') as string, + ) + + return { + customFieldValueId: (entry as Record)?.id ?? (entry as Record)?.customFieldValueId ?? null, + id: normalizedDefinition.id, + customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null, + name: normalizedDefinition.name, + type: normalizedDefinition.type, + required: normalizedDefinition.required, + options: normalizedDefinition.options, + optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '', + defaultValue: normalizedDefinition.defaultValue ?? '', + value, + readOnly: !!(entry as Record)?.readOnly, + } + }) + .filter((entry): entry is Record => entry !== null) + + const result = [...normalizedValues] + const keyFor = (item: Record) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}` + const existingMap = new Map>() + + result.forEach((item) => { + const key = keyFor(item) + if (key) existingMap.set(key, item) + const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null + if (fallbackKey) existingMap.set(fallbackKey, item) + }) + + const definitions = definitionSources + .flatMap((source) => (Array.isArray(source) ? source : [])) + .map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record)) + .filter((d): d is NormalizedCustomFieldDefinition => d !== null) + + definitions.forEach((normalizedDefinition) => { + const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}` + if (!key) return + + if (normalizedDefinition.id) { + const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}` + if (existingMap.has(fallbackKey)) { + const existingFallback = existingMap.get(fallbackKey) + if (existingFallback) { + existingFallback.id = existingFallback.id || normalizedDefinition.id + existingFallback.customFieldId = normalizedDefinition.id + existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly + existingMap.delete(fallbackKey) + existingMap.set(normalizedDefinition.id, existingFallback) + existingMap.set(fallbackKey, existingFallback) + return + } + } + } + + const existing = + existingMap.get(key) || + (normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null) + + if (existing) { + existing.name = existing.name || normalizedDefinition.name + existing.type = existing.type || normalizedDefinition.type + existing.required = (existing.required as boolean) || normalizedDefinition.required + if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) { + existing.options = normalizedDefinition.options + } + if (!existing.defaultValue && normalizedDefinition.defaultValue) { + existing.defaultValue = String(normalizedDefinition.defaultValue) + if (!existing.value) { + existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue) + } + } + existing.customFieldId = existing.customFieldId || normalizedDefinition.id + existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly + if (!existing.optionsText && normalizedDefinition.options?.length) { + existing.optionsText = normalizedDefinition.options.join('\n') + } + if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing) + if (normalizedDefinition.name) { + existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing) + } + return + } + + const entry: Record = { + customFieldValueId: null, + id: normalizedDefinition.id, + customFieldId: normalizedDefinition.id, + name: normalizedDefinition.name, + type: normalizedDefinition.type, + required: normalizedDefinition.required, + options: normalizedDefinition.options, + optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '', + defaultValue: normalizedDefinition.defaultValue ?? '', + value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string), + readOnly: false, + } + result.push(entry) + existingMap.set(key, entry) + const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null + if (fallbackKey) existingMap.set(fallbackKey, entry) + }) + + return result +} + +export const dedupeCustomFieldEntries = (fields: Record[]): Record[] => { + if (!Array.isArray(fields) || fields.length <= 1) { + return Array.isArray(fields) ? fields : [] + } + + const seen = new Set() + const result: Record[] = [] + + for (const field of fields) { + if (!field) continue + + field.type = field.type || 'text' + + let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : '' + + if (!normalizedName && (field.customField as Record)?.name) { + normalizedName = String((field.customField as Record).name).trim() + field.name = normalizedName + } else if (typeof field.name === 'string') { + field.name = normalizedName + } + + const key = + (field.customFieldId as string) || + (field.id as string) || + (normalizedName ? `${normalizedName}::${field.type || 'text'}` : null) + + if (!key && !normalizedName) continue + if (key && seen.has(key)) continue + if (!normalizedName) continue + + if (key) seen.add(key) + if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`) + result.push(field) + } + + return result +} + +// --------------------------------------------------------------------------- +// Summarize for display +// --------------------------------------------------------------------------- + +export const summarizeCustomFields = ( + fields: Record[] = [], +): { key: string; label: string; value: string }[] => { + const seen = new Set() + return fields + .slice() + .sort((a, b) => { + const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0 + const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0 + return (left as number) - (right as number) + }) + .filter(shouldDisplayCustomField) + .filter((field) => { + const key = (field.customFieldId || field.id || field.name) as string + if (!key) return true + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((field, index) => ({ + key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`, + label: (field.name as string) || 'Champ', + value: formatCustomFieldValue(field), + })) +} diff --git a/app/shared/utils/productDisplayUtils.ts b/app/shared/utils/productDisplayUtils.ts new file mode 100644 index 0000000..433e61e --- /dev/null +++ b/app/shared/utils/productDisplayUtils.ts @@ -0,0 +1,353 @@ +/** + * 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 + +// --------------------------------------------------------------------------- +// 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.productSkeleton, + (source.definition as AnyRecord)?.products, + (source.definition as AnyRecord)?.productSkeleton, + ((source.definition as AnyRecord)?.structure as AnyRecord)?.products, + ((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + (source.structure as AnyRecord)?.products, + (source.structure as AnyRecord)?.productSkeleton, + (source.requirement as AnyRecord)?.products, + (source.requirement as AnyRecord)?.productSkeleton, + ((source.requirement as AnyRecord)?.structure as AnyRecord)?.products, + ((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + ((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products, + (source.typeMachineComponentRequirement as AnyRecord)?.products, + (source.typeMachineComponentRequirement as AnyRecord)?.productSkeleton, + ((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.products, + ((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + ((source.typeMachineComponentRequirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products, + (source.typeComposant as AnyRecord)?.products, + (source.typeComposant as AnyRecord)?.productSkeleton, + ((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products, + ((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + (source.originalComposant as AnyRecord)?.products, + (source.originalComposant as AnyRecord)?.productSkeleton, + ((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products, + ((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.productSkeleton, + (((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products, + (((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + (source.originalComponent as AnyRecord)?.products, + (source.originalComponent as AnyRecord)?.productSkeleton, + ((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.products, + ((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.productSkeleton, + (((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products, + (((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton, + ] + + 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 +}