refacto(F1.2) : extract modules from machines/new.vue (2313→1231 LOC)
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>
This commit is contained in:
572
app/composables/useMachineCreatePreview.ts
Normal file
572
app/composables/useMachineCreatePreview.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
371
app/composables/useMachineCreateSelections.ts
Normal file
371
app/composables/useMachineCreateSelections.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Machine creation – requirement selection state management.
|
||||||
|
*
|
||||||
|
* Extracted from pages/machines/new.vue. Manages the reactive selection state
|
||||||
|
* for component / piece / product requirements when creating a new machine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface MachineCreateSelectionsDeps {
|
||||||
|
findComponentById: (id: string) => AnyRecord | null
|
||||||
|
findPieceById: (id: string) => AnyRecord | null
|
||||||
|
pieces: { value: AnyRecord[] }
|
||||||
|
get: (url: string) => Promise<AnyRecord>
|
||||||
|
toast: { showError: (msg: string) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractCollection = (payload: unknown): unknown[] => {
|
||||||
|
if (Array.isArray(payload)) return payload
|
||||||
|
if (Array.isArray((payload as AnyRecord)?.member)) return (payload as AnyRecord).member as unknown[]
|
||||||
|
if (Array.isArray((payload as AnyRecord)?.['hydra:member'])) return (payload as AnyRecord)['hydra:member'] as unknown[]
|
||||||
|
if (Array.isArray((payload as AnyRecord)?.data)) return (payload as AnyRecord).data as unknown[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||||
|
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reactive state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
|
||||||
|
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
|
||||||
|
const pieceLoadingByKey = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Piece option caching
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
|
||||||
|
`${requirement?.id || 'req'}:${entryIndex}`
|
||||||
|
|
||||||
|
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
|
||||||
|
if (!id) return null
|
||||||
|
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (!Array.isArray(bucket)) continue
|
||||||
|
const found = bucket.find((piece) => piece?.id === id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachePieceIfMissing = (piece: AnyRecord): void => {
|
||||||
|
if (!piece?.id) return
|
||||||
|
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||||
|
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
|
||||||
|
pieces.value = [...current, piece]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPieceOptions = async (
|
||||||
|
requirement: AnyRecord,
|
||||||
|
entryIndex: number,
|
||||||
|
term = '',
|
||||||
|
): Promise<void> => {
|
||||||
|
const key = getPieceKey(requirement, entryIndex)
|
||||||
|
if (pieceLoadingByKey.value[key]) return
|
||||||
|
|
||||||
|
const requirementTypeId =
|
||||||
|
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', '50')
|
||||||
|
if (term && term.trim()) params.set('name', term.trim())
|
||||||
|
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||||
|
|
||||||
|
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||||
|
try {
|
||||||
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
pieceOptionsByKey.value = {
|
||||||
|
...pieceOptionsByKey.value,
|
||||||
|
[key]: extractCollection(result.data) as AnyRecord[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry getters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
componentRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
pieceRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
productRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry factories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||||
|
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
|
||||||
|
composantId: source?.composantId || null,
|
||||||
|
definition: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||||
|
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
|
||||||
|
pieceId: source?.pieceId || null,
|
||||||
|
definition: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
|
||||||
|
typeProductId:
|
||||||
|
source?.typeProductId ||
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||||
|
null,
|
||||||
|
productId: source?.productId || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Selected piece IDs (for dedup)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const selectedPieceIds = computed(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
Object.values(pieceRequirementSelections).forEach((entries) => {
|
||||||
|
;(entries || []).forEach((entry) => {
|
||||||
|
if (entry?.pieceId) ids.push(entry.pieceId as string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
|
||||||
|
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount ?? null) as number | null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
componentRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createComponentSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
|
||||||
|
const entries = getComponentRequirementEntries(requirementId)
|
||||||
|
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
|
||||||
|
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount ?? null) as number | null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pieceRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createPieceSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
|
||||||
|
const entries = getPieceRequirementEntries(requirementId)
|
||||||
|
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProductSelectionEntry = (requirement: AnyRecord): void => {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount ?? null) as number | null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createProductSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
|
||||||
|
const entries = getProductRequirementEntries(requirementId)
|
||||||
|
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Selection setters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const setComponentRequirementComponent = (
|
||||||
|
requirement: AnyRecord,
|
||||||
|
index: number,
|
||||||
|
componentId: string,
|
||||||
|
): void => {
|
||||||
|
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||||
|
const entry = entries[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry.composantId = componentId || null
|
||||||
|
if (componentId) {
|
||||||
|
const component = findComponentById(componentId)
|
||||||
|
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
|
||||||
|
} else {
|
||||||
|
entry.typeComposantId = requirement?.typeComposantId || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPieceRequirementPiece = (
|
||||||
|
requirement: AnyRecord,
|
||||||
|
index: number,
|
||||||
|
pieceId: string,
|
||||||
|
): void => {
|
||||||
|
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||||
|
const entry = entries[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry.pieceId = pieceId || null
|
||||||
|
if (pieceId) {
|
||||||
|
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||||
|
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||||
|
if (piece) cachePieceIfMissing(piece as AnyRecord)
|
||||||
|
} else {
|
||||||
|
entry.typePieceId = requirement?.typePieceId || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProductRequirementProduct = (
|
||||||
|
requirement: AnyRecord,
|
||||||
|
index: number,
|
||||||
|
productId: string,
|
||||||
|
findProductById: (id: string) => AnyRecord | null,
|
||||||
|
): void => {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id as string)
|
||||||
|
const entry = entries[index]
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const normalizedProductId = productId || null
|
||||||
|
entry.productId = normalizedProductId
|
||||||
|
|
||||||
|
if (normalizedProductId) {
|
||||||
|
const product = findProductById(normalizedProductId)
|
||||||
|
entry.typeProductId =
|
||||||
|
product?.typeProductId ||
|
||||||
|
(product?.typeProduct as AnyRecord)?.id ||
|
||||||
|
entry.typeProductId ||
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
entry.typeProductId =
|
||||||
|
requirement?.typeProductId ||
|
||||||
|
(requirement?.typeProduct as AnyRecord)?.id ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bulk operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const clearRequirementSelections = (): void => {
|
||||||
|
Object.keys(componentRequirementSelections).forEach((key) => {
|
||||||
|
delete componentRequirementSelections[key]
|
||||||
|
})
|
||||||
|
Object.keys(pieceRequirementSelections).forEach((key) => {
|
||||||
|
delete pieceRequirementSelections[key]
|
||||||
|
})
|
||||||
|
Object.keys(productRequirementSelections).forEach((key) => {
|
||||||
|
delete productRequirementSelections[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeRequirementSelections = (type: AnyRecord): void => {
|
||||||
|
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
|
||||||
|
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
|
||||||
|
const productRequirements = (type.productRequirements || []) as AnyRecord[]
|
||||||
|
|
||||||
|
componentRequirements.forEach((requirement) => {
|
||||||
|
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||||
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
|
if (initialCount > 0) {
|
||||||
|
componentRequirementSelections[requirement.id as string] = Array.from(
|
||||||
|
{ length: initialCount },
|
||||||
|
() => createComponentSelectionEntry(requirement),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
componentRequirementSelections[requirement.id as string] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pieceRequirements.forEach((requirement) => {
|
||||||
|
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||||
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
|
if (initialCount > 0) {
|
||||||
|
pieceRequirementSelections[requirement.id as string] = Array.from(
|
||||||
|
{ length: initialCount },
|
||||||
|
() => createPieceSelectionEntry(requirement),
|
||||||
|
)
|
||||||
|
pieceRequirementSelections[requirement.id as string].forEach((_: unknown, index: number) => {
|
||||||
|
fetchPieceOptions(requirement, index).catch(() => {})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pieceRequirementSelections[requirement.id as string] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
productRequirements.forEach((requirement) => {
|
||||||
|
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||||
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
|
if (initialCount > 0) {
|
||||||
|
productRequirementSelections[requirement.id as string] = Array.from(
|
||||||
|
{ length: initialCount },
|
||||||
|
() => createProductSelectionEntry(requirement),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
productRequirementSelections[requirement.id as string] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
componentRequirementSelections,
|
||||||
|
pieceRequirementSelections,
|
||||||
|
productRequirementSelections,
|
||||||
|
pieceOptionsByKey,
|
||||||
|
pieceLoadingByKey,
|
||||||
|
selectedPieceIds,
|
||||||
|
getPieceKey,
|
||||||
|
findPieceInCachedOptions,
|
||||||
|
fetchPieceOptions,
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
addComponentSelectionEntry,
|
||||||
|
removeComponentSelectionEntry,
|
||||||
|
addPieceSelectionEntry,
|
||||||
|
removePieceSelectionEntry,
|
||||||
|
addProductSelectionEntry,
|
||||||
|
removeProductSelectionEntry,
|
||||||
|
setComponentRequirementComponent,
|
||||||
|
setPieceRequirementPiece,
|
||||||
|
setProductRequirementProduct,
|
||||||
|
clearRequirementSelections,
|
||||||
|
initializeRequirementSelections,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
220
app/shared/utils/assignmentUtils.ts
Normal file
220
app/shared/utils/assignmentUtils.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Entity assignment normalization and display utilities.
|
||||||
|
*
|
||||||
|
* Extracted from pages/machines/new.vue – these pure functions resolve
|
||||||
|
* machine / component / piece assignments from nested API payloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is AnyRecord =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
const toTrimmedString = (value: unknown): string | null => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dedup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const dedupeAssignments = (
|
||||||
|
assignments: AnyRecord[],
|
||||||
|
): AnyRecord[] => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return assignments.filter((assignment) => {
|
||||||
|
if (!assignment) return false
|
||||||
|
const id = assignment.id != null ? String(assignment.id) : ''
|
||||||
|
const name = assignment.name != null ? String(assignment.name) : ''
|
||||||
|
const key = `${id}::${name}`
|
||||||
|
if (!id && !name) return false
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine assignments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const normalizeMachineAssignment = (input: unknown): AnyRecord | null => {
|
||||||
|
if (!input) return null
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const name = toTrimmedString(input)
|
||||||
|
return name ? { id: name, name } : null
|
||||||
|
}
|
||||||
|
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||||
|
const value = String(input)
|
||||||
|
return { id: value, name: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = (input as AnyRecord).machine || (input as AnyRecord).machineData || input
|
||||||
|
if (!isPlainObject(container)) return null
|
||||||
|
|
||||||
|
const id =
|
||||||
|
container.id ?? (input as AnyRecord).machineId ?? (input as AnyRecord).id ?? null
|
||||||
|
const name =
|
||||||
|
container.name ||
|
||||||
|
(input as AnyRecord).machineName ||
|
||||||
|
container.label ||
|
||||||
|
container.title ||
|
||||||
|
(typeof id === 'string' ? id : null) ||
|
||||||
|
(typeof id === 'number' ? String(id) : null)
|
||||||
|
|
||||||
|
if (id == null && name == null) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id != null ? id : null,
|
||||||
|
name: name != null ? name : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectMachineAssignments = (source: unknown): AnyRecord[] => {
|
||||||
|
if (!isPlainObject(source)) return []
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
source.machines,
|
||||||
|
source.machineLinks,
|
||||||
|
source.machineAssignments,
|
||||||
|
source.machinesAssignments,
|
||||||
|
source.linkedMachines,
|
||||||
|
]
|
||||||
|
|
||||||
|
const assignments: AnyRecord[] = []
|
||||||
|
|
||||||
|
candidates.forEach((list) => {
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
list.forEach((item) => {
|
||||||
|
const normalized = normalizeMachineAssignment(item)
|
||||||
|
if (normalized) assignments.push(normalized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!assignments.length) {
|
||||||
|
const direct = normalizeMachineAssignment(source.machine)
|
||||||
|
if (direct) assignments.push(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignments.length) {
|
||||||
|
const idCandidate = source.machineId ?? source.machineID ?? null
|
||||||
|
const nameCandidate = source.machineName ?? null
|
||||||
|
const normalized = normalizeMachineAssignment(nameCandidate || idCandidate)
|
||||||
|
if (normalized) assignments.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeAssignments(assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component assignments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const normalizeComponentAssignment = (input: unknown): AnyRecord | null => {
|
||||||
|
if (!input) return null
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const value = toTrimmedString(input)
|
||||||
|
return value ? { id: value, name: value } : null
|
||||||
|
}
|
||||||
|
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||||
|
const value = String(input)
|
||||||
|
return { id: value, name: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
const container =
|
||||||
|
(input as AnyRecord).component || (input as AnyRecord).composant || input
|
||||||
|
if (!isPlainObject(container)) return null
|
||||||
|
|
||||||
|
const id =
|
||||||
|
container.id ??
|
||||||
|
(input as AnyRecord).componentId ??
|
||||||
|
(input as AnyRecord).composantId ??
|
||||||
|
(input as AnyRecord).id ??
|
||||||
|
null
|
||||||
|
const name =
|
||||||
|
container.name ||
|
||||||
|
(input as AnyRecord).componentName ||
|
||||||
|
(input as AnyRecord).composantName ||
|
||||||
|
container.label ||
|
||||||
|
(typeof id === 'string' ? id : null) ||
|
||||||
|
(typeof id === 'number' ? String(id) : null)
|
||||||
|
|
||||||
|
if (id == null && name == null) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id != null ? id : null,
|
||||||
|
name: name != null ? name : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectComponentAssignments = (source: unknown): AnyRecord[] => {
|
||||||
|
if (!isPlainObject(source)) return []
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
source.components,
|
||||||
|
source.composants,
|
||||||
|
source.componentLinks,
|
||||||
|
source.linkedComponents,
|
||||||
|
]
|
||||||
|
|
||||||
|
const assignments: AnyRecord[] = []
|
||||||
|
|
||||||
|
candidates.forEach((list) => {
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
list.forEach((item) => {
|
||||||
|
const normalized = normalizeComponentAssignment(item)
|
||||||
|
if (normalized) assignments.push(normalized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!assignments.length) {
|
||||||
|
const direct = normalizeComponentAssignment(source.component || source.composant)
|
||||||
|
if (direct) assignments.push(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignments.length) {
|
||||||
|
const idCandidate = source.componentId ?? source.composantId ?? null
|
||||||
|
const normalized = normalizeComponentAssignment(idCandidate)
|
||||||
|
if (normalized) assignments.push(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeAssignments(assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getComponentMachineAssignments = (component: unknown): AnyRecord[] =>
|
||||||
|
collectMachineAssignments(component || {})
|
||||||
|
|
||||||
|
export const getPieceMachineAssignments = (piece: unknown): AnyRecord[] =>
|
||||||
|
collectMachineAssignments(piece || {})
|
||||||
|
|
||||||
|
export const getPieceComponentAssignments = (piece: unknown): AnyRecord[] =>
|
||||||
|
collectComponentAssignments(piece || {})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const formatAssignmentList = (assignments: AnyRecord[]): string => {
|
||||||
|
if (!Array.isArray(assignments) || assignments.length === 0) return ''
|
||||||
|
return assignments
|
||||||
|
.map((assignment) => (assignment?.name || assignment?.id) as string)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user