diff --git a/app/composables/useMachineCreatePreview.ts b/app/composables/useMachineCreatePreview.ts new file mode 100644 index 0000000..161e02c --- /dev/null +++ b/app/composables/useMachineCreatePreview.ts @@ -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 + +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, + } +} diff --git a/app/composables/useMachineCreateSelections.ts b/app/composables/useMachineCreateSelections.ts new file mode 100644 index 0000000..f1dcc6b --- /dev/null +++ b/app/composables/useMachineCreateSelections.ts @@ -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 + +export interface MachineCreateSelectionsDeps { + findComponentById: (id: string) => AnyRecord | null + findPieceById: (id: string) => AnyRecord | null + pieces: { value: AnyRecord[] } + get: (url: string) => Promise + 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>({}) + const pieceRequirementSelections = reactive>({}) + const productRequirementSelections = reactive>({}) + + const pieceOptionsByKey = ref>({}) + const pieceLoadingByKey = ref>({}) + + // --------------------------------------------------------------------------- + // 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 => { + 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, + } +} diff --git a/app/pages/machines/new.vue b/app/pages/machines/new.vue index 7fbe50c..557ea2b 100644 --- a/app/pages/machines/new.vue +++ b/app/pages/machines/new.vue @@ -203,7 +203,7 @@ Fournisseur : {{ findComponentById(entry.composantId)?.constructeur?.name || findComponentById(entry.composantId)?.constructeurName || "—" }} - + @@ -306,7 +306,7 @@ Fournisseur : {{ findPieceById(entry.pieceId)?.constructeur?.name || findPieceById(entry.pieceId)?.constructeurName || "—" }} - + @@ -746,7 +746,21 @@ import { usePieces } from '~/composables/usePieces' import { useProducts } from '~/composables/useProducts' import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' -import { sanitizeDefinitionOverrides } from '~/shared/modelUtils' +import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections' +import { + useMachineCreatePreview, + validateRequirementSelections as _validateRequirementSelections, + getStatusBadgeClass, + handleIssueClick, + resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel, + resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel, +} from '~/composables/useMachineCreatePreview' +import { + getComponentMachineAssignments, + getPieceMachineAssignments, + getPieceComponentAssignments, + formatAssignmentList, +} from '~/shared/utils/assignmentUtils' import SearchSelect from '~/components/common/SearchSelect.vue' import ProductSelect from '~/components/ProductSelect.vue' import IconLucidePlus from '~icons/lucide/plus' @@ -756,6 +770,10 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle' import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2' import IconLucideCircle from '~icons/lucide/circle' +// --------------------------------------------------------------------------- +// Composable calls +// --------------------------------------------------------------------------- + const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines() const { sites, loadSites } = useSites() const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi() @@ -765,6 +783,10 @@ const { products, loadProducts, loading: productsLoading } = useProducts() const { get } = useApi() const toast = useToast() +// --------------------------------------------------------------------------- +// Local state +// --------------------------------------------------------------------------- + const submitting = ref(false) const newMachine = reactive({ @@ -774,10 +796,6 @@ const newMachine = reactive({ reference: '' }) -const componentRequirementSelections = reactive({}) -const pieceRequirementSelections = reactive({}) -const productRequirementSelections = reactive({}) - const selectedMachineType = computed(() => { if (!newMachine.typeMachineId) { return null @@ -785,31 +803,9 @@ const selectedMachineType = computed(() => { return machineTypes.value.find(type => type.id === newMachine.typeMachineId) || null }) -const machineTypeLabel = (type) => { - if (!type) { - return '' - } - return type.name || 'Type de machine' -} - -const machineTypeDescription = (type) => { - if (!type) { - return '' - } - const parts = [] - if (type.category) { - parts.push(`Catégorie : ${type.category}`) - } - const componentCount = type.componentRequirements?.length ?? 0 - const pieceCount = type.pieceRequirements?.length ?? 0 - const productCount = type.productRequirements?.length ?? 0 - parts.push( - `${componentCount} composant(s)`, - `${pieceCount} pièce(s)`, - `${productCount} produit(s)` - ) - return parts.join(' • ') -} +// --------------------------------------------------------------------------- +// Entity lookup maps +// --------------------------------------------------------------------------- const componentById = computed(() => { const map = new Map() @@ -845,305 +841,139 @@ const productById = computed(() => { return map }) -const pieceOptionsByKey = ref({}) -const pieceLoadingByKey = ref({}) +// --------------------------------------------------------------------------- +// Entity finders +// --------------------------------------------------------------------------- -const extractCollection = (payload) => { - if (Array.isArray(payload)) { - return payload - } - if (Array.isArray(payload?.member)) { - return payload.member - } - if (Array.isArray(payload?.['hydra:member'])) { - return payload['hydra:member'] - } - if (Array.isArray(payload?.data)) { - return payload.data - } - return [] -} - -const getPieceKey = (requirement, entryIndex) => `${requirement?.id || 'req'}:${entryIndex}` - -const findPieceInCachedOptions = (id) => { +const findComponentById = (id) => { 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 + return componentById.value.get(id) || null } -const cachePieceIfMissing = (piece) => { - if (!piece?.id) { - return - } - if (pieceById.value.has(piece.id)) { - return - } - const current = Array.isArray(pieces.value) ? pieces.value : [] - pieces.value = [...current, piece] -} - -const fetchPieceOptions = async (requirement, entryIndex, term = '') => { - const key = getPieceKey(requirement, entryIndex) - if (pieceLoadingByKey.value[key]) { - return - } - - const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || 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) - } - } - } finally { - pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false } - } -} - -const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) - -const toTrimmedString = (value) => { - 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 -} - -const dedupeAssignments = (assignments) => { - const seen = new Set() - 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 - }) -} - -const normalizeMachineAssignment = (input) => { - if (!input) { +const findPieceById = (id) => { + if (!id) { 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.machine || input.machineData || input - if (!isPlainObject(container)) { - return null - } - - const id = container.id ?? input.machineId ?? input.id ?? null - const name = - container.name - || input.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, - } + return pieceById.value.get(id) || findPieceInCachedOptions(id) || null } -const collectMachineAssignments = (source) => { - if (!isPlainObject(source)) { - return [] +const findProductById = (id) => { + if (!id) { + return null } + return productById.value.get(id) || null +} - const candidates = [ - source.machines, - source.machineLinks, - source.machineAssignments, - source.machinesAssignments, - source.linkedMachines, - ] +// --------------------------------------------------------------------------- +// Selection state (from composable) +// --------------------------------------------------------------------------- - const assignments = [] +const { + pieceOptionsByKey, + pieceLoadingByKey, + selectedPieceIds, + getPieceKey, + findPieceInCachedOptions, + fetchPieceOptions, + getComponentRequirementEntries, + getPieceRequirementEntries, + getProductRequirementEntries, + addComponentSelectionEntry, + removeComponentSelectionEntry, + addPieceSelectionEntry, + removePieceSelectionEntry, + addProductSelectionEntry, + removeProductSelectionEntry, + setComponentRequirementComponent, + setPieceRequirementPiece, + setProductRequirementProduct: _setProductRequirementProduct, + clearRequirementSelections, + initializeRequirementSelections, +} = useMachineCreateSelections({ + findComponentById, + findPieceById, + pieces, + get, + toast, +}) - candidates.forEach((list) => { - if (Array.isArray(list)) { - list.forEach((item) => { - const normalized = normalizeMachineAssignment(item) - if (normalized) { - assignments.push(normalized) - } - }) - } +// --------------------------------------------------------------------------- +// Preview / validation (from composable) +// --------------------------------------------------------------------------- + +const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({ + newMachine, + sites, + selectedMachineType, + findComponentById, + findPieceById, + findProductById, + getComponentRequirementEntries, + getPieceRequirementEntries, + getProductRequirementEntries, +}) + +// --------------------------------------------------------------------------- +// Template wrappers (bridge 2-arg template calls to 3-arg extracted functions) +// --------------------------------------------------------------------------- + +const resolveComponentRequirementTypeLabel = (requirement, entry) => + _resolveComponentRequirementTypeLabel(requirement, entry, findComponentById) + +const resolvePieceRequirementTypeLabel = (requirement, entry) => + _resolvePieceRequirementTypeLabel(requirement, entry, findPieceById) + +const setProductRequirementProduct = (requirement, index, productId) => + _setProductRequirementProduct(requirement, index, productId, findProductById) + +const validateRequirementSelections = (type) => + _validateRequirementSelections(type, { + newMachine, + sites, + selectedMachineType, + findComponentById, + findPieceById, + findProductById, + getComponentRequirementEntries, + getPieceRequirementEntries, + getProductRequirementEntries, }) - if (!assignments.length) { - const direct = normalizeMachineAssignment(source.machine) - if (direct) { - assignments.push(direct) - } - } +// --------------------------------------------------------------------------- +// Machine type helpers +// --------------------------------------------------------------------------- - 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) -} - -const normalizeComponentAssignment = (input) => { - 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.component || input.composant || input - if (!isPlainObject(container)) { - return null - } - - const id = container.id ?? input.componentId ?? input.composantId ?? input.id ?? null - const name = - container.name - || input.componentName - || input.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, - } -} - -const collectComponentAssignments = (source) => { - if (!isPlainObject(source)) { - return [] - } - - const candidates = [ - source.components, - source.composants, - source.componentLinks, - source.linkedComponents, - ] - - const assignments = [] - - 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) -} - -const getComponentMachineAssignments = component => collectMachineAssignments(component || {}) -const getPieceMachineAssignments = piece => collectMachineAssignments(piece || {}) -const getPieceComponentAssignments = piece => collectComponentAssignments(piece || {}) - -const formatAssignmentList = (assignments) => { - if (!Array.isArray(assignments) || assignments.length === 0) { +const machineTypeLabel = (type) => { + if (!type) { return '' } - return assignments - .map((assignment) => assignment?.name || assignment?.id) - .filter(Boolean) - .join(', ') + return type.name || 'Type de machine' } -const selectedPieceIds = computed(() => { - const ids = [] - Object.values(pieceRequirementSelections).forEach((entries) => { - ;(entries || []).forEach((entry) => { - if (entry?.pieceId) { - ids.push(entry.pieceId) - } - }) - }) - return ids -}) +const machineTypeDescription = (type) => { + if (!type) { + return '' + } + const parts = [] + if (type.category) { + parts.push(`Catégorie : ${type.category}`) + } + const componentCount = type.componentRequirements?.length ?? 0 + const pieceCount = type.pieceRequirements?.length ?? 0 + const productCount = type.productRequirements?.length ?? 0 + parts.push( + `${componentCount} composant(s)`, + `${pieceCount} pièce(s)`, + `${productCount} produit(s)` + ) + return parts.join(' • ') +} + +// --------------------------------------------------------------------------- +// Option filters +// --------------------------------------------------------------------------- const getComponentOptions = (requirement, currentEntry) => { const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null @@ -1184,6 +1014,27 @@ const getPieceOptions = (requirement, currentEntry, entryIndex) => { }) } +const getProductOptions = (requirement) => { + const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null + return productInventory.value.filter((product) => { + if (!product?.id) { + return false + } + if (!requirementTypeId) { + return true + } + const productTypeId = + product.typeProductId || + product.typeProduct?.id || + null + return productTypeId === requirementTypeId + }) +} + +// --------------------------------------------------------------------------- +// Option label / description helpers +// --------------------------------------------------------------------------- + const componentOptionLabel = (component) => component?.name || 'Composant' const componentOptionDescription = (component) => { @@ -1240,23 +1091,6 @@ const pieceOptionDescription = (piece) => { return parts.join(' • ') } -const getProductOptions = (requirement) => { - const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null - return productInventory.value.filter((product) => { - if (!product?.id) { - return false - } - if (!requirementTypeId) { - return true - } - const productTypeId = - product.typeProductId || - product.typeProduct?.id || - null - return productTypeId === requirementTypeId - }) -} - const productOptionLabel = (product) => product?.name || product?.reference || 'Produit' const productOptionDescription = (product) => { @@ -1285,929 +1119,9 @@ const productOptionDescription = (product) => { return parts.join(' • ') } -const getProductTypeIdFromComponent = (component) => { - if (!component || typeof component !== 'object') { - return null - } - return ( - component.product?.typeProductId || - component.product?.typeProduct?.id || - component.productTypeId || - null - ) -} - -const getProductTypeIdFromPiece = (piece) => { - if (!piece || typeof piece !== 'object') { - return null - } - return ( - piece.product?.typeProductId || - piece.product?.typeProduct?.id || - piece.productTypeId || - null - ) -} - -const setComponentRequirementComponent = (requirement, index, componentId) => { - const entries = getComponentRequirementEntries(requirement.id) - 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, index, pieceId) => { - const entries = getPieceRequirementEntries(requirement.id) - 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) - } - } else { - entry.typePieceId = requirement?.typePieceId || null - } -} - -const findComponentById = (id) => { - if (!id) { - return null - } - return componentById.value.get(id) || null -} - -const findPieceById = (id) => { - if (!id) { - return null - } - return pieceById.value.get(id) || findPieceInCachedOptions(id) || null -} - -const findProductById = (id) => { - if (!id) { - return null - } - return productById.value.get(id) || null -} -const getStatusBadgeClass = (status) => { - if (status === 'ready') { - return 'badge-success' - } - if (status === 'warning') { - return 'badge-warning' - } - return 'badge-error' -} - -const resolveComponentRequirementTypeLabel = (requirement, entry) => { - if (entry?.composantId) { - const component = findComponentById(entry.composantId) - if (component?.typeComposant?.name) { - return component.typeComposant.name - } - } - return requirement?.typeComposant?.name || 'Type non défini' -} - -const resolvePieceRequirementTypeLabel = (requirement, entry) => { - if (entry?.pieceId) { - const piece = findPieceById(entry.pieceId) - if (piece?.typePiece?.name) { - return piece.typePiece.name - } - } - return requirement?.typePiece?.name || 'Type non défini' -} - -const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || [] -const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || [] -const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || [] - -const createComponentSelectionEntry = (requirement, source = null) => ({ - typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null, - composantId: source?.composantId || null, - definition: {}, -}) - -const createPieceSelectionEntry = (requirement, source = null) => ({ - typePieceId: requirement?.typePieceId || requirement?.typePiece?.id || null, - pieceId: source?.pieceId || null, - definition: {}, -}) - -const createProductSelectionEntry = (requirement, source = null) => ({ - typeProductId: - source?.typeProductId || - requirement?.typeProductId || - requirement?.typeProduct?.id || - null, - productId: source?.productId || null, -}) - -const computeProductUsageFromSelections = (type) => { - const usage = new Map() - - const increment = (typeProductId) => { - if (!typeProductId) { - return - } - usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1) - } - - for (const requirement of type.componentRequirements || []) { - const entries = getComponentRequirementEntries(requirement.id) - entries.forEach((entry) => { - if (!entry?.composantId) { - return - } - const component = findComponentById(entry.composantId) - const typeProductId = getProductTypeIdFromComponent(component) - increment(typeProductId) - }) - } - - for (const requirement of type.pieceRequirements || []) { - const entries = getPieceRequirementEntries(requirement.id) - entries.forEach((entry) => { - if (!entry?.pieceId) { - return - } - const piece = findPieceById(entry.pieceId) - const typeProductId = getProductTypeIdFromPiece(piece) - increment(typeProductId) - }) - } - - for (const requirement of type.productRequirements || []) { - const entries = getProductRequirementEntries(requirement.id) - entries.forEach((entry) => { - if (!entry?.productId) { - return - } - const product = findProductById(entry.productId) - const typeProductId = - product?.typeProductId || - product?.typeProduct?.id || - entry?.typeProductId || - requirement?.typeProductId || - requirement?.typeProduct?.id || - null - increment(typeProductId) - }) - } - - return usage -} - -const buildProductRequirementStats = (type) => { - const usage = computeProductUsageFromSelections(type) - - const stats = (type.productRequirements || []).map((requirement) => { - const typeProductId = - requirement.typeProductId || - requirement.typeProduct?.id || - null - - const label = - requirement.label?.trim() || - requirement.typeProduct?.name || - requirement.typeProduct?.code || - 'Produit requis' - - const typeName = requirement.typeProduct?.name || 'Non défini' - - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const max = requirement.maxCount ?? null - const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0 - const rawEntries = getProductRequirementEntries(requirement.id) - const normalizedEntries = rawEntries.map((entry, index) => { - const product = entry?.productId ? findProductById(entry.productId) : null - const subtitleParts = [] - 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.length) { - const label = product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ') - if (label) { - subtitleParts.push(`Fournisseurs: ${label}`) - } - } - return { - key: `${requirement.id}-${index}`, - status: product ? 'complete' : 'pending', - title: product?.name || product?.reference || `Sélection #${index + 1}`, - subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null, - } - }) - - const issues = [] - 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((entry) => entry.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((entry) => entry.status === 'complete').length - const total = normalizedEntries.length - - const status = issues.some((issue) => issue.kind === 'error') - ? 'error' - : issues.some((issue) => issue.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 } -} - -const clearRequirementSelections = () => { - 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 addComponentSelectionEntry = (requirement) => { - const entries = getComponentRequirementEntries(requirement.id) - const max = requirement.maxCount ?? null - if (max !== null && entries.length >= max) { - toast.showError(`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || requirement.typeComposant?.name || 'ce groupe'}`) - return - } - componentRequirementSelections[requirement.id] = [ - ...entries, - createComponentSelectionEntry(requirement), - ] -} - -const removeComponentSelectionEntry = (requirementId, index) => { - const entries = getComponentRequirementEntries(requirementId) - componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index) -} - -const addPieceSelectionEntry = (requirement) => { - const entries = getPieceRequirementEntries(requirement.id) - const max = requirement.maxCount ?? 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?.name || 'ce groupe'}`) - return - } - pieceRequirementSelections[requirement.id] = [ - ...entries, - createPieceSelectionEntry(requirement), - ] - fetchPieceOptions(requirement, entries.length).catch(() => {}) -} - -const removePieceSelectionEntry = (requirementId, index) => { - const entries = getPieceRequirementEntries(requirementId) - pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index) -} - -const addProductSelectionEntry = (requirement) => { - const entries = getProductRequirementEntries(requirement.id) - const max = requirement.maxCount ?? null - if (max !== null && entries.length >= max) { - toast.showError(`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`) - return - } - productRequirementSelections[requirement.id] = [ - ...entries, - createProductSelectionEntry(requirement), - ] -} - -const removeProductSelectionEntry = (requirementId, index) => { - const entries = getProductRequirementEntries(requirementId) - productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index) -} - -const setProductRequirementProduct = (requirement, index, productId) => { - const entries = getProductRequirementEntries(requirement.id) - 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?.id || - entry.typeProductId || - requirement?.typeProductId || - requirement?.typeProduct?.id || - null - } else { - entry.typeProductId = - requirement?.typeProductId || - requirement?.typeProduct?.id || - null - } -} - -const extractParentIdentifiers = (source) => { - if (!isPlainObject(source)) { - return {} - } - - const identifiers = {} - - const idKeys = [ - 'parentRequirementId', - 'parentComponentRequirementId', - 'parentPieceRequirementId', - 'parentMachineComponentRequirementId', - 'parentMachinePieceRequirementId', - 'parentLinkId', - 'parentComponentId', - 'parentPieceId', - ] - - idKeys.forEach((key) => { - if (Object.prototype.hasOwnProperty.call(source, key)) { - const value = source[key] - if (value !== undefined && value !== null && value !== '') { - identifiers[key] = value - } - } - }) - - const objectKeys = [ - 'parentRequirement', - 'parentComponentRequirement', - 'parentPieceRequirement', - 'parentMachineComponentRequirement', - 'parentMachinePieceRequirement', - ] - - objectKeys.forEach((key) => { - const value = source[key] - if (isPlainObject(value) && value.id !== undefined && value.id !== null && value.id !== '') { - const idKey = `${key}Id` - if (!Object.prototype.hasOwnProperty.call(identifiers, idKey)) { - identifiers[idKey] = value.id - } - } - }) - - return identifiers -} - -const validateRequirementSelections = (type) => { - const errors = [] - const componentLinksPayload = [] - const pieceLinksPayload = [] - const productLinksPayload = [] - - for (const requirement of type.componentRequirements || []) { - const entries = getComponentRequirementEntries(requirement.id) - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const max = requirement.maxCount ?? null - - if (entries.length < min) { - errors.push(`Le groupe "${requirement.label || requirement.typeComposant?.name || 'Composants'}" nécessite au moins ${min} élément(s).`) - } - - if (max !== null && entries.length > max) { - errors.push(`Le groupe "${requirement.label || requirement.typeComposant?.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?.name || 'Composants'}".`) - return - } - - const component = findComponentById(entry.composantId) - if (!component) { - errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`) - return - } - - const requiredTypeId = requirement.typeComposantId || requirement.typeComposant?.id || 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 = { - requirementId: requirement.id, - composantId: entry.composantId, - } - - const overrides = sanitizeDefinitionOverrides(entry.definition) - if (overrides) { - payload.overrides = overrides - } - - Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry)) - - componentLinksPayload.push(payload) - }) - } - - for (const requirement of type.pieceRequirements || []) { - const entries = getPieceRequirementEntries(requirement.id) - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const max = requirement.maxCount ?? null - - if (entries.length < min) { - errors.push(`Le groupe "${requirement.label || requirement.typePiece?.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?.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?.name || 'Pièces'}".`) - return - } - - const piece = findPieceById(entry.pieceId) - if (!piece) { - errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`) - return - } - - const requiredTypeId = requirement.typePieceId || requirement.typePiece?.id || 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 = { - requirementId: requirement.id, - pieceId: entry.pieceId, - } - - const overrides = sanitizeDefinitionOverrides(entry.definition) - if (overrides) { - payload.overrides = overrides - } - - Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry)) - - pieceLinksPayload.push(payload) - }) - } - - const { stats: productStats } = buildProductRequirementStats(type) - for (const requirement of type.productRequirements || []) { - const entries = getProductRequirementEntries(requirement.id) - const max = requirement.maxCount ?? null - - if (max !== null && entries.length > max) { - errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.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?.name || 'Produits'}".`) - return - } - - const product = findProductById(entry.productId) - if (!product) { - errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`) - return - } - - const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null - const productTypeId = - product.typeProductId || - product.typeProduct?.id || - entry.typeProductId || - 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 = { - requirementId: requirement.id, - productId: entry.productId, - } - - Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry)) - - productLinksPayload.push(payload) - }) - } - - productStats.forEach((stat) => { - stat.issues - .filter((issue) => issue.kind === 'error') - .forEach((issue) => { - errors.push(issue.message) - }) - }) - - if (errors.length > 0) { - return { valid: false, error: errors[0] } - } - - return { - valid: true, - componentLinks: componentLinksPayload, - pieceLinks: pieceLinksPayload, - productLinks: productLinksPayload, - } -} - -const machinePreview = computed(() => { - const type = selectedMachineType.value - if (!type) { - return null - } - - const trimmedName = (newMachine.name || '').trim() - const currentSite = newMachine.siteId - ? sites.value.find(site => site.id === newMachine.siteId) || null - : null - const trimmedReference = (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', - status: currentSite ? 'complete' : 'missing' - }, - { - key: 'type', - label: 'Type sélectionné', - display: type.name, - status: 'complete' - }, - { - key: 'reference', - label: 'Référence', - display: trimmedReference || 'Non renseignée', - status: trimmedReference ? 'complete' : 'optional' - } - ] - - const baseIssues = [] - 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' - - const componentGroups = (type.componentRequirements || []).map((requirement) => { - const entries = getComponentRequirementEntries(requirement.id) - const normalizedEntries = entries.map((entry, index) => { - const selectedComponent = entry.composantId - ? findComponentById(entry.composantId) - : null - const displayName = selectedComponent?.name - || requirement.typeComposant?.name - || 'Composant' - - const subtitleParts = [] - if (selectedComponent?.reference) { - subtitleParts.push(`Réf. ${selectedComponent.reference}`) - } - const constructeurName = selectedComponent?.constructeur?.name || selectedComponent?.constructeurName - if (constructeurName) { - subtitleParts.push(constructeurName) - } - const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : [] - const assignmentLabel = formatAssignmentList(machineAssignments) - if (assignmentLabel) { - subtitleParts.push(`Liée à ${assignmentLabel}`) - } - const status = entry.composantId ? 'complete' : 'pending' - - return { - key: `${requirement.id}-${index}`, - status, - title: displayName, - subtitle: subtitleParts.join(' • ') || null, - assignmentLabel, - assignments: machineAssignments, - } - }) - - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const max = requirement.maxCount ?? null - const completed = normalizedEntries.filter(entry => entry.status === 'complete').length - const issues = [] - - 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(entry => entry.status !== 'complete')) { - issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` }) - } - - const hasErrors = issues.some(issue => issue.kind === 'error') - const hasWarnings = completed < entries.length - - const status = hasErrors - ? 'error' - : hasWarnings - ? 'warning' - : 'ready' - - return { - id: requirement.id, - label: requirement.label || requirement.typeComposant?.name || 'Famille de composants', - typeName: requirement.typeComposant?.name || 'Non défini', - min, - max, - entries: normalizedEntries, - issues, - completed, - total: entries.length, - status - } - }) - - const pieceGroups = (type.pieceRequirements || []).map((requirement) => { - const entries = getPieceRequirementEntries(requirement.id) - const normalizedEntries = entries.map((entry, index) => { - const selectedPiece = entry.pieceId ? findPieceById(entry.pieceId) : null - const displayName = selectedPiece?.name || requirement.typePiece?.name || 'Pièce' - - const subtitleParts = [] - if (selectedPiece?.reference) { - subtitleParts.push(`Réf. ${selectedPiece.reference}`) - } - const constructeurName = selectedPiece?.constructeur?.name || selectedPiece?.constructeurName - if (constructeurName) { - subtitleParts.push(constructeurName) - } - 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}`) - } - const status = entry.pieceId ? 'complete' : 'pending' - - return { - key: `${requirement.id}-${index}`, - status, - title: displayName, - subtitle: subtitleParts.join(' • ') || null, - machineAssignmentLabel, - componentAssignmentLabel, - machineAssignments, - componentAssignments, - } - }) - - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const max = requirement.maxCount ?? null - const completed = normalizedEntries.filter(entry => entry.status === 'complete').length - const issues = [] - - 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(entry => entry.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(issue => issue.kind === 'error') - const hasWarnings = completed < entries.length - - const status = hasErrors - ? 'error' - : hasWarnings - ? 'warning' - : 'ready' - - return { - id: requirement.id, - label: requirement.label || requirement.typePiece?.name || 'Groupe de pièces', - typeName: requirement.typePiece?.name || 'Non défini', - min, - max, - entries: normalizedEntries, - issues, - completed, - total: entries.length, - status - } -}) - - const { stats: productGroups } = buildProductRequirementStats(type) - - 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 => group.issues.map(issue => ({ ...issue, scope: group.label }))), - ] - - const statuses = [ - baseStatus, - ...componentGroups.map(group => group.status), - ...pieceGroups.map(group => group.status), - ...productGroups.map(group => group.status), - ] - - 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?.length || 0) > 0 || - (type.pieceRequirements?.length || 0) > 0 || - (type.productRequirements?.length || 0) > 0 - }, - status: overallStatus, - ready: overallStatus === 'ready', - issues: aggregatedIssues - } -}) - -const blockingPreviewIssues = computed(() => { - if (!machinePreview.value) { - return [] - } - return machinePreview.value.issues.filter(issue => issue.kind === 'error') -}) - -const canCreateMachine = computed(() => { - if (!machinePreview.value) { - return false - } - return blockingPreviewIssues.value.length === 0 -}) - -const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2'] - -const scrollToAnchor = (anchor) => { - 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) -} - -const handleIssueClick = (issue) => { - if (!issue?.anchor) { - return - } - scrollToAnchor(issue.anchor) -} - -const initializeRequirementSelections = (type) => { - const componentRequirements = type.componentRequirements || [] - const pieceRequirements = type.pieceRequirements || [] - const productRequirements = type.productRequirements || [] - - componentRequirements.forEach((requirement) => { - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const initialCount = Math.max(min, requirement.required ? 1 : 0) - if (initialCount > 0) { - componentRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createComponentSelectionEntry(requirement)) - } else { - componentRequirementSelections[requirement.id] = [] - } - }) - - pieceRequirements.forEach((requirement) => { - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const initialCount = Math.max(min, requirement.required ? 1 : 0) - if (initialCount > 0) { - pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement)) - pieceRequirementSelections[requirement.id].forEach((_, index) => { - fetchPieceOptions(requirement, index).catch(() => {}) - }) - } else { - pieceRequirementSelections[requirement.id] = [] - } - }) - - productRequirements.forEach((requirement) => { - const min = requirement.minCount ?? (requirement.required ? 1 : 0) - const initialCount = Math.max(min, requirement.required ? 1 : 0) - if (initialCount > 0) { - productRequirementSelections[requirement.id] = Array.from( - { length: initialCount }, - () => createProductSelectionEntry(requirement), - ) - } else { - productRequirementSelections[requirement.id] = [] - } - }) -} +// --------------------------------------------------------------------------- +// Machine creation +// --------------------------------------------------------------------------- const finalizeMachineCreation = async () => { if (submitting.value) { @@ -2284,6 +1198,10 @@ const finalizeMachineCreation = async () => { } } +// --------------------------------------------------------------------------- +// Watchers & lifecycle +// --------------------------------------------------------------------------- + watch( () => newMachine.typeMachineId, (typeId) => { diff --git a/app/shared/utils/assignmentUtils.ts b/app/shared/utils/assignmentUtils.ts new file mode 100644 index 0000000..16f1a47 --- /dev/null +++ b/app/shared/utils/assignmentUtils.ts @@ -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 + +// --------------------------------------------------------------------------- +// 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() + 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(', ') +}