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>
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
/**
|
|
* Machine detail — product display sub-composable.
|
|
*
|
|
* Handles product resolution, display helpers, supplier info,
|
|
* and machine-level direct product links.
|
|
*/
|
|
|
|
import { computed } from 'vue'
|
|
import { useProducts } from '~/composables/useProducts'
|
|
import {
|
|
resolveProductReference as _resolveProductReference,
|
|
getProductDisplay as _getProductDisplay,
|
|
getProductSuppliersLabel,
|
|
getProductPriceLabel,
|
|
} from '~/shared/utils/productDisplayUtils'
|
|
import {
|
|
resolveConstructeurs,
|
|
uniqueConstructeurIds,
|
|
} from '~/shared/constructeurUtils'
|
|
|
|
type AnyRecord = Record<string, unknown>
|
|
|
|
interface MachineDetailProductsDeps {
|
|
machineProductLinks: Ref<AnyRecord[]>
|
|
productDocumentsMap: Ref<Map<string, AnyRecord[]>>
|
|
constructeurs: Ref<unknown[]>
|
|
}
|
|
|
|
export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
|
const { machineProductLinks, productDocumentsMap, constructeurs } = deps
|
|
const { products, loadProducts } = useProducts()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Computed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const productInventory = computed(() => products.value || [])
|
|
|
|
const productById = computed(() => {
|
|
const map = new Map<string, AnyRecord>()
|
|
;(productInventory.value as AnyRecord[]).forEach((product: AnyRecord) => {
|
|
if (product?.id) map.set(product.id as string, product)
|
|
})
|
|
return map
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const findProductById = (productId: string | null | undefined): AnyRecord | null => {
|
|
if (!productId) return null
|
|
return productById.value.get(productId) || null
|
|
}
|
|
|
|
const resolveProductReference = (source: AnyRecord) =>
|
|
_resolveProductReference(source, findProductById as any)
|
|
const getProductDisplay = (source: AnyRecord) =>
|
|
_getProductDisplay(source, findProductById as any)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Machine direct products
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const machineDirectProducts = computed(() => {
|
|
return machineProductLinks.value.map((link) => {
|
|
const productObj = link.product as AnyRecord | string | null
|
|
let resolved: AnyRecord | null = null
|
|
let productId: string | null = null
|
|
|
|
if (typeof productObj === 'string') {
|
|
productId = productObj.split('/').pop() || null
|
|
resolved = productId ? findProductById(productId) : null
|
|
} else if (productObj && typeof productObj === 'object') {
|
|
productId = (productObj as AnyRecord)?.id as string | null
|
|
// Prefer the embedded product from the structure endpoint — it has richer
|
|
// data (typeProduct as object, supplierPrice, constructeurs) than the
|
|
// global products cache which may store typeProduct as an IRI string.
|
|
const cached = productId ? findProductById(productId) : null
|
|
resolved = productObj as AnyRecord
|
|
if (cached) {
|
|
// Merge: use embedded as base, overlay any non-null cached fields
|
|
resolved = { ...resolved, ...Object.fromEntries(
|
|
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
|
|
) }
|
|
// But always prefer the embedded typeProduct when it's an object
|
|
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
|
|
resolved.typeProduct = productObj.typeProduct
|
|
}
|
|
}
|
|
}
|
|
|
|
const cIds = uniqueConstructeurIds(
|
|
resolved?.constructeurs,
|
|
resolved?.constructeurIds,
|
|
)
|
|
const resolvedConstructeurs = resolveConstructeurs(
|
|
cIds,
|
|
resolved?.constructeurs as any[] || [],
|
|
constructeurs.value as any,
|
|
)
|
|
|
|
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) || (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
|
|
: getProductSuppliersLabel(resolved),
|
|
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,
|
|
}
|
|
})
|
|
})
|
|
|
|
return {
|
|
// Computed
|
|
productInventory,
|
|
productById,
|
|
machineDirectProducts,
|
|
|
|
// Helpers
|
|
findProductById,
|
|
resolveProductReference,
|
|
getProductDisplay,
|
|
|
|
// Loading
|
|
loadProducts,
|
|
}
|
|
}
|