/** * Machine creation – preview computation and validation. * * Extracted from pages/machines/new.vue. Builds the live preview model * and validates requirement selections before machine creation. */ import { computed, type Ref, type ComputedRef } from 'vue' import { sanitizeDefinitionOverrides } from '~/shared/modelUtils' import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils' import { getComponentMachineAssignments, getPieceMachineAssignments, getPieceComponentAssignments, formatAssignmentList, } from '~/shared/utils/assignmentUtils' type AnyRecord = Record export interface MachineCreatePreviewDeps { newMachine: { name: string; siteId: string; typeMachineId: string; reference: string } sites: Ref selectedMachineType: ComputedRef findComponentById: (id: string) => AnyRecord | null findPieceById: (id: string) => AnyRecord | null findProductById: (id: string) => AnyRecord | null getComponentRequirementEntries: (requirementId: string) => AnyRecord[] getPieceRequirementEntries: (requirementId: string) => AnyRecord[] getProductRequirementEntries: (requirementId: string) => AnyRecord[] } // --------------------------------------------------------------------------- // Product type ID extractors // --------------------------------------------------------------------------- const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => { if (!component || typeof component !== 'object') return null return ( (component.product as AnyRecord)?.typeProductId || ((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id || component.productTypeId || null ) as string | null } const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => { if (!piece || typeof piece !== 'object') return null return ( (piece.product as AnyRecord)?.typeProductId || ((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id || piece.productTypeId || null ) as string | null } // --------------------------------------------------------------------------- // Status badge helper // --------------------------------------------------------------------------- export const getStatusBadgeClass = (status: string): string => { if (status === 'ready') return 'badge-success' if (status === 'warning') return 'badge-warning' return 'badge-error' } // --------------------------------------------------------------------------- // Scroll / issue click helpers // --------------------------------------------------------------------------- const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2'] export const scrollToAnchor = (anchor: string): void => { if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return const target = document.getElementById(anchor) if (!target) return target.scrollIntoView({ behavior: 'smooth', block: 'center' }) highlightClasses.forEach((cls) => target.classList.add(cls)) window.setTimeout(() => { highlightClasses.forEach((cls) => target.classList.remove(cls)) }, 1500) } export const handleIssueClick = (issue: AnyRecord): void => { if (!issue?.anchor) return scrollToAnchor(issue.anchor as string) } // --------------------------------------------------------------------------- // Type label resolvers // --------------------------------------------------------------------------- export const resolveComponentRequirementTypeLabel = ( requirement: AnyRecord, entry: AnyRecord, findComponentById: (id: string) => AnyRecord | null, ): string => { if (entry?.composantId) { const component = findComponentById(entry.composantId as string) if ((component?.typeComposant as AnyRecord)?.name) { return (component!.typeComposant as AnyRecord).name as string } } return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini' } export const resolvePieceRequirementTypeLabel = ( requirement: AnyRecord, entry: AnyRecord, findPieceById: (id: string) => AnyRecord | null, ): string => { if (entry?.pieceId) { const piece = findPieceById(entry.pieceId as string) if ((piece?.typePiece as AnyRecord)?.name) { return (piece!.typePiece as AnyRecord).name as string } } return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini' } // --------------------------------------------------------------------------- // Product requirement stats // --------------------------------------------------------------------------- const computeProductUsageFromSelections = ( type: AnyRecord, deps: MachineCreatePreviewDeps, ): Map => { const usage = new Map() const increment = (typeProductId: string | null) => { if (!typeProductId) return usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1) } for (const requirement of (type.componentRequirements || []) as AnyRecord[]) { const entries = deps.getComponentRequirementEntries(requirement.id as string) entries.forEach((entry) => { if (!entry?.composantId) return const component = deps.findComponentById(entry.composantId as string) increment(getProductTypeIdFromComponent(component)) }) } for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) { const entries = deps.getPieceRequirementEntries(requirement.id as string) entries.forEach((entry) => { if (!entry?.pieceId) return const piece = deps.findPieceById(entry.pieceId as string) increment(getProductTypeIdFromPiece(piece)) }) } for (const requirement of (type.productRequirements || []) as AnyRecord[]) { const entries = deps.getProductRequirementEntries(requirement.id as string) entries.forEach((entry) => { if (!entry?.productId) return const product = deps.findProductById(entry.productId as string) const typeProductId = ( product?.typeProductId || (product?.typeProduct as AnyRecord)?.id || entry?.typeProductId || requirement?.typeProductId || (requirement?.typeProduct as AnyRecord)?.id || null ) as string | null increment(typeProductId) }) } return usage } const buildProductRequirementStats = ( type: AnyRecord, deps: MachineCreatePreviewDeps, ): { stats: AnyRecord[]; usage: Map } => { const usage = computeProductUsageFromSelections(type, deps) const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => { const typeProductId = ( requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null ) as string | null const label = ( (requirement.label as string)?.trim() || (requirement.typeProduct as AnyRecord)?.name || (requirement.typeProduct as AnyRecord)?.code || 'Produit requis' ) as string const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const max = (requirement.maxCount ?? null) as number | null const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0 const rawEntries = deps.getProductRequirementEntries(requirement.id as string) const normalizedEntries = rawEntries.map((entry, index) => { const product = entry?.productId ? deps.findProductById(entry.productId as string) : null const subtitleParts: string[] = [] if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`) if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) { const price = Number(product.supplierPrice) if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)} €`) } if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) { const cLabel = (product!.constructeurs as AnyRecord[]) .map((c) => c?.name) .filter(Boolean) .join(', ') if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`) } return { key: `${requirement.id}-${index}`, status: product ? 'complete' : 'pending', title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string, subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null, } }) const issues: AnyRecord[] = [] if (count < min) { issues.push({ message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`, kind: 'error', anchor: `product-group-${requirement.id}`, }) } if (max !== null && count > max) { issues.push({ message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`, kind: 'error', anchor: `product-group-${requirement.id}`, }) } if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) { issues.push({ message: 'Sélectionner un produit pour chaque entrée ajoutée.', kind: 'error', anchor: `product-group-${requirement.id}`, }) } const completed = normalizedEntries.filter((e) => e.status === 'complete').length const total = normalizedEntries.length const status = issues.some((i) => i.kind === 'error') ? 'error' : issues.some((i) => i.kind === 'warning') ? 'warning' : 'ready' return { id: requirement.id, requirement, label, typeName, count, min, max, completed, total, entries: normalizedEntries, issues, allowNewModels: requirement.allowNewModels ?? true, status, } }) return { stats, usage } } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- export const validateRequirementSelections = ( type: AnyRecord, deps: MachineCreatePreviewDeps, ): AnyRecord => { const errors: string[] = [] const componentLinksPayload: AnyRecord[] = [] const pieceLinksPayload: AnyRecord[] = [] const productLinksPayload: AnyRecord[] = [] for (const requirement of (type.componentRequirements || []) as AnyRecord[]) { const entries = deps.getComponentRequirementEntries(requirement.id as string) const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const max = (requirement.maxCount ?? null) as number | null if (entries.length < min) { errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`) } if (max !== null && entries.length > max) { errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`) } entries.forEach((entry) => { if (!entry.composantId) { errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`) return } const component = deps.findComponentById(entry.composantId as string) if (!component) { errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`) return } const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) { errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`) return } const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId } const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord) if (overrides) payload.overrides = overrides Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry)) componentLinksPayload.push(payload) }) } for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) { const entries = deps.getPieceRequirementEntries(requirement.id as string) const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const max = (requirement.maxCount ?? null) as number | null if (entries.length < min) { errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`) } if (max !== null && entries.length > max) { errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`) } entries.forEach((entry) => { if (!entry.pieceId) { errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`) return } const piece = deps.findPieceById(entry.pieceId as string) if (!piece) { errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`) return } const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) { errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`) return } const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId } const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord) if (overrides) payload.overrides = overrides Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry)) pieceLinksPayload.push(payload) }) } const { stats: productStats } = buildProductRequirementStats(type, deps) for (const requirement of (type.productRequirements || []) as AnyRecord[]) { const entries = deps.getProductRequirementEntries(requirement.id as string) const max = (requirement.maxCount ?? null) as number | null if (max !== null && entries.length > max) { errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`) } entries.forEach((entry) => { if (!entry.productId) { errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`) return } const product = deps.findProductById(entry.productId as string) if (!product) { errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`) return } const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) { errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`) return } const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId } Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry)) productLinksPayload.push(payload) }) } productStats.forEach((stat) => { ((stat.issues || []) as AnyRecord[]) .filter((issue) => issue.kind === 'error') .forEach((issue) => errors.push(issue.message as string)) }) if (errors.length > 0) return { valid: false, error: errors[0] } return { valid: true, componentLinks: componentLinksPayload, pieceLinks: pieceLinksPayload, productLinks: productLinksPayload, } } // --------------------------------------------------------------------------- // Main preview composable // --------------------------------------------------------------------------- export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) { const machinePreview = computed(() => { const type = deps.selectedMachineType.value if (!type) return null const trimmedName = (deps.newMachine.name || '').trim() const currentSite = deps.newMachine.siteId ? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null : null const trimmedReference = (deps.newMachine.reference || '').trim() const baseFields = [ { key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' }, { key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' }, { key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' }, { key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' }, ] const baseIssues: AnyRecord[] = [] if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' }) if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' }) const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready' // Component groups const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => { const entries = deps.getComponentRequirementEntries(requirement.id as string) const normalizedEntries = entries.map((entry, index) => { const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string const subtitleParts: string[] = [] if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`) const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName if (constructeurName) subtitleParts.push(constructeurName as string) const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : [] const assignmentLabel = formatAssignmentList(machineAssignments) if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`) return { key: `${requirement.id}-${index}`, status: entry.composantId ? 'complete' : 'pending', title: displayName, subtitle: subtitleParts.join(' • ') || null, assignmentLabel, assignments: machineAssignments, } }) const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const max = (requirement.maxCount ?? null) as number | null const completed = normalizedEntries.filter((e) => e.status === 'complete').length const issues: AnyRecord[] = [] if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` }) if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` }) if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` }) const hasErrors = issues.some((i) => i.kind === 'error') const hasWarnings = completed < entries.length const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready' return { id: requirement.id, label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string, typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string, min, max, entries: normalizedEntries, issues, completed, total: entries.length, status, } }) // Piece groups const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => { const entries = deps.getPieceRequirementEntries(requirement.id as string) const normalizedEntries = entries.map((entry, index) => { const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string const subtitleParts: string[] = [] if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`) const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName if (constructeurName) subtitleParts.push(constructeurName as string) const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : [] const machineAssignmentLabel = formatAssignmentList(machineAssignments) if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`) const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : [] const componentAssignmentLabel = formatAssignmentList(componentAssignments) if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`) return { key: `${requirement.id}-${index}`, status: entry.pieceId ? 'complete' : 'pending', title: displayName, subtitle: subtitleParts.join(' • ') || null, machineAssignmentLabel, componentAssignmentLabel, machineAssignments, componentAssignments, } }) const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const max = (requirement.maxCount ?? null) as number | null const completed = normalizedEntries.filter((e) => e.status === 'complete').length const issues: AnyRecord[] = [] if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` }) if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` }) if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` }) const hasErrors = issues.some((i) => i.kind === 'error') const hasWarnings = completed < entries.length const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready' return { id: requirement.id, label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string, typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string, min, max, entries: normalizedEntries, issues, completed, total: entries.length, status, } }) // Product groups const { stats: productGroups } = buildProductRequirementStats(type, deps) // Aggregate const aggregatedIssues = [ ...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })), ...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))), ...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))), ...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))), ] const statuses = [ baseStatus, ...componentGroups.map((g) => g.status), ...pieceGroups.map((g) => g.status), ...productGroups.map((g: AnyRecord) => g.status as string), ] const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready' return { base: { fields: baseFields, issues: baseIssues, status: baseStatus }, componentGroups, pieceGroups, productGroups, type: { name: type.name, category: type.category || null, hasStructuredDefinition: ((type.componentRequirements as unknown[])?.length || 0) > 0 || ((type.pieceRequirements as unknown[])?.length || 0) > 0 || ((type.productRequirements as unknown[])?.length || 0) > 0, }, status: overallStatus, ready: overallStatus === 'ready', issues: aggregatedIssues, } }) const blockingPreviewIssues = computed(() => { if (!machinePreview.value) return [] return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error') }) const canCreateMachine = computed(() => { if (!machinePreview.value) return false return blockingPreviewIssues.value.length === 0 }) return { machinePreview, blockingPreviewIssues, canCreateMachine, } }