Extract assignment normalization utils to shared/utils/assignmentUtils.ts. Extract selection state management to composables/useMachineCreateSelections.ts. Extract preview computation and validation to composables/useMachineCreatePreview.ts. Wire machines/new.vue to use extracted modules (-47% LOC). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
573 lines
25 KiB
TypeScript
573 lines
25 KiB
TypeScript
/**
|
||
* 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<string, unknown>
|
||
|
||
export interface MachineCreatePreviewDeps {
|
||
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
|
||
sites: Ref<AnyRecord[]>
|
||
selectedMachineType: ComputedRef<AnyRecord | null>
|
||
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<string, number> => {
|
||
const usage = new Map<string, number>()
|
||
|
||
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<string, number> } => {
|
||
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,
|
||
}
|
||
}
|