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:
Matthieu
2025-11-05 15:35:02 +01:00
parent 3af6c50892
commit d860f24e69
42 changed files with 6052 additions and 142 deletions

View File

@@ -3,12 +3,16 @@ import {
type ComponentModelCustomFieldType,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure,
type ComponentModelStructureNode,
type PieceModelCustomField,
type PieceModelProduct,
type PieceModelStructure,
type PieceModelStructureEditorField,
type PieceModelStructureForEditor,
type ProductModelStructure,
createEmptyProductModelStructure,
createEmptyPieceModelStructure,
} from './types/inventory'
import { uniqueConstructeurIds } from './constructeurUtils'
@@ -20,6 +24,7 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export interface ModelStructurePreview {
customFields: number
pieces: number
products: number
subcomponents: number
}
@@ -37,6 +42,7 @@ const ensureStructureShape = (input: any): ComponentModelStructure => {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents
: Array.isArray((input as any).subComponents)
@@ -240,6 +246,66 @@ const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
.filter((piece): piece is ComponentModelPiece => !!piece)
}
const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
@@ -331,6 +397,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[],
pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents),
}
@@ -398,6 +465,20 @@ export const normalizeStructureForSave = (input: any): any => {
return payload
}) as any
const backendProducts = sanitizeProducts(source.products).map((product) => {
const payload: Record<string, any> = {}
if ((product as any).familyCode) {
payload.familyCode = (product as any).familyCode
}
if (product.typeProductId) {
payload.typeProductId = product.typeProductId
}
if (product.role) {
payload.role = product.role
}
return payload
}) as any
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
const payload: Record<string, any> = {}
if (subcomponent.typeComposantId) {
@@ -423,6 +504,7 @@ export const normalizeStructureForSave = (input: any): any => {
const result: ComponentModelStructure = {
customFields: backendCustomFields,
pieces: backendPieces,
products: backendProducts,
subcomponents: backendSubcomponents,
}
@@ -545,6 +627,20 @@ const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
}))
}
const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
@@ -569,6 +665,7 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
return {
customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces),
products: hydrateProducts(source.products),
subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
),
@@ -619,6 +716,19 @@ const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
}))
}
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
@@ -645,6 +755,7 @@ export const extractStructureFromComponent = (component: any) => {
const raw = {
customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces),
products: mapComponentProducts(component.products),
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
@@ -662,12 +773,13 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subcomponents: 0 }
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
}
return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
products: Array.isArray(structure.products) ? structure.products.length : 0,
subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length
: Array.isArray(structure.subComponents)
@@ -678,13 +790,14 @@ export const computeStructureStats = (structure: any): ModelStructurePreview =>
export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
return 'Structure vide'
}
const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.products) segments.push(`${stats.products} produit(s)`)
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ')
}
@@ -741,6 +854,10 @@ export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(),
})
export const defaultProductStructure = (): ProductModelStructure => ({
...createEmptyProductModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) {
@@ -750,10 +867,11 @@ const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const clone: PieceModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
}
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (key === 'customFields') {
if (key === 'customFields' || key === 'products') {
continue
}
clone[key] = value
@@ -771,6 +889,10 @@ export const clonePieceStructure = (input: any): PieceModelStructure => {
}
}
export const cloneProductStructure = (input: any): ProductModelStructure => {
return clonePieceStructure(input)
}
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
@@ -811,12 +933,18 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
.filter((field): field is PieceModelCustomField => !!field)
}
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
return sanitizeProducts(products) as PieceModelProduct[]
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input)
const restEntries = Object.entries(source).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'),
),
...Object.fromEntries(restEntries),
products: sanitizePieceProducts(source.products),
customFields: sanitizePieceCustomFields(source.customFields),
}
}
@@ -844,8 +972,9 @@ export const hydratePieceStructureForEditor = (input: any): PieceModelStructureF
const source = clonePieceStructure(input)
const payload: PieceModelStructureForEditor = {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'),
Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
),
products: hydrateProducts(source.products) as PieceModelProduct[],
customFields: hydratePieceCustomFields(source.customFields),
}
return payload
@@ -859,10 +988,30 @@ export const formatPieceStructurePreview = (structure: any) => {
const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length
: 0
const products = Array.isArray((structure as any).products)
? (structure as any).products.length
: 0
if (!customFields) {
return 'Aucun champ personnalisé'
if (!customFields && !products) {
return 'Aucun produit ni champ personnalisé'
}
return `${customFields} champ(s) personnalisé(s)`
const segments: string[] = []
if (products) {
segments.push(`${products} produit(s)`)
}
if (customFields) {
segments.push(`${customFields} champ(s) personnalisé(s)`)
}
return segments.join(' · ')
}
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
normalizePieceStructureForSave(input)
export const hydrateProductStructureForEditor = (input: any) =>
hydratePieceStructureForEditor(input)
export const formatProductStructurePreview = (structure: any) =>
formatPieceStructurePreview(structure)