feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
This commit is contained in:
@@ -148,6 +148,18 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabel(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
@@ -189,14 +201,16 @@
|
||||
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
||||
>
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Chargement du catalogue de pièces et de composants…
|
||||
Chargement du catalogue de pièces, produits et composants…
|
||||
</div>
|
||||
<ComponentStructureAssignmentNode
|
||||
v-else-if="structureAssignments"
|
||||
:assignment="structureAssignments"
|
||||
:pieces="availablePieces"
|
||||
:products="availableProducts"
|
||||
:components="availableComponents"
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:components-loading="componentsLoading"
|
||||
/>
|
||||
<p v-else class="text-xs text-error">
|
||||
@@ -335,6 +349,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
@@ -342,6 +357,7 @@ import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/mo
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
ComponentModelStructure,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory'
|
||||
@@ -367,6 +383,11 @@ const {
|
||||
loadPieces,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loadProducts,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
@@ -387,9 +408,10 @@ const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||
const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value,
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -486,6 +508,21 @@ const extractPiecesFromNode = (
|
||||
)
|
||||
}
|
||||
|
||||
const extractProductsFromNode = (
|
||||
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
|
||||
): ComponentModelProduct[] => {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
return []
|
||||
}
|
||||
const raw = Array.isArray((definition as any).products)
|
||||
? (definition as any).products
|
||||
: []
|
||||
return raw.filter(
|
||||
(item: unknown): item is ComponentModelProduct =>
|
||||
!!item && typeof item === 'object',
|
||||
)
|
||||
}
|
||||
|
||||
const buildAssignmentNode = (
|
||||
definition: ComponentModelStructureNode | ComponentModelStructure,
|
||||
path: string,
|
||||
@@ -496,6 +533,12 @@ const buildAssignmentNode = (
|
||||
selectedPieceId: '',
|
||||
}))
|
||||
|
||||
const products = extractProductsFromNode(definition).map((product, index) => ({
|
||||
path: `${path}:product-${index}`,
|
||||
definition: product,
|
||||
selectedProductId: '',
|
||||
}))
|
||||
|
||||
const subcomponents = extractSubcomponents(definition).map(
|
||||
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
|
||||
)
|
||||
@@ -505,6 +548,7 @@ const buildAssignmentNode = (
|
||||
definition,
|
||||
selectedComponentId: '',
|
||||
pieces,
|
||||
products,
|
||||
subcomponents,
|
||||
}
|
||||
}
|
||||
@@ -522,7 +566,7 @@ const hasAssignments = (node: StructureAssignmentNode | null): boolean => {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (node.pieces.length > 0 || node.subcomponents.length > 0) {
|
||||
if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
|
||||
return true
|
||||
}
|
||||
return node.subcomponents.some((child) => hasAssignments(child))
|
||||
@@ -539,13 +583,21 @@ const isAssignmentNodeComplete = (
|
||||
const piecesComplete = node.pieces.every(
|
||||
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
|
||||
)
|
||||
const productsComplete = node.products.every(
|
||||
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
|
||||
)
|
||||
const subcomponentsComplete = node.subcomponents.every(
|
||||
(child) =>
|
||||
!!child.selectedComponentId &&
|
||||
child.selectedComponentId.length > 0 &&
|
||||
isAssignmentNodeComplete(child, false),
|
||||
)
|
||||
return piecesComplete && subcomponentsComplete && (isRootNode || !!node.selectedComponentId)
|
||||
return (
|
||||
piecesComplete &&
|
||||
productsComplete &&
|
||||
subcomponentsComplete &&
|
||||
(isRootNode || !!node.selectedComponentId)
|
||||
)
|
||||
}
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
@@ -588,6 +640,15 @@ const sanitizePieceDefinition = (definition: ComponentModelPiece) =>
|
||||
familyCode: (definition as any).familyCode ?? null,
|
||||
})
|
||||
|
||||
const sanitizeProductDefinition = (definition: ComponentModelProduct) =>
|
||||
stripNullish({
|
||||
role: (definition as any).role ?? null,
|
||||
typeProductId: definition.typeProductId ?? null,
|
||||
typeProductLabel: (definition as any).typeProductLabel ?? null,
|
||||
reference: (definition as any).reference ?? null,
|
||||
familyCode: (definition as any).familyCode ?? null,
|
||||
})
|
||||
|
||||
const serializeStructureAssignments = (
|
||||
root: StructureAssignmentNode | null,
|
||||
) => {
|
||||
@@ -609,6 +670,16 @@ const serializeStructureAssignments = (
|
||||
}),
|
||||
)
|
||||
|
||||
const serializedProducts = assignment.products
|
||||
.filter((product) => !!product.selectedProductId)
|
||||
.map((product) =>
|
||||
stripNullish({
|
||||
path: product.path,
|
||||
definition: sanitizeProductDefinition(product.definition),
|
||||
selectedProductId: product.selectedProductId,
|
||||
}),
|
||||
)
|
||||
|
||||
const serializedSubcomponents = assignment.subcomponents
|
||||
.map((child) => serializeNode(child, false))
|
||||
.filter((child) => Object.keys(child).length > 0)
|
||||
@@ -624,6 +695,9 @@ const serializeStructureAssignments = (
|
||||
if (serializedPieces.length) {
|
||||
base.pieces = serializedPieces
|
||||
}
|
||||
if (serializedProducts.length) {
|
||||
base.products = serializedProducts
|
||||
}
|
||||
if (serializedSubcomponents.length) {
|
||||
base.subcomponents = serializedSubcomponents
|
||||
}
|
||||
@@ -634,6 +708,7 @@ const serializeStructureAssignments = (
|
||||
const serializedRoot = serializeNode(root, true)
|
||||
if (
|
||||
(!serializedRoot.pieces || serializedRoot.pieces.length === 0) &&
|
||||
(!serializedRoot.products || serializedRoot.products.length === 0) &&
|
||||
(!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
|
||||
) {
|
||||
return null
|
||||
@@ -682,6 +757,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
@@ -709,6 +788,25 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||
}
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
parts.push(product.role)
|
||||
}
|
||||
if (product.typeProduct?.name) {
|
||||
parts.push(product.typeProduct.name)
|
||||
} else if (product.typeProductLabel) {
|
||||
parts.push(product.typeProductLabel)
|
||||
} else if (product.typeProduct?.code) {
|
||||
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||
} else if (product.familyCode) {
|
||||
parts.push(`Catégorie ${product.familyCode}`)
|
||||
} else if (product.typeProductId) {
|
||||
parts.push(`#${product.typeProductId}`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Produit'
|
||||
}
|
||||
|
||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (node.alias) {
|
||||
@@ -776,8 +874,17 @@ const submitCreation = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const rootProductSelection =
|
||||
structureAssignments.value?.products?.find(
|
||||
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
|
||||
) ?? null
|
||||
|
||||
if (rootProductSelection?.selectedProductId) {
|
||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||
}
|
||||
|
||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||||
toast.showError('Complétez la sélection des pièces et sous-composants.')
|
||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -829,6 +936,7 @@ onMounted(async () => {
|
||||
loadComponentTypes(),
|
||||
loadPieces(),
|
||||
loadComposants(),
|
||||
loadProducts(),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user