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>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
/**
|
|
* Machine detail — hierarchy & link management sub-composable.
|
|
*
|
|
* Handles machine hierarchy building, component/piece tree resolution,
|
|
* flatten helpers, find-by-id utilities, and structure link CRUD.
|
|
*/
|
|
|
|
import { ref, computed } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import {
|
|
resolveIdentifier,
|
|
} from '~/shared/utils/productDisplayUtils'
|
|
import {
|
|
buildMachineHierarchyFromLinks,
|
|
resolveLinkArray,
|
|
} from '~/composables/useMachineHierarchy'
|
|
|
|
type AnyRecord = Record<string, unknown>
|
|
|
|
interface MachineDetailHierarchyDeps {
|
|
machineId: string
|
|
machine: Ref<AnyRecord | null>
|
|
constructeurs: Ref<unknown[]>
|
|
findProductById: (id: string | null | undefined) => AnyRecord | null
|
|
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
|
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
|
syncMachineCustomFields: () => void
|
|
}
|
|
|
|
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
|
const {
|
|
machineId,
|
|
machine,
|
|
constructeurs,
|
|
findProductById,
|
|
transformComponentCustomFields,
|
|
transformCustomFields,
|
|
syncMachineCustomFields,
|
|
} = deps
|
|
|
|
const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
|
|
const toast = useToast()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const components = ref<AnyRecord[]>([])
|
|
const pieces = ref<AnyRecord[]>([])
|
|
const machineComponentLinks = ref<AnyRecord[]>([])
|
|
const machinePieceLinks = ref<AnyRecord[]>([])
|
|
const machineProductLinks = ref<AnyRecord[]>([])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
|
|
const result: AnyRecord[] = []
|
|
const traverse = (items: AnyRecord[]) => {
|
|
items.forEach((item) => {
|
|
result.push(item)
|
|
if (Array.isArray(item.subComponents) && item.subComponents.length) {
|
|
traverse(item.subComponents as AnyRecord[])
|
|
}
|
|
})
|
|
}
|
|
traverse(list)
|
|
return result
|
|
}
|
|
|
|
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
|
|
for (const item of items || []) {
|
|
if (item.id === id) return item
|
|
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
}
|
|
|
|
const findPieceById = (pieceId: string): AnyRecord | null => {
|
|
const direct = pieces.value.find((p) => p.id === pieceId)
|
|
if (direct) return direct
|
|
|
|
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
|
|
for (const item of items || []) {
|
|
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
|
|
if (match) return match
|
|
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
|
|
if (nested) return nested
|
|
}
|
|
return null
|
|
}
|
|
return searchInComponents(components.value)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hierarchy & links
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const applyMachineLinks = (source: AnyRecord): boolean => {
|
|
const container = (source?.machine as AnyRecord) ?? null
|
|
const componentLinksData =
|
|
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
|
|
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
|
|
const pieceLinksData =
|
|
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
|
|
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
|
|
const productLinksData =
|
|
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
|
|
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
|
|
|
|
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
|
|
return false
|
|
}
|
|
|
|
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
|
|
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
|
|
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
|
|
|
|
machineComponentLinks.value = normalizedComponentLinks
|
|
machinePieceLinks.value = normalizedPieceLinks
|
|
machineProductLinks.value = normalizedProductLinks
|
|
|
|
const { components: hierarchy, machinePieces: machineLevelPieces } =
|
|
buildMachineHierarchyFromLinks(
|
|
normalizedComponentLinks,
|
|
normalizedPieceLinks,
|
|
findProductById as any,
|
|
constructeurs.value as any,
|
|
)
|
|
|
|
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
|
|
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
|
|
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Computed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const flattenedComponents = computed(() => flattenComponents(components.value))
|
|
|
|
const machinePieces = computed(() => {
|
|
return pieces.value.filter((piece) => {
|
|
const parentLinkId = resolveIdentifier(
|
|
piece.parentComponentLinkId,
|
|
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
|
|
piece.parentLinkId,
|
|
)
|
|
if (parentLinkId) return false
|
|
return !piece.composantId
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structure reload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const reloadMachineStructure = async () => {
|
|
const result: any = await get(`/machines/${machineId}/structure`)
|
|
if (result.success) {
|
|
const machinePayload =
|
|
result.data?.machine && typeof result.data.machine === 'object'
|
|
? result.data.machine
|
|
: result.data
|
|
if (machinePayload && typeof machinePayload === 'object') {
|
|
machine.value = {
|
|
...machine.value,
|
|
...machinePayload,
|
|
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
|
|
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
|
|
}
|
|
const linksApplied = applyMachineLinks(result.data)
|
|
if (linksApplied && machine.value) {
|
|
machine.value.componentLinks = machineComponentLinks.value
|
|
machine.value.pieceLinks = machinePieceLinks.value
|
|
machine.value.productLinks = machineProductLinks.value
|
|
}
|
|
syncMachineCustomFields()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structure link CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const addComponentLink = async (composantId: string) => {
|
|
const result: any = await apiPost('/machine_component_links', {
|
|
machine: `/api/machines/${machineId}`,
|
|
composant: `/api/composants/${composantId}`,
|
|
})
|
|
if (result.success) {
|
|
toast.showSuccess('Composant ajouté à la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de l\'ajout du composant')
|
|
}
|
|
return result
|
|
}
|
|
|
|
const removeComponentLink = async (linkId: string) => {
|
|
const result: any = await apiDel(`/machine_component_links/${linkId}`)
|
|
if (result.success) {
|
|
toast.showSuccess('Composant retiré de la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de la suppression du composant')
|
|
}
|
|
return result
|
|
}
|
|
|
|
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
|
|
const payload: any = {
|
|
machine: `/api/machines/${machineId}`,
|
|
piece: `/api/pieces/${pieceId}`,
|
|
}
|
|
if (parentComponentLinkId) {
|
|
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
|
}
|
|
const result: any = await apiPost('/machine_piece_links', payload)
|
|
if (result.success) {
|
|
toast.showSuccess('Pièce ajoutée à la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de l\'ajout de la pièce')
|
|
}
|
|
return result
|
|
}
|
|
|
|
const removePieceLink = async (linkId: string) => {
|
|
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
|
|
if (result.success) {
|
|
toast.showSuccess('Pièce retirée de la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de la suppression de la pièce')
|
|
}
|
|
return result
|
|
}
|
|
|
|
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
|
|
const payload: any = {
|
|
machine: `/api/machines/${machineId}`,
|
|
product: `/api/products/${productId}`,
|
|
}
|
|
if (parentComponentLinkId) {
|
|
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
|
}
|
|
if (parentPieceLinkId) {
|
|
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
|
|
}
|
|
const result: any = await apiPost('/machine_product_links', payload)
|
|
if (result.success) {
|
|
toast.showSuccess('Produit ajouté à la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de l\'ajout du produit')
|
|
}
|
|
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) {
|
|
toast.showSuccess('Produit retiré de la machine')
|
|
await reloadMachineStructure()
|
|
} else {
|
|
toast.showError('Erreur lors de la suppression du produit')
|
|
}
|
|
return result
|
|
}
|
|
|
|
return {
|
|
// State
|
|
components,
|
|
pieces,
|
|
machineComponentLinks,
|
|
machinePieceLinks,
|
|
machineProductLinks,
|
|
|
|
// Computed
|
|
flattenedComponents,
|
|
machinePieces,
|
|
|
|
// Helpers
|
|
flattenComponents,
|
|
findComponentById,
|
|
findPieceById,
|
|
|
|
// Hierarchy
|
|
applyMachineLinks,
|
|
|
|
// Structure link management
|
|
reloadMachineStructure,
|
|
addComponentLink,
|
|
removeComponentLink,
|
|
addPieceLink,
|
|
removePieceLink,
|
|
addProductLink,
|
|
addComponentLinkCategoryOnly,
|
|
addPieceLinkCategoryOnly,
|
|
addProductLinkCategoryOnly,
|
|
fillEntityLink,
|
|
removeProductLink,
|
|
}
|
|
}
|