feat(machines) : allow category-only links on machine structure

Enable adding a component, piece, or product to a machine by selecting
only the category (ModelType) without a specific entity. The link
displays a red "À remplir" badge; clicking it reopens the modal
pre-filled with the category so the user can associate an item later.

Backend: entity FKs made nullable on the 3 link tables, modelType FK
added, controller/audit/version/MCP normalization adapted for null
entities.

Frontend: modal accepts category-only confirm, page handles fill mode,
hierarchy builder creates pending nodes, display components show
clickable badge with event propagation through the full hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 10:15:47 +02:00
parent 342ae37762
commit 1c3b566923
20 changed files with 452 additions and 77 deletions

View File

@@ -193,6 +193,10 @@ export function useMachineDetailData(machineId: string) {
removePieceLink,
addProductLink,
removeProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
} = hierarchy
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
@@ -511,6 +515,8 @@ export function useMachineDetailData(machineId: string) {
loadMachineData, loadInitialData,
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
addProductLink, removeProductLink, reloadMachineStructure,
addComponentLinkCategoryOnly, addPieceLinkCategoryOnly,
addProductLinkCategoryOnly, fillEntityLink,
// External
constructeurs, loadProducts, updateMachineStructure, toast,

View File

@@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
syncMachineCustomFields,
} = deps
const { get, post: apiPost, delete: apiDel } = useApi()
const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
const toast = useToast()
// ---------------------------------------------------------------------------
@@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
return result
}
const addComponentLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addPieceLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_piece_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addProductLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_product_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => {
let endpoint = ''
let payload: Record<string, string> = {}
if (entityKind === 'component') {
endpoint = `/machine_component_links/${linkId}`
payload = { composant: `/api/composants/${entityId}` }
} else if (entityKind === 'piece') {
endpoint = `/machine_piece_links/${linkId}`
payload = { piece: `/api/pieces/${entityId}` }
} else {
endpoint = `/machine_product_links/${linkId}`
payload = { product: `/api/products/${entityId}` }
}
const result: any = await apiPatch(endpoint, payload)
if (result.success) {
toast.showSuccess('Item associé avec succès')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'association')
}
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
@@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
addPieceLink,
removePieceLink,
addProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
removeProductLink,
}
}

View File

@@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
return {
id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu',
name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu',
reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
@@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
pendingEntity: (link.pendingEntity as boolean) || false,
modelTypeId: (link.modelTypeId as string) || null,
modelType: (link.modelType as string) || null,
}
})
})

View File

@@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = (
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.piece && !link.pieceId)) {
const machinePieceLinkId = normalizePieceLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machinePieceLinkId || `pending-${link.id}`,
linkId: machinePieceLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
pieceId: null,
constructeurs: [],
documents: [],
customFields: [],
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
parentComponentName,
machinePieceLinkId,
quantity: 1,
}
}
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
@@ -205,6 +229,35 @@ export const buildMachineHierarchyFromLinks = (
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.composant && !link.composantId)) {
const machineComponentLinkId = normalizeComponentLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machineComponentLinkId || `pending-${link.id}`,
linkId: machineComponentLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
composantId: null,
composant: null,
constructeurs: [],
documents: [],
customFields: [],
customFieldValues: [],
subComponents: [],
pieces: [],
overrides: null,
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
machineComponentLinkId,
childLinks: [],
pieceLinks: [],
}
}
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null