= {
COMPONENT: 'Composants',
PIECE: 'Pièces',
+ PRODUCT: 'Produits',
};
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
diff --git a/app/components/model-types/Toolbar.vue b/app/components/model-types/Toolbar.vue
index 5e6b180..be6f6a5 100644
--- a/app/components/model-types/Toolbar.vue
+++ b/app/components/model-types/Toolbar.vue
@@ -78,12 +78,13 @@
import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search';
+import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc';
const props = defineProps<{
- category: 'COMPONENT' | 'PIECE';
+ category: ModelCategory;
search: string;
sort: SortField;
dir: SortDirection;
@@ -92,16 +93,17 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- (e: 'update:category', value: 'COMPONENT' | 'PIECE'): void;
+ (e: 'update:category', value: ModelCategory): void;
(e: 'update:search', value: string): void;
(e: 'update:sort', value: SortField): void;
(e: 'update:dir', value: SortDirection): void;
(e: 'create'): void;
}>();
-const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [
+const categories: Array<{ label: string; value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' },
+ { label: 'Produits', value: 'PRODUCT' },
];
const onSearch = (event: Event) => {
diff --git a/app/composables/useDocuments.js b/app/composables/useDocuments.js
index 219eea1..5b89cd6 100644
--- a/app/composables/useDocuments.js
+++ b/app/composables/useDocuments.js
@@ -60,6 +60,11 @@ export function useDocuments () {
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
}
+ const loadDocumentsByProduct = async (productId, options = {}) => {
+ if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } }
+ return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
+ }
+
const loadDocumentsByPiece = async (pieceId, options = {}) => {
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } }
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
@@ -140,6 +145,7 @@ export function useDocuments () {
loadDocumentsByMachine,
loadDocumentsByComponent,
loadDocumentsByPiece,
+ loadDocumentsByProduct,
uploadDocuments,
deleteDocument
}
diff --git a/app/composables/useMachineTypesApi.js b/app/composables/useMachineTypesApi.js
index 6811bd8..2f38673 100644
--- a/app/composables/useMachineTypesApi.js
+++ b/app/composables/useMachineTypesApi.js
@@ -5,6 +5,20 @@ import { useApi } from './useApi'
const machineTypes = ref([])
const loading = ref(false)
+const normalizeRequirementList = (value) => (Array.isArray(value) ? value : [])
+
+const normalizeMachineType = (type) => {
+ if (!type || typeof type !== 'object') {
+ return type
+ }
+ return {
+ ...type,
+ componentRequirements: normalizeRequirementList(type.componentRequirements),
+ pieceRequirements: normalizeRequirementList(type.pieceRequirements),
+ productRequirements: normalizeRequirementList(type.productRequirements),
+ }
+}
+
export function useMachineTypesApi () {
const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi()
@@ -14,7 +28,9 @@ export function useMachineTypesApi () {
try {
const result = await get('/types/machines')
if (result.success) {
- machineTypes.value = result.data
+ machineTypes.value = Array.isArray(result.data)
+ ? result.data.map(normalizeMachineType)
+ : []
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`)
}
} catch (error) {
@@ -29,7 +45,7 @@ export function useMachineTypesApi () {
try {
const result = await post('/types/machines', typeData)
if (result.success) {
- machineTypes.value.push(result.data)
+ machineTypes.value.push(normalizeMachineType(result.data))
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
}
return result
@@ -46,9 +62,10 @@ export function useMachineTypesApi () {
try {
const result = await patch(`/types/machines/${id}`, typeData)
if (result.success) {
+ const normalized = normalizeMachineType(result.data)
const index = machineTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
- machineTypes.value[index] = result.data
+ machineTypes.value[index] = normalized
}
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
}
@@ -91,7 +108,7 @@ export function useMachineTypesApi () {
const result = await get(`/types/machines/${id}`)
if (result.success) {
// Ajouter au cache local
- machineTypes.value.push(result.data)
+ machineTypes.value.push(normalizeMachineType(result.data))
}
return result
} catch (error) {
diff --git a/app/composables/useProductTypes.js b/app/composables/useProductTypes.js
new file mode 100644
index 0000000..d2071c0
--- /dev/null
+++ b/app/composables/useProductTypes.js
@@ -0,0 +1,132 @@
+import { ref } from 'vue'
+import { useToast } from './useToast'
+import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
+
+const productTypes = ref([])
+const loadingProductTypes = ref(false)
+
+export function useProductTypes () {
+ const { showSuccess, showError } = useToast()
+
+ const generateCodeFromName = (name) => {
+ return (name || '')
+ .normalize('NFD')
+ .replace(/[\u0300-\u036F]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .replace(/-+/g, '-') || 'type'
+ }
+
+ const loadProductTypes = async () => {
+ loadingProductTypes.value = true
+ try {
+ const data = await listModelTypes({
+ category: 'PRODUCT',
+ sort: 'name',
+ dir: 'asc',
+ limit: 200,
+ })
+
+ productTypes.value = data.items.map(item => ({
+ ...item,
+ description: item.description ?? item.notes ?? null,
+ }))
+
+ return { success: true, data: productTypes.value }
+ } catch (error) {
+ const message = error?.message || 'Erreur inconnue'
+ showError(`Impossible de charger les types de produit: ${message}`)
+ return { success: false, error: message }
+ } finally {
+ loadingProductTypes.value = false
+ }
+ }
+
+ const createProductType = async (payload) => {
+ loadingProductTypes.value = true
+ try {
+ const data = await createModelType({
+ name: payload.name,
+ code: payload.code || generateCodeFromName(payload.name),
+ category: 'PRODUCT',
+ notes: payload.description ?? payload.notes,
+ description: payload.description ?? null,
+ structure: payload.structure,
+ })
+
+ const normalized = {
+ ...data,
+ description: data.description ?? data.notes ?? null,
+ }
+
+ productTypes.value.push(normalized)
+ showSuccess(`Type de produit "${data.name}" créé`)
+
+ return { success: true, data: normalized }
+ } catch (error) {
+ const message = error?.data?.message || error?.message || 'Erreur inconnue'
+ showError(`Erreur lors de la création du type de produit: ${message}`)
+ return { success: false, error: message }
+ } finally {
+ loadingProductTypes.value = false
+ }
+ }
+
+ const updateProductType = async (id, payload) => {
+ loadingProductTypes.value = true
+ try {
+ const data = await updateModelType(id, {
+ name: payload.name,
+ description: payload.description,
+ notes: payload.notes,
+ code: payload.code,
+ structure: payload.structure,
+ })
+
+ const normalized = {
+ ...data,
+ description: data.description ?? data.notes ?? null,
+ }
+
+ const index = productTypes.value.findIndex(type => type.id === id)
+ if (index !== -1) {
+ productTypes.value[index] = normalized
+ }
+ showSuccess(`Type de produit "${data.name}" mis à jour`)
+
+ return { success: true, data: normalized }
+ } catch (error) {
+ const message = error?.data?.message || error?.message || 'Erreur inconnue'
+ showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
+ return { success: false, error: message }
+ } finally {
+ loadingProductTypes.value = false
+ }
+ }
+
+ const deleteProductType = async (id) => {
+ loadingProductTypes.value = true
+ try {
+ await deleteModelType(id)
+ productTypes.value = productTypes.value.filter(type => type.id !== id)
+ showSuccess('Type de produit supprimé')
+ return { success: true }
+ } catch (error) {
+ const message = error?.data?.message || error?.message || 'Erreur inconnue'
+ showError(`Erreur lors de la suppression du type de produit: ${message}`)
+ return { success: false, error: message }
+ } finally {
+ loadingProductTypes.value = false
+ }
+ }
+
+ return {
+ productTypes,
+ loadingProductTypes,
+ loadProductTypes,
+ createProductType,
+ updateProductType,
+ deleteProductType,
+ }
+}
diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js
new file mode 100644
index 0000000..59207ef
--- /dev/null
+++ b/app/composables/useProducts.js
@@ -0,0 +1,184 @@
+import { ref } from 'vue'
+import { useToast } from './useToast'
+import { useApi } from './useApi'
+
+const products = ref([])
+const total = ref(0)
+const loading = ref(false)
+const loaded = ref(false)
+const error = ref(null)
+
+const replaceInCache = (item) => {
+ if (!item?.id) {
+ return false
+ }
+ const index = products.value.findIndex((product) => product.id === item.id)
+ if (index === -1) {
+ products.value.unshift(item)
+ return true
+ }
+ const clone = products.value.slice()
+ clone[index] = item
+ products.value = clone
+ return false
+}
+
+export function useProducts () {
+ const { showError } = useToast()
+ const { get, post, patch, delete: del } = useApi()
+
+ const loadProducts = async (options = {}) => {
+ if (loading.value) {
+ return {
+ success: true,
+ data: { items: products.value, total: total.value },
+ }
+ }
+ if (loaded.value && !options.force) {
+ return {
+ success: true,
+ data: { items: products.value, total: total.value },
+ }
+ }
+
+ loading.value = true
+ error.value = null
+ try {
+ const result = await get('/products?limit=100')
+ if (result.success) {
+ const items = Array.isArray(result.data?.items) ? result.data.items : []
+ products.value = items
+ total.value = typeof result.data?.total === 'number' ? result.data.total : items.length
+ loaded.value = true
+ } else if (result.error) {
+ error.value = result.error
+ showError(`Impossible de charger les produits: ${result.error}`)
+ }
+ return result
+ } catch (err) {
+ console.error('Erreur lors du chargement des produits:', err)
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ showError(`Impossible de charger les produits: ${message}`)
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const createProduct = async (payload) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await post('/products', payload)
+ if (result.success && result.data) {
+ const added = replaceInCache(result.data)
+ if (added) {
+ total.value += 1
+ }
+ } else if (result.error) {
+ error.value = result.error
+ showError(result.error)
+ }
+ return result
+ } catch (err) {
+ console.error('Erreur lors de la création du produit:', err)
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ showError(message)
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const updateProduct = async (id, payload) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await patch(`/products/${id}`, payload)
+ if (result.success && result.data) {
+ replaceInCache(result.data)
+ } else if (result.error) {
+ error.value = result.error
+ showError(result.error)
+ }
+ return result
+ } catch (err) {
+ console.error('Erreur lors de la mise à jour du produit:', err)
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ showError(message)
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const deleteProduct = async (id) => {
+ loading.value = true
+ error.value = null
+ try {
+ const result = await del(`/products/${id}`)
+ if (result.success) {
+ const removed = products.value.find((product) => product.id === id)
+ products.value = products.value.filter((product) => product.id !== id)
+ total.value = Math.max(0, total.value - 1)
+ } else if (result.error) {
+ error.value = result.error
+ showError(result.error)
+ }
+ return result
+ } catch (err) {
+ console.error('Erreur lors de la suppression du produit:', err)
+ const message = err?.message ?? 'Erreur inconnue'
+ error.value = message
+ showError(message)
+ return { success: false, error: message }
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const getProduct = async (id, options = {}) => {
+ if (!options.force) {
+ const cached = products.value.find((product) => product.id === id)
+ if (cached) {
+ return { success: true, data: cached }
+ }
+ }
+
+ try {
+ const result = await get(`/products/${id}`)
+ if (result.success && result.data) {
+ replaceInCache(result.data)
+ }
+ return result
+ } catch (err) {
+ console.error('Erreur lors du chargement du produit:', err)
+ const message = err?.message ?? 'Erreur inconnue'
+ return { success: false, error: message }
+ }
+ }
+
+ const clearProductsCache = () => {
+ products.value = []
+ total.value = 0
+ loaded.value = false
+ error.value = null
+ }
+
+ return {
+ products,
+ total,
+ loading,
+ loaded,
+ error,
+ loadProducts,
+ createProduct,
+ updateProduct,
+ deleteProduct,
+ getProduct,
+ clearProductsCache,
+ }
+}
diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue
index a6ede45..39b4615 100644
--- a/app/pages/component-catalog.vue
+++ b/app/pages/component-catalog.vue
@@ -94,6 +94,7 @@
Aperçu
Nom
Référence
+ Type de composant
Actions
@@ -107,6 +108,7 @@
{{ component.name || 'Composant sans nom' }}
{{ component.reference || '—' }}
+ {{ resolveComponentType(component) }}
) => {
return 'Aperçu du document'
}
+const resolveComponentType = (component: Record) => {
+ const type = component?.typeComposant
+ if (type?.name) {
+ return type.name
+ }
+ if (component?.typeComposantLabel) {
+ return component.typeComposantLabel
+ }
+ return '—'
+}
+
const resolveDeleteGuard = (component: Record) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks)
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index 53314ca..21af22f 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -445,7 +445,6 @@ const loadingDocuments = ref(false)
const componentDocuments = ref([])
const previewDocument = ref(null)
const previewVisible = ref(false)
-
const selectedTypeId = ref('')
const editionForm = reactive({
name: '' as string,
diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue
index d55ab89..baa27f6 100644
--- a/app/pages/component/create.vue
+++ b/app/pages/component/create.vue
@@ -148,6 +148,18 @@
+
+
Produits imposés
+
+
+ {{ resolveProductLabel(product) }}
+
+
+
+
Sous-composants
@@ -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"
>
- Chargement du catalogue de pièces et de composants…
+ Chargement du catalogue de pièces, produits et composants…
@@ -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([])
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) => {
return parts.length ? parts.join(' • ') : 'Pièce'
}
+const resolveProductLabel = (product: Record) => {
+ 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) => {
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(),
])
})
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 8cd7ad2..67711a3 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -423,6 +423,12 @@
selectedMachineType.pieceRequirements?.length || 0
}}
+
+ Produits requis :
+ {{
+ selectedMachineType.productRequirements?.length || 0
+ }}
+
Catégorie :
{{
diff --git a/app/pages/machine-skeleton/index.vue b/app/pages/machine-skeleton/index.vue
index 6934bd7..36059a6 100644
--- a/app/pages/machine-skeleton/index.vue
+++ b/app/pages/machine-skeleton/index.vue
@@ -58,6 +58,13 @@
pièces
+
+
+ {{ type.productRequirements?.length || 0 }} produit(s)
+ requis
+
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
+
+
+ {{ type.productRequirements?.length || 0 }} produit(s)
+
@@ -85,6 +89,7 @@ import { useToast } from '~/composables/useToast'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list'
+import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast()
@@ -100,7 +105,8 @@ const createEmptyType = () => ({
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
- pieceRequirements: []
+ pieceRequirements: [],
+ productRequirements: []
})
const draftType = ref(createEmptyType())
@@ -187,6 +193,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
+const normalizeProductRequirements = (requirements = []) =>
+ requirements
+ .filter(req => req?.typeProductId)
+ .map((req, index) => ({
+ typeProductId: req.typeProductId,
+ label: req.label?.trim() ? req.label.trim() : undefined,
+ minCount: toIntegerOrNull(req.minCount, 0),
+ maxCount: toIntegerOrNull(req.maxCount, null),
+ required: req.required ?? false,
+ allowNewModels: req.allowNewModels ?? true,
+ orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
+ }))
+ .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
+ .map((req, index) => ({ ...req, orderIndex: index }))
+
const buildPayload = typeData => ({
name: typeData.name,
description: typeData.description,
@@ -194,7 +215,8 @@ const buildPayload = typeData => ({
maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
- pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
+ pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
+ productRequirements: normalizeProductRequirements(typeData.productRequirements)
})
const resetForm = () => {
diff --git a/app/pages/machine/[id].vue b/app/pages/machine/[id].vue
index 19cf7bf..9a575d6 100644
--- a/app/pages/machine/[id].vue
+++ b/app/pages/machine/[id].vue
@@ -341,6 +341,55 @@
+
+
+
+
+
+
Produits associés
+
+ Produits sélectionnés directement pour cette machine selon le squelette.
+
+
+
+ {{ machineDirectProducts.length }} produit{{ machineDirectProducts.length > 1 ? 's' : '' }}
+
+
+
+
+
+
+
+ {{ product.name }}
+
+
+ {{ product.groupLabel }}
+
+
+
+ Référence :
+ {{ product.reference }}
+
+
+ Fournisseurs :
+ {{ product.supplierLabel }}
+
+
+ Prix indicatif :
+ {{ product.priceLabel }}
+
+
+
+
+ Aucun produit n'a été associé directement à cette machine.
+
+
+
+
@@ -399,7 +448,7 @@
@@ -452,6 +501,31 @@
{{ field.value }}
+
+
+ Produit :
+ {{ component.__productDisplay.name }}
+
+
+ Catégorie :
+ {{ component.__productDisplay.category }}
+
+
+ Référence :
+ {{ component.__productDisplay.reference }}
+
+
+ Fournisseurs :
+ {{ component.__productDisplay.suppliers }}
+
+
+ Prix indicatif :
+ {{ component.__productDisplay.price }}
+
+
Aucun composant rattaché à ce groupe.
@@ -500,11 +574,88 @@
{{ field.value }}
+
+
+ Produit :
+ {{ piece.__productDisplay.name }}
+
+
+ Catégorie :
+ {{ piece.__productDisplay.category }}
+
+
+ Référence :
+ {{ piece.__productDisplay.reference }}
+
+
+ Fournisseurs :
+ {{ piece.__productDisplay.suppliers }}
+
+
+ Prix indicatif :
+ {{ piece.__productDisplay.price }}
+
+
Aucune pièce rattachée à ce groupe.
+
+
+
Produits requis
+
+
+
+
+ {{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
+
+
+ Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
+
+
+
+ Total {{ group.totalCount }}
+ Direct {{ group.directProducts.length }}
+
+
+
+
+ Via composants : {{ group.componentCount }} • Via pièces : {{ group.pieceCount }}
+
+
+
+
+
+ {{ product.name }}
+
+
+ Référence : {{ product.reference }}
+
+
+ Fournisseurs : {{ product.supplierLabel }}
+
+
+ Prix indicatif : {{ product.priceLabel }}
+
+
+
+
+ Aucune sélection directe. Couverture assurée via composants ou pièces associés.
+
+
+
@@ -560,6 +711,7 @@ import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
+import { useProducts } from '~/composables/useProducts'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -593,6 +745,7 @@ const {
} = usePieces()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
+const { products, loadProducts } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue: updateCustomFieldValueApi } = useCustomFields()
const { get } = useApi()
@@ -612,6 +765,7 @@ const components = ref([])
const pieces = ref([])
const machineComponentLinks = ref([])
const machinePieceLinks = ref([])
+const machineProductLinks = ref([])
const printAreaRef = ref(null)
const { constructeurs, loadConstructeurs } = useConstructeurs()
@@ -679,12 +833,16 @@ const skeletonEditor = reactive({
const componentRequirementSelections = reactive({})
const pieceRequirementSelections = reactive({})
+const productRequirementSelections = reactive({})
const machineType = computed(() => machine.value?.typeMachine || null)
const componentRequirements = computed(() => machineType.value?.componentRequirements || [])
const pieceRequirements = computed(() => machineType.value?.pieceRequirements || [])
+const productRequirements = computed(() => machineType.value?.productRequirements || [])
const machineHasSkeletonRequirements = computed(() =>
- componentRequirements.value.length > 0 || pieceRequirements.value.length > 0
+ componentRequirements.value.length > 0 ||
+ pieceRequirements.value.length > 0 ||
+ productRequirements.value.length > 0
)
const componentTypeOptions = computed(() => componentTypes.value || [])
@@ -722,6 +880,358 @@ const pieceTypeLabelMap = computed(() => {
return map
})
+const productInventory = computed(() => products.value || [])
+
+const productById = computed(() => {
+ const map = new Map()
+ productInventory.value.forEach((product) => {
+ if (product?.id) {
+ map.set(product.id, product)
+ }
+ })
+ return map
+})
+
+const findProductById = (productId) => {
+ if (!productId) {
+ return null
+ }
+ return productById.value.get(productId) || null
+}
+
+const resolveProductReference = (source) => {
+ if (!source || typeof source !== 'object') {
+ return { product: null, productId: null }
+ }
+
+ const candidateKeys = [
+ null,
+ 'productLink',
+ 'machinePieceLink',
+ 'machineComponentLink',
+ 'machineProductLink',
+ 'originalPiece',
+ 'originalComposant',
+ 'link',
+ 'overrides',
+ 'machineComponentLinkOverrides',
+ 'requirement',
+ 'selection',
+ 'entry',
+ ]
+
+ let product = null
+ let productId = null
+
+ const inspect = (container) => {
+ if (!container || typeof container !== 'object') {
+ return
+ }
+ if (!product && container.product && typeof container.product === 'object') {
+ product = container.product
+ }
+ if (!productId) {
+ const candidate =
+ container.productId ||
+ (container.product && typeof container.product === 'object'
+ ? container.product.id || container.product.productId
+ : null) ||
+ null
+ if (candidate) {
+ productId = candidate
+ }
+ }
+ }
+
+ candidateKeys.forEach((key) => {
+ if (key === null) {
+ inspect(source)
+ } else {
+ inspect(source[key])
+ }
+ })
+
+ if (!product && productId) {
+ product = findProductById(productId) || null
+ }
+
+ if (!product && !productId && source.productName) {
+ const suppliersLabel = typeof source.constructeursLabel === 'string'
+ ? source.constructeursLabel
+ : typeof source.productSuppliers === 'string'
+ ? source.productSuppliers
+ : null
+
+ return {
+ product: {
+ name: source.productName,
+ reference: source.productReference || null,
+ typeProduct: source.productCategory
+ ? { name: source.productCategory }
+ : null,
+ constructeurs: suppliersLabel
+ ? suppliersLabel
+ .split(',')
+ .map((name) => name.trim())
+ .filter((name) => name.length > 0)
+ .map((name) => ({ name }))
+ : undefined,
+ supplierPrice:
+ source.productPrice ??
+ source.productPriceLabel ??
+ source.price ??
+ null,
+ },
+ productId: null,
+ }
+ }
+
+ if (productId && product && product.id && product.id !== productId) {
+ const resolved = findProductById(productId)
+ if (resolved) {
+ product = resolved
+ }
+ }
+
+ return { product: product || null, productId: productId || null }
+}
+
+const getProductSuppliersLabel = (product) => {
+ if (!product) {
+ return null
+ }
+ const suppliers = Array.isArray(product.constructeurs)
+ ? product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean)
+ : []
+ if (suppliers.length > 0) {
+ return suppliers.join(', ')
+ }
+ return null
+}
+
+const getProductPriceLabel = (product) => {
+ if (!product) {
+ return null
+ }
+ const priceValue =
+ product.supplierPrice ??
+ product.prix ??
+ product.price ??
+ null
+ if (priceValue === undefined || priceValue === null) {
+ return null
+ }
+ const numeric = Number(priceValue)
+ if (Number.isNaN(numeric)) {
+ return null
+ }
+ return `${numeric.toFixed(2)} €`
+}
+
+const resolveProductFromSource = (source) => resolveProductReference(source).product
+
+const getProductDisplay = (source) => {
+ if (!source || typeof source !== 'object') {
+ return null
+ }
+
+ const { product, productId } = resolveProductReference(source)
+
+ if (product) {
+ return {
+ name: product.name || product.reference || 'Produit catalogue',
+ reference: product.reference || null,
+ category: product.typeProduct?.name || null,
+ suppliers: getProductSuppliersLabel(product),
+ price: getProductPriceLabel(product),
+ }
+ }
+
+ let fallbackName =
+ source.productName ||
+ source.productLabel ||
+ source.typeProductLabel ||
+ source.typeProduct?.name ||
+ (productId ? `Produit ${productId}` : null)
+ let fallbackReference =
+ source.productReference ||
+ source.reference ||
+ null
+ let fallbackCategory =
+ source.productCategory ||
+ source.typeProductLabel ||
+ source.typeProduct?.name ||
+ null
+ let fallbackSuppliers =
+ source.productSuppliers ||
+ source.constructeursLabel ||
+ source.supplierLabel ||
+ null
+ let fallbackPrice =
+ source.productPriceLabel ||
+ source.productPrice ||
+ source.priceLabel ||
+ source.price ||
+ null
+
+ const structuralCandidates = [
+ source.products,
+ source.productSkeleton,
+ source.definition?.products,
+ source.definition?.productSkeleton,
+ source.definition?.structure?.products,
+ source.definition?.structure?.productSkeleton,
+ source.structure?.products,
+ source.structure?.productSkeleton,
+ source.requirement?.products,
+ source.requirement?.productSkeleton,
+ source.requirement?.structure?.products,
+ source.requirement?.structure?.productSkeleton,
+ source.requirement?.componentSkeleton?.products,
+ source.typeMachineComponentRequirement?.products,
+ source.typeMachineComponentRequirement?.productSkeleton,
+ source.typeMachineComponentRequirement?.structure?.products,
+ source.typeMachineComponentRequirement?.structure?.productSkeleton,
+ source.typeMachineComponentRequirement?.componentSkeleton?.products,
+ source.typeComposant?.products,
+ source.typeComposant?.productSkeleton,
+ source.typeComposant?.structure?.products,
+ source.typeComposant?.structure?.productSkeleton,
+ source.originalComposant?.products,
+ source.originalComposant?.productSkeleton,
+ source.originalComposant?.definition?.products,
+ source.originalComposant?.definition?.productSkeleton,
+ source.originalComposant?.definition?.structure?.products,
+ source.originalComposant?.definition?.structure?.productSkeleton,
+ source.originalComponent?.products,
+ source.originalComponent?.productSkeleton,
+ source.originalComponent?.definition?.products,
+ source.originalComponent?.definition?.productSkeleton,
+ source.originalComponent?.definition?.structure?.products,
+ source.originalComponent?.definition?.structure?.productSkeleton,
+ ]
+
+ const structuralProducts = structuralCandidates
+ .flatMap((candidate) => {
+ if (Array.isArray(candidate)) {
+ return candidate
+ }
+ if (candidate && typeof candidate === 'object' && Array.isArray(candidate.products)) {
+ return candidate.products
+ }
+ return []
+ })
+ .filter((entry) => entry && typeof entry === 'object')
+
+ const structuralProduct = structuralProducts.length ? structuralProducts[0] : null
+
+ const structuralFamilyCode =
+ (structuralProduct && typeof structuralProduct.familyCode === 'string'
+ ? structuralProduct.familyCode
+ : null) ||
+ (typeof source.familyCode === 'string' ? source.familyCode : null)
+
+ if (!fallbackName && structuralProduct) {
+ fallbackName =
+ structuralProduct.typeProductLabel ||
+ structuralProduct.typeProduct?.name ||
+ structuralProduct.reference ||
+ (structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
+ null
+ }
+
+ if (!fallbackReference && structuralProduct?.reference) {
+ fallbackReference = structuralProduct.reference
+ }
+
+ if (!fallbackCategory) {
+ fallbackCategory =
+ structuralProduct?.typeProductLabel ||
+ structuralProduct?.typeProduct?.name ||
+ (structuralFamilyCode ? `Famille ${structuralFamilyCode}` : null) ||
+ null
+ }
+
+ if (!fallbackSuppliers && structuralProduct?.supplierLabel) {
+ fallbackSuppliers = structuralProduct.supplierLabel
+ }
+
+ if (!fallbackSuppliers && Array.isArray(structuralProduct?.constructeurs)) {
+ const supplierNames = structuralProduct.constructeurs
+ .map((constructeur) => constructeur?.name)
+ .filter((name) => typeof name === 'string' && name.trim().length > 0)
+ if (supplierNames.length) {
+ fallbackSuppliers = supplierNames.join(', ')
+ }
+ }
+
+ if (!fallbackPrice && structuralProduct?.priceLabel) {
+ fallbackPrice = structuralProduct.priceLabel
+ }
+ if (!fallbackPrice && structuralProduct?.price) {
+ fallbackPrice = structuralProduct.price
+ }
+
+ if (
+ fallbackName ||
+ fallbackReference ||
+ fallbackCategory ||
+ fallbackSuppliers ||
+ fallbackPrice
+ ) {
+ return {
+ name: fallbackName || 'Produit catalogue',
+ reference: fallbackReference,
+ category: fallbackCategory,
+ suppliers: fallbackSuppliers,
+ price: typeof fallbackPrice === 'number'
+ ? `${fallbackPrice.toFixed(2)} €`
+ : fallbackPrice || null,
+ }
+ }
+
+ return null
+}
+
+const getProductOptionsForRequirement = (requirement) => {
+ const requirementTypeId =
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+
+ return productInventory.value.filter((product) => {
+ if (!product?.id) {
+ return false
+ }
+ if (!requirementTypeId) {
+ return true
+ }
+ const productTypeId =
+ product.typeProductId ||
+ product.typeProduct?.id ||
+ null
+ return productTypeId === requirementTypeId
+ })
+}
+
+const resolveProductRequirementTypeLabel = (requirement, entry) => {
+ const typeId =
+ entry?.typeProductId ||
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+
+ if (typeId) {
+ const typeMatch = productRequirements.value.find(
+ (req) => req.typeProductId === typeId || req.typeProduct?.id === typeId,
+ )
+ if (typeMatch?.typeProduct?.name) {
+ return typeMatch.typeProduct.name
+ }
+ }
+ return requirement?.typeProduct?.name || 'Catégorie non définie'
+}
+
const isPlainObject = (value) => Object.prototype.toString.call(value) === '[object Object]'
const resolveIdentifier = (...candidates) => {
@@ -807,6 +1317,10 @@ const getPieceRequirementEntries = (requirementId) => {
return pieceRequirementSelections[requirementId] || []
}
+const getProductRequirementEntries = (requirementId) => {
+ return productRequirementSelections[requirementId] || []
+}
+
const createComponentSelectionEntry = (requirement, source = null) => {
const link = source?.machineComponentLink || null
@@ -966,6 +1480,59 @@ const createPieceSelectionEntry = (requirement, source = null) => {
return entry
}
+const createProductSelectionEntry = (requirement, source = null) => {
+ const link = source?.machineProductLink || source || null
+
+ return {
+ linkId: resolveIdentifier(
+ link?.id,
+ source?.machineProductLinkId,
+ source?.linkId,
+ ),
+ productId: resolveIdentifier(source?.productId, link?.productId),
+ parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
+ parentComponentLinkId: resolveIdentifier(
+ link?.parentComponentLinkId,
+ source?.parentComponentLinkId,
+ ),
+ parentPieceLinkId: resolveIdentifier(
+ link?.parentPieceLinkId,
+ source?.parentPieceLinkId,
+ ),
+ parentRequirementId: resolveIdentifier(
+ link?.parentRequirementId,
+ source?.parentRequirementId,
+ requirement?.parentRequirementId,
+ ),
+ parentComponentRequirementId: resolveIdentifier(
+ link?.parentComponentRequirementId,
+ source?.parentComponentRequirementId,
+ requirement?.parentComponentRequirementId,
+ ),
+ parentPieceRequirementId: resolveIdentifier(
+ link?.parentPieceRequirementId,
+ source?.parentPieceRequirementId,
+ requirement?.parentPieceRequirementId,
+ ),
+ parentMachineComponentRequirementId: resolveIdentifier(
+ link?.parentMachineComponentRequirementId,
+ source?.parentMachineComponentRequirementId,
+ requirement?.parentMachineComponentRequirementId,
+ ),
+ parentMachinePieceRequirementId: resolveIdentifier(
+ link?.parentMachinePieceRequirementId,
+ source?.parentMachinePieceRequirementId,
+ requirement?.parentMachinePieceRequirementId,
+ ),
+ typeProductId: resolveIdentifier(
+ link?.typeProductId,
+ source?.typeProductId,
+ requirement?.typeProductId,
+ requirement?.typeProduct?.id,
+ ),
+ }
+}
+
const resetSkeletonRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]
@@ -973,6 +1540,9 @@ const resetSkeletonRequirementSelections = () => {
Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key]
})
+ Object.keys(productRequirementSelections).forEach((key) => {
+ delete productRequirementSelections[key]
+ })
}
const addComponentSelectionEntry = (requirement) => {
@@ -1044,6 +1614,51 @@ const setPieceRequirementConstructeur = (requirementId, index, value) => {
entry.definition.constructeurId = ids[0] || null
}
+const addProductSelectionEntry = (requirement) => {
+ const entries = getProductRequirementEntries(requirement.id)
+ const max = requirement.maxCount ?? null
+ if (max !== null && entries.length >= max) {
+ toast.showError(
+ `Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`,
+ )
+ return
+ }
+ productRequirementSelections[requirement.id] = [
+ ...entries,
+ createProductSelectionEntry(requirement),
+ ]
+}
+
+const removeProductSelectionEntry = (requirementId, index) => {
+ const entries = getProductRequirementEntries(requirementId)
+ productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
+}
+
+const setProductRequirementProduct = (requirementId, index, productId) => {
+ const entries = getProductRequirementEntries(requirementId)
+ const entry = entries[index]
+ if (!entry) return
+
+ const normalizedProductId = productId || null
+ entry.productId = normalizedProductId
+
+ if (normalizedProductId) {
+ const product = findProductById(normalizedProductId)
+ entry.typeProductId =
+ product?.typeProductId ||
+ product?.typeProduct?.id ||
+ entry.typeProductId ||
+ null
+ }
+}
+
+const setProductRequirementType = (requirementId, index, value) => {
+ const entries = getProductRequirementEntries(requirementId)
+ const entry = entries[index]
+ if (!entry) return
+ entry.typeProductId = value || entry.typeProductId || null
+}
+
const collectPiecesForSkeleton = () => {
const aggregated = []
machinePieces.value.forEach((piece) => {
@@ -1066,6 +1681,12 @@ const initializeSkeletonRequirementSelections = async () => {
return
}
+ try {
+ await loadProducts()
+ } catch (error) {
+ console.error('Erreur lors du chargement des produits pour le squelette:', error)
+ }
+
;(type.componentRequirements || []).forEach((requirement) => {
const existingComponents = flattenedComponents.value.filter(
(component) => component.typeMachineComponentRequirementId === requirement.id,
@@ -1102,6 +1723,33 @@ const initializeSkeletonRequirementSelections = async () => {
pieceRequirementSelections[requirement.id] = entries
}
})
+
+ const existingProductLinks = Array.isArray(machineProductLinks.value)
+ ? machineProductLinks.value
+ : Array.isArray(machine.value?.productLinks)
+ ? machine.value.productLinks
+ : []
+
+ ;(type.productRequirements || []).forEach((requirement) => {
+ const matches = existingProductLinks.filter((link) => {
+ const requirementId = resolveIdentifier(
+ link?.typeMachineProductRequirementId,
+ link?.requirementId,
+ )
+ return requirementId === requirement.id
+ })
+
+ const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
+
+ const min = requirement.minCount ?? (requirement.required ? 1 : 0)
+ while (entries.length < min) {
+ entries.push(createProductSelectionEntry(requirement))
+ }
+
+ if (entries.length) {
+ productRequirementSelections[requirement.id] = entries
+ }
+ })
} finally {
skeletonEditor.loading = false
}
@@ -1157,10 +1805,72 @@ const changeMachineView = async (view) => {
activeMachineView.value = 'details'
}
+const computeSkeletonProductUsage = (type) => {
+ const usage = new Map()
+
+ const increment = (typeProductId) => {
+ if (!typeProductId) {
+ return
+ }
+ usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
+ }
+
+ for (const requirement of type.componentRequirements || []) {
+ const entries = getComponentRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.composantId) {
+ return
+ }
+ const component = findComponentById(components.value, entry.composantId)
+ const typeProductId =
+ component?.product?.typeProductId ||
+ component?.product?.typeProduct?.id ||
+ null
+ increment(typeProductId)
+ })
+ }
+
+ for (const requirement of type.pieceRequirements || []) {
+ const entries = getPieceRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.pieceId) {
+ return
+ }
+ const piece = findPieceById(entry.pieceId)
+ const typeProductId =
+ piece?.product?.typeProductId ||
+ piece?.product?.typeProduct?.id ||
+ null
+ increment(typeProductId)
+ })
+ }
+
+ for (const requirement of type.productRequirements || []) {
+ const entries = getProductRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.productId) {
+ return
+ }
+ const product = findProductById(entry.productId)
+ const typeProductId =
+ product?.typeProductId ||
+ product?.typeProduct?.id ||
+ entry?.typeProductId ||
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+ increment(typeProductId)
+ })
+ }
+
+ return usage
+}
+
const validateSkeletonSelections = (type) => {
const errors = []
const componentLinksPayload = []
const pieceLinksPayload = []
+ const productLinksPayload = []
for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id)
@@ -1276,6 +1986,93 @@ const validateSkeletonSelections = (type) => {
})
}
+ const productUsage = computeSkeletonProductUsage(type)
+
+ for (const requirement of type.productRequirements || []) {
+ const entries = getProductRequirementEntries(requirement.id)
+ const max = requirement.maxCount ?? null
+
+ if (max !== null && entries.length > max) {
+ errors.push(
+ `Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`,
+ )
+ }
+
+ const typeProductId =
+ requirement.typeProductId ||
+ requirement.typeProduct?.id ||
+ null
+
+ const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
+ const min = requirement.minCount ?? (requirement.required ? 1 : 0)
+
+ if (count < min) {
+ errors.push(
+ `Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`,
+ )
+ }
+
+ if (max !== null && count > max) {
+ errors.push(
+ `Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`,
+ )
+ }
+
+ entries.forEach((entry) => {
+ if (!entry.productId) {
+ errors.push(
+ `Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`,
+ )
+ return
+ }
+
+ const product = findProductById(entry.productId)
+ if (!product) {
+ errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
+ return
+ }
+
+ const productTypeId =
+ product.typeProductId ||
+ product.typeProduct?.id ||
+ entry.typeProductId ||
+ null
+
+ if (
+ typeProductId &&
+ productTypeId &&
+ productTypeId !== typeProductId
+ ) {
+ errors.push(
+ `Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`,
+ )
+ return
+ }
+
+ const payload = {
+ requirementId: requirement.id,
+ productId: entry.productId,
+ }
+
+ if (entry.linkId) {
+ payload.id = entry.linkId
+ payload.linkId = entry.linkId
+ }
+
+ if (entry.typeProductId) {
+ payload.typeProductId = entry.typeProductId
+ }
+
+ Object.assign(
+ payload,
+ extractParentLinkIdentifiers(requirement),
+ extractParentLinkIdentifiers(entry),
+ )
+
+ productLinksPayload.push(payload)
+ })
+ }
+
if (errors.length > 0) {
return { valid: false, error: errors[0] }
}
@@ -1284,6 +2081,7 @@ const validateSkeletonSelections = (type) => {
valid: true,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
+ productLinks: productLinksPayload,
}
}
@@ -1306,6 +2104,7 @@ const applySkeletonReconfigurationResult = async (data) => {
if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
+ machine.value.productLinks = machineProductLinks.value
}
collapseAllComponents()
return
@@ -1322,6 +2121,15 @@ const applySkeletonReconfigurationResult = async (data) => {
pieces.value = transformCustomFields(newPieces)
}
+ const productLinks = resolveLinkArray(data, ['productLinks', 'machineProductLinks'])
+ ?? resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
+ if (Array.isArray(productLinks)) {
+ machineProductLinks.value = productLinks
+ if (machine.value) {
+ machine.value.productLinks = productLinks
+ }
+ }
+
}
const saveSkeletonConfiguration = async () => {
@@ -1330,7 +2138,7 @@ const saveSkeletonConfiguration = async () => {
}
const type = machineType.value
- let payload = { componentLinks: [], pieceLinks: [] }
+ let payload = { componentLinks: [], pieceLinks: [], productLinks: [] }
if (type && machineHasSkeletonRequirements.value) {
const validation = validateSkeletonSelections(type)
@@ -1341,6 +2149,7 @@ const saveSkeletonConfiguration = async () => {
payload = {
componentLinks: validation.componentLinks,
pieceLinks: validation.pieceLinks,
+ productLinks: validation.productLinks,
}
}
@@ -1440,24 +2249,7 @@ const flattenComponents = (list = []) => {
})
}
traverse(list)
- const filtered = result.filter((field) => {
- const key =
- field.customFieldId
- || field.id
- || (field.name ? `${field.name}::${field.type}` : null)
-
- if (!key) {
- return false
- }
-
- if (allowedKeys.size > 0) {
- return allowedKeys.has(key)
- }
-
- return true
- })
-
- return filtered
+ return result
}
const flattenedComponents = computed(() => flattenComponents(components.value))
@@ -1497,7 +2289,10 @@ const componentRequirementGroups = computed(() => {
flattenedComponents.value.forEach((component) => {
const reqId = component.typeMachineComponentRequirementId
if (reqId && map.has(reqId)) {
- map.get(reqId).components.push(component)
+ map.get(reqId).components.push({
+ ...component,
+ __productDisplay: getProductDisplay(component),
+ })
}
})
@@ -1524,6 +2319,7 @@ const pieceRequirementGroups = computed(() => {
...piece,
constructeurs: piece.constructeurs || [],
parentComponentName: null,
+ __productDisplay: getProductDisplay(piece),
})
})
@@ -1535,6 +2331,7 @@ const pieceRequirementGroups = computed(() => {
...piece,
constructeurs: piece.constructeurs || [],
parentComponentName: component.name,
+ __productDisplay: getProductDisplay(piece),
})
})
}
@@ -1553,6 +2350,105 @@ const pieceRequirementGroups = computed(() => {
return groups
})
+const productRequirementGroups = computed(() => {
+ const requirements = machine.value?.typeMachine?.productRequirements || []
+ if (!requirements.length) return []
+
+ const componentAggregates = flattenedComponents.value || []
+ const pieceAggregates = collectPiecesForSkeleton()
+ const links = Array.isArray(machineProductLinks.value) ? machineProductLinks.value : []
+
+ return requirements.map((requirement) => {
+ const typeProductId =
+ requirement.typeProductId ||
+ requirement.typeProduct?.id ||
+ null
+
+ const directProducts = links
+ .filter((link) => {
+ const requirementId = resolveIdentifier(
+ link?.typeMachineProductRequirementId,
+ link?.requirementId,
+ )
+ return requirementId === requirement.id
+ })
+ .map((link) => {
+ const productId = resolveIdentifier(link?.productId, link?.product?.id)
+ const product =
+ productId ? findProductById(productId) : link?.product ?? null
+
+ const supplierLabel = Array.isArray(product?.constructeurs)
+ ? product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ')
+ : link?.constructeursLabel || null
+
+ const priceValue =
+ product?.supplierPrice ??
+ link?.supplierPrice ??
+ null
+
+ let priceLabel = null
+ if (priceValue !== undefined && priceValue !== null) {
+ const numericPrice = Number(priceValue)
+ if (!Number.isNaN(numericPrice)) {
+ priceLabel = `${numericPrice.toFixed(2)} €`
+ }
+ }
+
+ return {
+ id: productId || link?.id || null,
+ name: product?.name || link?.productName || productId || 'Produit',
+ reference: product?.reference || link?.reference || null,
+ supplierLabel,
+ priceLabel,
+ }
+ })
+
+ let componentCount = 0
+ componentAggregates.forEach((component) => {
+ const componentTypeProductId =
+ component?.product?.typeProductId ||
+ component?.product?.typeProduct?.id ||
+ null
+ if (typeProductId && componentTypeProductId === typeProductId) {
+ componentCount += 1
+ }
+ })
+
+ let pieceCount = 0
+ pieceAggregates.forEach((piece) => {
+ const pieceTypeProductId =
+ piece?.product?.typeProductId ||
+ piece?.product?.typeProduct?.id ||
+ null
+ if (typeProductId && pieceTypeProductId === typeProductId) {
+ pieceCount += 1
+ }
+ })
+
+ const totalCount = directProducts.length + componentCount + pieceCount
+
+ return {
+ requirement,
+ directProducts,
+ componentCount,
+ pieceCount,
+ totalCount,
+ }
+ })
+})
+
+const machineDirectProducts = computed(() => {
+ return productRequirementGroups.value.flatMap((group) =>
+ (group.directProducts || []).map((product) => ({
+ ...product,
+ groupLabel:
+ group.requirement.label ||
+ group.requirement.typeProduct?.name ||
+ 'Produit requis',
+ })),
+ )
+})
+
const findComponentById = (items, id) => {
for (const item of items || []) {
if (item.id === id) return item
@@ -2346,6 +3242,8 @@ const transformCustomFields = (pieces) => {
piece.originalPiece?.constructeur,
)
+ const { product: resolvedProduct, productId: resolvedProductId } = resolveProductReference(piece)
+
const constructeursList = resolveConstructeurs(
constructeurIds,
Array.isArray(piece.constructeurs) ? piece.constructeurs : [],
@@ -2356,9 +3254,19 @@ const transformCustomFields = (pieces) => {
piece.originalPiece?.constructeur ? [piece.originalPiece.constructeur] : [],
constructeurs.value,
)
+ const normalizedPiece = {
+ ...piece,
+ product: resolvedProduct || piece.product || null,
+ productId:
+ resolvedProductId ||
+ piece.productId ||
+ piece.product?.id ||
+ null,
+ }
+ const productDisplay = getProductDisplay(normalizedPiece)
return {
- ...piece,
+ ...normalizedPiece,
customFields,
documents: piece.documents || [],
constructeurs: constructeursList,
@@ -2369,6 +3277,7 @@ const transformCustomFields = (pieces) => {
|| piece.typeMachinePieceRequirement?.typePieceId
|| piece.typePiece?.id
|| null,
+ __productDisplay: productDisplay,
}
})
}
@@ -2453,9 +3362,20 @@ const transformComponentCustomFields = (componentsData) => {
actualComponent?.constructeur ? [actualComponent.constructeur] : [],
constructeurs.value,
)
+ const { product: resolvedProduct, productId: resolvedProductId } = resolveProductReference(component)
+ const normalizedComponent = {
+ ...component,
+ product: resolvedProduct || component.product || null,
+ productId:
+ resolvedProductId ||
+ component.productId ||
+ component.product?.id ||
+ null,
+ }
+ const productDisplay = getProductDisplay(normalizedComponent)
return {
- ...component,
+ ...normalizedComponent,
customFields,
pieces,
subComponents,
@@ -2468,6 +3388,7 @@ const transformComponentCustomFields = (componentsData) => {
|| component.typeMachineComponentRequirement?.typeComposantId
|| component.typeComposant?.id
|| null,
+ __productDisplay: productDisplay,
}
})
}
@@ -2718,6 +3639,22 @@ const buildMachineHierarchyFromLinks = (componentLinks = [], pieceLinks = []) =>
skeletonOnly: !pieceId,
}
+ const resolvedProductId = resolveIdentifier(
+ appliedPiece.productId,
+ appliedPiece.product?.id,
+ link.productId,
+ link.product?.id,
+ originalPiece?.productId,
+ originalPiece?.product?.id,
+ )
+
+ const resolvedProduct =
+ appliedPiece.product ||
+ link.product ||
+ originalPiece?.product ||
+ (resolvedProductId ? findProductById(resolvedProductId) : null) ||
+ null
+
const constructeurs = collectConstructeurs(
appliedPiece.constructeurs,
appliedPiece.constructeur,
@@ -2734,6 +3671,12 @@ const buildMachineHierarchyFromLinks = (componentLinks = [], pieceLinks = []) =>
constructeurs,
constructeur: constructeurs[0] || basePiece.constructeur || null,
constructeurId: constructeurs[0]?.id || basePiece.constructeurId || null,
+ productId: resolvedProductId || appliedPiece.productId || null,
+ product: resolvedProduct || appliedPiece.product || null,
+ __productDisplay: getProductDisplay({
+ product: resolvedProduct || appliedPiece.product || null,
+ productId: resolvedProductId || appliedPiece.productId || null,
+ }),
}
}
@@ -2776,6 +3719,22 @@ const buildMachineHierarchyFromLinks = (componentLinks = [], pieceLinks = []) =>
? link.childLinks.map(createComponentNode).filter(Boolean)
: []
+ const resolvedProductId = resolveIdentifier(
+ appliedComponent.productId,
+ appliedComponent.product?.id,
+ link.productId,
+ link.product?.id,
+ originalComponent?.productId,
+ originalComponent?.product?.id,
+ )
+
+ const resolvedProduct =
+ appliedComponent.product ||
+ link.product ||
+ originalComponent?.product ||
+ (resolvedProductId ? findProductById(resolvedProductId) : null) ||
+ null
+
const baseComponent = {
...appliedComponent,
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
@@ -2874,6 +3833,12 @@ const buildMachineHierarchyFromLinks = (componentLinks = [], pieceLinks = []) =>
constructeurs,
constructeur: constructeurs[0] || baseComponent.constructeur || null,
constructeurId: constructeurs[0]?.id || baseComponent.constructeurId || null,
+ productId: resolvedProductId || appliedComponent.productId || null,
+ product: resolvedProduct || appliedComponent.product || null,
+ __productDisplay: getProductDisplay({
+ product: resolvedProduct || appliedComponent.product || null,
+ productId: resolvedProductId || appliedComponent.productId || null,
+ }),
}
}
@@ -2926,16 +3891,21 @@ const applyMachineLinks = (source) => {
const pieceLinks =
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
+ const productLinks =
+ resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
+ resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
- if (componentLinks === null && pieceLinks === null) {
+ if (componentLinks === null && pieceLinks === null && productLinks === null) {
return false
}
const normalizedComponentLinks = componentLinks ?? []
const normalizedPieceLinks = pieceLinks ?? []
+ const normalizedProductLinks = productLinks ?? []
machineComponentLinks.value = normalizedComponentLinks
machinePieceLinks.value = normalizedPieceLinks
+ machineProductLinks.value = normalizedProductLinks
const { components: hierarchy, machinePieces: machineLevelPieces } =
buildMachineHierarchyFromLinks(normalizedComponentLinks, normalizedPieceLinks)
@@ -2982,16 +3952,32 @@ const loadMachineData = async () => {
syncMachineCustomFields()
initMachineFields()
+ if (!productInventory.value.length) {
+ try {
+ await loadProducts()
+ } catch (error) {
+ console.error('Erreur lors du chargement des produits:', error)
+ }
+ }
+
const linksApplied = applyMachineLinks(machineResult.data)
if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
+ machine.value.productLinks = machineProductLinks.value
}
if (!linksApplied) {
components.value = transformComponentCustomFields(machinePayload.components || [])
pieces.value = transformCustomFields(machinePayload.pieces || [])
+ machineProductLinks.value = Array.isArray(machinePayload.productLinks)
+ ? machinePayload.productLinks
+ : []
+ }
+
+ if (machine.value) {
+ machine.value.productLinks = machineProductLinks.value
}
collapseAllComponents()
@@ -3050,17 +4036,25 @@ const updateMachineInfo = async () => {
const updateComponent = async (updatedComponent) => {
try {
- const prixValue = updatedComponent.prix
const constructeurIds = uniqueConstructeurIds(
updatedComponent.constructeurIds,
updatedComponent.constructeurId,
updatedComponent.constructeur,
)
+ const productId = updatedComponent.productId ? String(updatedComponent.productId) : null
+ const prix =
+ updatedComponent.prix !== null &&
+ updatedComponent.prix !== undefined &&
+ String(updatedComponent.prix).trim() !== ''
+ ? Number(updatedComponent.prix)
+ : null
+
const result = await updateComposantApi(updatedComponent.id, {
name: updatedComponent.name,
reference: updatedComponent.reference,
constructeurIds,
- prix: prixValue && prixValue !== '' ? parseFloat(prixValue) : null,
+ prix: Number.isNaN(prix) ? null : prix,
+ productId,
})
if (result.success) {
const transformed = transformComponentCustomFields([result.data])[0]
@@ -3078,11 +4072,20 @@ const updatePieceFromComponent = async (updatedPiece) => {
updatedPiece.constructeurId,
updatedPiece.constructeur,
)
+ const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
+ const prix =
+ updatedPiece.prix !== null &&
+ updatedPiece.prix !== undefined &&
+ String(updatedPiece.prix).trim() !== ''
+ ? Number(updatedPiece.prix)
+ : null
+
const result = await updatePieceApi(updatedPiece.id, {
name: updatedPiece.name,
reference: updatedPiece.reference,
constructeurIds,
- prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null,
+ prix: Number.isNaN(prix) ? null : prix,
+ productId,
})
if (result.success) {
const transformed = transformCustomFields([result.data])[0]
@@ -3113,11 +4116,20 @@ const updatePieceInfo = async (updatedPiece) => {
updatedPiece.constructeurId,
updatedPiece.constructeur,
)
+ const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
+ const prix =
+ updatedPiece.prix !== null &&
+ updatedPiece.prix !== undefined &&
+ String(updatedPiece.prix).trim() !== ''
+ ? Number(updatedPiece.prix)
+ : null
+
const result = await updatePieceApi(updatedPiece.id, {
name: updatedPiece.name,
reference: updatedPiece.reference,
constructeurIds,
- prix: updatedPiece.prix && updatedPiece.prix !== '' ? parseFloat(updatedPiece.prix) : null,
+ prix: Number.isNaN(prix) ? null : prix,
+ productId,
})
if (result.success) {
const transformed = transformCustomFields([result.data])[0]
diff --git a/app/pages/machines/new.vue b/app/pages/machines/new.vue
index d9341d2..c88be5f 100644
--- a/app/pages/machines/new.vue
+++ b/app/pages/machines/new.vue
@@ -90,13 +90,17 @@
Groupes de pièces :
{{ selectedMachineType.pieceRequirements?.length || 0 }}
+
+ Produits requis :
+ {{ selectedMachineType.productRequirements?.length || 0 }}
+
Catégorie :
{{ selectedMachineType.category || 'N/A' }}
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
@@ -304,10 +308,130 @@
+
+
+
+
+
+ Produits catalogue requis
+
+
+
+
+
+
+ {{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
+
+
+ Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
+ · Max : {{ requirement.maxCount ?? '∞' }}
+
+
+ Sélection de produits existants uniquement.
+
+
+
+
+
+ Ajouter
+
+
+
+
+ Aucun produit sélectionné pour ce groupe.
+
+
+
+
+
+ Catégorie appliquée :
+ {{ requirement.typeProduct?.name || 'Non définie' }}
+
+
+
+
+
+
+
+
+
+
+ Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
+
+
+
+
+
+ {{ findProductById(entry.productId)?.name || 'Produit' }}
+
+
+ Référence : {{ findProductById(entry.productId)?.reference || "—" }}
+
+
+ Prix indicatif :
+
+ {{ Number(findProductById(entry.productId)?.supplierPrice).toFixed(2) }} €
+
+
+ —
+
+
+
+ Fournisseurs :
+
+ {{ findProductById(entry.productId)?.constructeurs.map(constructeur => constructeur?.name).filter(Boolean).join(', ') }}
+
+
+ —
+
+
-
+
+
+
@@ -486,6 +610,73 @@
Aucun groupe de pièces à configurer pour ce type.
+
+
+ Produits requis
+
+
+
+
+
+ {{ group.label }}
+
+
+ Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
+ {{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
+
+
+
+
+ Couverture : {{ group.count }}
+
+
+ Direct {{ group.completed }} / {{ group.total || 0 }}
+
+
+
+
+
+
+
+ {{ issue.message }}
+
+
+
+
+
+
+
+
+
+ {{ entry.title }}
+
+
+ {{ entry.subtitle }}
+
+
+
+
+
+
+ Couverture assurée via composants ou pièces liés.
+
+
+
+
{
if (!newMachine.typeMachineId) {
@@ -604,7 +799,12 @@ const machineTypeDescription = (type) => {
}
const componentCount = type.componentRequirements?.length ?? 0
const pieceCount = type.pieceRequirements?.length ?? 0
- parts.push(`${componentCount} composant(s)`, `${pieceCount} pièce(s)`)
+ const productCount = type.productRequirements?.length ?? 0
+ parts.push(
+ `${componentCount} composant(s)`,
+ `${pieceCount} pièce(s)`,
+ `${productCount} produit(s)`
+ )
return parts.join(' • ')
}
@@ -630,6 +830,17 @@ const pieceById = computed(() => {
const componentInventory = computed(() => composants.value || [])
const pieceInventory = computed(() => pieces.value || [])
+const productInventory = computed(() => products.value || [])
+
+const productById = computed(() => {
+ const map = new Map()
+ ;(productInventory.value || []).forEach((product) => {
+ if (product?.id) {
+ map.set(product.id, product)
+ }
+ })
+ return map
+})
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -904,6 +1115,11 @@ const componentOptionDescription = (component) => {
if (machineAssignments.length) {
parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
}
+ const productTypeName = component.product?.typeProduct?.name
+ const productLabel = component.product?.name || component.product?.reference
+ if (productTypeName || productLabel) {
+ parts.push(`Produit: ${productTypeName || productLabel}`)
+ }
return parts.join(' • ')
}
@@ -929,9 +1145,83 @@ const pieceOptionDescription = (piece) => {
if (componentAssignments.length) {
parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
}
+ const productTypeName = piece.product?.typeProduct?.name
+ const productLabel = piece.product?.name || piece.product?.reference
+ if (productTypeName || productLabel) {
+ parts.push(`Produit: ${productTypeName || productLabel}`)
+ }
return parts.join(' • ')
}
+const getProductOptions = (requirement) => {
+ const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
+ return productInventory.value.filter((product) => {
+ if (!product?.id) {
+ return false
+ }
+ if (!requirementTypeId) {
+ return true
+ }
+ const productTypeId =
+ product.typeProductId ||
+ product.typeProduct?.id ||
+ null
+ return productTypeId === requirementTypeId
+ })
+}
+
+const productOptionLabel = (product) => product?.name || product?.reference || 'Produit'
+
+const productOptionDescription = (product) => {
+ if (!product) {
+ return ''
+ }
+ const parts = []
+ if (product.reference) {
+ parts.push(`Réf. ${product.reference}`)
+ }
+ if (product.constructeurs?.length) {
+ const label = product.constructeurs
+ .map((constructeur) => constructeur?.name)
+ .filter(Boolean)
+ .join(', ')
+ if (label) {
+ parts.push(`Fournisseurs: ${label}`)
+ }
+ }
+ if (product.supplierPrice !== undefined && product.supplierPrice !== null) {
+ const price = Number(product.supplierPrice)
+ if (!Number.isNaN(price)) {
+ parts.push(`${price.toFixed(2)} €`)
+ }
+ }
+ return parts.join(' • ')
+}
+
+const getProductTypeIdFromComponent = (component) => {
+ if (!component || typeof component !== 'object') {
+ return null
+ }
+ return (
+ component.product?.typeProductId ||
+ component.product?.typeProduct?.id ||
+ component.productTypeId ||
+ null
+ )
+}
+
+const getProductTypeIdFromPiece = (piece) => {
+ if (!piece || typeof piece !== 'object') {
+ return null
+ }
+ return (
+ piece.product?.typeProductId ||
+ piece.product?.typeProduct?.id ||
+ piece.productTypeId ||
+ null
+ )
+}
+
const setComponentRequirementComponent = (requirement, index, componentId) => {
const entries = getComponentRequirementEntries(requirement.id)
const entry = entries[index]
@@ -971,6 +1261,13 @@ const findPieceById = (id) => {
}
return pieceById.value.get(id) || null
}
+
+const findProductById = (id) => {
+ if (!id) {
+ return null
+ }
+ return productById.value.get(id) || null
+}
const getStatusBadgeClass = (status) => {
if (status === 'ready') {
return 'badge-success'
@@ -1003,6 +1300,7 @@ const resolvePieceRequirementTypeLabel = (requirement, entry) => {
const getComponentRequirementEntries = requirementId => componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = requirementId => pieceRequirementSelections[requirementId] || []
+const getProductRequirementEntries = requirementId => productRequirementSelections[requirementId] || []
const createComponentSelectionEntry = (requirement, source = null) => ({
typeComposantId: requirement?.typeComposantId || requirement?.typeComposant?.id || null,
@@ -1016,6 +1314,170 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
definition: {},
})
+const createProductSelectionEntry = (requirement, source = null) => ({
+ typeProductId:
+ source?.typeProductId ||
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null,
+ productId: source?.productId || null,
+})
+
+const computeProductUsageFromSelections = (type) => {
+ const usage = new Map()
+
+ const increment = (typeProductId) => {
+ if (!typeProductId) {
+ return
+ }
+ usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
+ }
+
+ for (const requirement of type.componentRequirements || []) {
+ const entries = getComponentRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.composantId) {
+ return
+ }
+ const component = findComponentById(entry.composantId)
+ const typeProductId = getProductTypeIdFromComponent(component)
+ increment(typeProductId)
+ })
+ }
+
+ for (const requirement of type.pieceRequirements || []) {
+ const entries = getPieceRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.pieceId) {
+ return
+ }
+ const piece = findPieceById(entry.pieceId)
+ const typeProductId = getProductTypeIdFromPiece(piece)
+ increment(typeProductId)
+ })
+ }
+
+ for (const requirement of type.productRequirements || []) {
+ const entries = getProductRequirementEntries(requirement.id)
+ entries.forEach((entry) => {
+ if (!entry?.productId) {
+ return
+ }
+ const product = findProductById(entry.productId)
+ const typeProductId =
+ product?.typeProductId ||
+ product?.typeProduct?.id ||
+ entry?.typeProductId ||
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+ increment(typeProductId)
+ })
+ }
+
+ return usage
+}
+
+const buildProductRequirementStats = (type) => {
+ const usage = computeProductUsageFromSelections(type)
+
+ const stats = (type.productRequirements || []).map((requirement) => {
+ const typeProductId =
+ requirement.typeProductId ||
+ requirement.typeProduct?.id ||
+ null
+
+ const label =
+ requirement.label?.trim() ||
+ requirement.typeProduct?.name ||
+ requirement.typeProduct?.code ||
+ 'Produit requis'
+
+ const typeName = requirement.typeProduct?.name || 'Non défini'
+
+ const min = requirement.minCount ?? (requirement.required ? 1 : 0)
+ const max = requirement.maxCount ?? null
+ const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
+ const rawEntries = getProductRequirementEntries(requirement.id)
+ const normalizedEntries = rawEntries.map((entry, index) => {
+ const product = entry?.productId ? findProductById(entry.productId) : null
+ const subtitleParts = []
+ if (product?.reference) {
+ subtitleParts.push(`Réf. ${product.reference}`)
+ }
+ if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
+ const price = Number(product.supplierPrice)
+ if (!Number.isNaN(price)) {
+ subtitleParts.push(`${price.toFixed(2)} €`)
+ }
+ }
+ if (Array.isArray(product?.constructeurs) && product.constructeurs.length) {
+ const label = product.constructeurs.map((constructeur) => constructeur?.name).filter(Boolean).join(', ')
+ if (label) {
+ subtitleParts.push(`Fournisseurs: ${label}`)
+ }
+ }
+ return {
+ key: `${requirement.id}-${index}`,
+ status: product ? 'complete' : 'pending',
+ title: product?.name || product?.reference || `Sélection #${index + 1}`,
+ subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
+ }
+ })
+
+ const issues = []
+ if (count < min) {
+ issues.push({
+ message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
+ kind: 'error',
+ anchor: `product-group-${requirement.id}`,
+ })
+ }
+ if (max !== null && count > max) {
+ issues.push({
+ message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
+ kind: 'error',
+ anchor: `product-group-${requirement.id}`,
+ })
+ }
+
+ if (normalizedEntries.length > 0 && normalizedEntries.some((entry) => entry.status !== 'complete')) {
+ issues.push({
+ message: 'Sélectionner un produit pour chaque entrée ajoutée.',
+ kind: 'error',
+ anchor: `product-group-${requirement.id}`,
+ })
+ }
+
+ const completed = normalizedEntries.filter((entry) => entry.status === 'complete').length
+ const total = normalizedEntries.length
+
+ const status = issues.some((issue) => issue.kind === 'error')
+ ? 'error'
+ : issues.some((issue) => issue.kind === 'warning')
+ ? 'warning'
+ : 'ready'
+
+ return {
+ id: requirement.id,
+ requirement,
+ label,
+ typeName,
+ count,
+ min,
+ max,
+ completed,
+ total,
+ entries: normalizedEntries,
+ issues,
+ allowNewModels: requirement.allowNewModels ?? true,
+ status,
+ }
+ })
+
+ return { stats, usage }
+}
+
const clearRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]
@@ -1023,6 +1485,9 @@ const clearRequirementSelections = () => {
Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key]
})
+ Object.keys(productRequirementSelections).forEach((key) => {
+ delete productRequirementSelections[key]
+ })
}
const addComponentSelectionEntry = (requirement) => {
@@ -1061,6 +1526,51 @@ const removePieceSelectionEntry = (requirementId, index) => {
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
+const addProductSelectionEntry = (requirement) => {
+ const entries = getProductRequirementEntries(requirement.id)
+ const max = requirement.maxCount ?? null
+ if (max !== null && entries.length >= max) {
+ toast.showError(`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || requirement.typeProduct?.name || 'ce groupe'}`)
+ return
+ }
+ productRequirementSelections[requirement.id] = [
+ ...entries,
+ createProductSelectionEntry(requirement),
+ ]
+}
+
+const removeProductSelectionEntry = (requirementId, index) => {
+ const entries = getProductRequirementEntries(requirementId)
+ productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
+}
+
+const setProductRequirementProduct = (requirement, index, productId) => {
+ const entries = getProductRequirementEntries(requirement.id)
+ const entry = entries[index]
+ if (!entry) {
+ return
+ }
+
+ const normalizedProductId = productId || null
+ entry.productId = normalizedProductId
+
+ if (normalizedProductId) {
+ const product = findProductById(normalizedProductId)
+ entry.typeProductId =
+ product?.typeProductId ||
+ product?.typeProduct?.id ||
+ entry.typeProductId ||
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+ } else {
+ entry.typeProductId =
+ requirement?.typeProductId ||
+ requirement?.typeProduct?.id ||
+ null
+ }
+}
+
const extractParentIdentifiers = (source) => {
if (!isPlainObject(source)) {
return {}
@@ -1113,6 +1623,7 @@ const validateRequirementSelections = (type) => {
const errors = []
const componentLinksPayload = []
const pieceLinksPayload = []
+ const productLinksPayload = []
for (const requirement of type.componentRequirements || []) {
const entries = getComponentRequirementEntries(requirement.id)
@@ -1216,6 +1727,58 @@ const validateRequirementSelections = (type) => {
})
}
+ const { stats: productStats } = buildProductRequirementStats(type)
+ for (const requirement of type.productRequirements || []) {
+ const entries = getProductRequirementEntries(requirement.id)
+ const max = requirement.maxCount ?? null
+
+ if (max !== null && entries.length > max) {
+ errors.push(`Le groupe "${requirement.label || requirement.typeProduct?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
+ }
+
+ entries.forEach((entry) => {
+ if (!entry.productId) {
+ errors.push(`Sélectionner un produit pour "${requirement.label || requirement.typeProduct?.name || 'Produits'}".`)
+ return
+ }
+
+ const product = findProductById(entry.productId)
+ if (!product) {
+ errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
+ return
+ }
+
+ const requiredTypeId = requirement.typeProductId || requirement.typeProduct?.id || null
+ const productTypeId =
+ product.typeProductId ||
+ product.typeProduct?.id ||
+ entry.typeProductId ||
+ null
+
+ if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
+ errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
+ return
+ }
+
+ const payload = {
+ requirementId: requirement.id,
+ productId: entry.productId,
+ }
+
+ Object.assign(payload, extractParentIdentifiers(requirement), extractParentIdentifiers(entry))
+
+ productLinksPayload.push(payload)
+ })
+ }
+
+ productStats.forEach((stat) => {
+ stat.issues
+ .filter((issue) => issue.kind === 'error')
+ .forEach((issue) => {
+ errors.push(issue.message)
+ })
+ })
+
if (errors.length > 0) {
return { valid: false, error: errors[0] }
}
@@ -1224,6 +1787,7 @@ const validateRequirementSelections = (type) => {
valid: true,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
+ productLinks: productLinksPayload,
}
}
@@ -1425,20 +1989,24 @@ const machinePreview = computed(() => {
issues,
completed,
total: entries.length,
- status
- }
- })
+ status
+ }
+})
+
+ const { stats: productGroups } = buildProductRequirementStats(type)
const aggregatedIssues = [
...baseIssues.map(issue => ({ ...issue, scope: 'Informations générales' })),
...componentGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
- ...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label })))
+ ...pieceGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
+ ...productGroups.flatMap(group => group.issues.map(issue => ({ ...issue, scope: group.label }))),
]
const statuses = [
baseStatus,
...componentGroups.map(group => group.status),
- ...pieceGroups.map(group => group.status)
+ ...pieceGroups.map(group => group.status),
+ ...productGroups.map(group => group.status),
]
const overallStatus = statuses.includes('error')
@@ -1455,11 +2023,14 @@ const machinePreview = computed(() => {
},
componentGroups,
pieceGroups,
+ productGroups,
type: {
name: type.name,
category: type.category || null,
hasStructuredDefinition:
- (type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0
+ (type.componentRequirements?.length || 0) > 0 ||
+ (type.pieceRequirements?.length || 0) > 0 ||
+ (type.productRequirements?.length || 0) > 0
},
status: overallStatus,
ready: overallStatus === 'ready',
@@ -1508,6 +2079,7 @@ const handleIssueClick = (issue) => {
const initializeRequirementSelections = (type) => {
const componentRequirements = type.componentRequirements || []
const pieceRequirements = type.pieceRequirements || []
+ const productRequirements = type.productRequirements || []
componentRequirements.forEach((requirement) => {
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
@@ -1528,6 +2100,19 @@ const initializeRequirementSelections = (type) => {
pieceRequirementSelections[requirement.id] = []
}
})
+
+ productRequirements.forEach((requirement) => {
+ const min = requirement.minCount ?? (requirement.required ? 1 : 0)
+ const initialCount = Math.max(min, requirement.required ? 1 : 0)
+ if (initialCount > 0) {
+ productRequirementSelections[requirement.id] = Array.from(
+ { length: initialCount },
+ () => createProductSelectionEntry(requirement),
+ )
+ } else {
+ productRequirementSelections[requirement.id] = []
+ }
+ })
}
const finalizeMachineCreation = async () => {
@@ -1553,10 +2138,14 @@ const finalizeMachineCreation = async () => {
typeMachineId: type.id
}
- const hasRequirements = (type.componentRequirements?.length || 0) > 0 || (type.pieceRequirements?.length || 0) > 0
+ const hasRequirements =
+ (type.componentRequirements?.length || 0) > 0 ||
+ (type.pieceRequirements?.length || 0) > 0 ||
+ (type.productRequirements?.length || 0) > 0
let componentLinks = []
let pieceLinks = []
+ let productLinks = []
if (hasRequirements) {
const validationResult = validateRequirementSelections(type)
@@ -1566,6 +2155,7 @@ const finalizeMachineCreation = async () => {
}
componentLinks = validationResult.componentLinks
pieceLinks = validationResult.pieceLinks
+ productLinks = validationResult.productLinks
}
const payload = {
@@ -1573,7 +2163,8 @@ const finalizeMachineCreation = async () => {
...(hasRequirements
? {
componentLinks,
- pieceLinks
+ pieceLinks,
+ productLinks
}
: {})
}
@@ -1621,7 +2212,8 @@ onMounted(async () => {
loadSites(),
loadMachineTypes(),
loadComposants(),
- loadPieces()
+ loadPieces(),
+ loadProducts()
])
})
diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue
index 21f350c..8c4923d 100644
--- a/app/pages/pieces-catalog.vue
+++ b/app/pages/pieces-catalog.vue
@@ -93,6 +93,7 @@
Aperçu
Nom
Référence
+ Type de pièce
Actions
@@ -106,6 +107,7 @@
{{ piece.name || 'Pièce sans nom' }}
{{ piece.reference || '—' }}
+ {{ resolvePieceType(piece) }}
) => {
return 'Aperçu du document'
}
+const resolvePieceType = (piece: Record) => {
+ const type = piece?.typePiece
+ if (type?.name) {
+ return type.name
+ }
+ if (piece?.typePieceLabel) {
+ return piece.typePieceLabel
+ }
+ return '—'
+}
+
const resolveDeleteGuard = (piece: Record) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks)
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index 2fa2de8..6811205 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -123,6 +123,36 @@
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
@@ -356,6 +386,7 @@ import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
+import ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
@@ -366,7 +397,7 @@ import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
-import type { PieceModelStructure } from '~/shared/types/inventory'
+import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType {
@@ -411,6 +442,7 @@ const editionForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
+ productId: null as string | null,
})
const customFieldInputs = ref([])
@@ -542,6 +574,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const structureProducts = computed(() =>
+ getStructureProducts(selectedType.value?.structure ?? null),
+)
+
+const requiresProductSelection = computed(() => structureProducts.value.length > 0)
+
+const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
+
+const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
+ if (!requirement) {
+ return `Produit ${index + 1}`
+ }
+ const parts: string[] = []
+ if (requirement.role) {
+ parts.push(requirement.role)
+ }
+ if (requirement.typeProductLabel) {
+ parts.push(requirement.typeProductLabel)
+ } else if (requirement.typeProductId) {
+ parts.push(`Catégorie #${requirement.typeProductId}`)
+ }
+ if (requirement.familyCode) {
+ parts.push(`Famille ${requirement.familyCode}`)
+ }
+ if (parts.length === 0) {
+ parts.push(`Produit ${index + 1}`)
+ }
+ return parts.join(' • ')
+}
+
+const productRequirementDescriptions = computed(() =>
+ structureProducts.value.map((requirement, index) =>
+ describeProductRequirement(requirement, index),
+ ),
+)
+
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
@@ -554,12 +622,15 @@ const requiredCustomFieldsFilled = computed(() =>
}),
)
-const canSubmit = computed(() => Boolean(
- piece.value &&
- editionForm.name &&
- requiredCustomFieldsFilled.value &&
- !saving.value,
-))
+const canSubmit = computed(() =>
+ Boolean(
+ piece.value &&
+ editionForm.name &&
+ requiredCustomFieldsFilled.value &&
+ (!requiresProductSelection.value || editionForm.productId) &&
+ !saving.value,
+ ),
+)
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
@@ -610,6 +681,7 @@ watch(
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
+ editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
customFieldInputs.value = buildCustomFieldInputs(
currentType?.structure ?? null,
@@ -636,6 +708,11 @@ const submitEdition = async () => {
return
}
+ if (requiresProductSelection.value && !editionForm.productId) {
+ toast.showError('Sélectionnez un produit conforme au squelette.')
+ return
+ }
+
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
@@ -650,6 +727,12 @@ const submitEdition = async () => {
payload.reference = reference ? reference : null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
+ const selectedProductId =
+ typeof editionForm.productId === 'string'
+ ? editionForm.productId.trim()
+ : ''
+ payload.productId = selectedProductId || null
+
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
@@ -841,7 +924,11 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue)
}
-const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
+
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue
index 8167f00..4d57437 100644
--- a/app/pages/pieces/create.vue
+++ b/app/pages/pieces/create.vue
@@ -71,10 +71,10 @@
Fournisseur
@@ -96,6 +96,36 @@
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
@@ -253,6 +283,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
+import ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
@@ -261,7 +292,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
-import type { PieceModelStructure } from '~/shared/types/inventory'
+import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType {
@@ -286,6 +317,7 @@ const creationForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
+ productId: null as string | null,
})
const lastSuggestedName = ref('')
@@ -332,6 +364,42 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const structureProducts = computed(() =>
+ getStructureProducts(selectedType.value?.structure ?? null),
+)
+
+const requiresProductSelection = computed(() => structureProducts.value.length > 0)
+
+const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
+
+const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
+ if (!requirement) {
+ return `Produit ${index + 1}`
+ }
+ const parts: string[] = []
+ if (requirement.role) {
+ parts.push(requirement.role)
+ }
+ if (requirement.typeProductLabel) {
+ parts.push(requirement.typeProductLabel)
+ } else if (requirement.typeProductId) {
+ parts.push(`Catégorie #${requirement.typeProductId}`)
+ }
+ if (requirement.familyCode) {
+ parts.push(`Famille ${requirement.familyCode}`)
+ }
+ if (parts.length === 0) {
+ parts.push(`Produit ${index + 1}`)
+ }
+ return parts.join(' • ')
+}
+
+const productRequirementDescriptions = computed(() =>
+ structureProducts.value.map((requirement, index) =>
+ describeProductRequirement(requirement, index),
+ ),
+)
+
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
@@ -343,6 +411,7 @@ watch(selectedType, (type) => {
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
+ creationForm.productId = null
})
const requiredCustomFieldsFilled = computed(() =>
@@ -357,12 +426,15 @@ const requiredCustomFieldsFilled = computed(() =>
}),
)
-const canSubmit = computed(() => Boolean(
- selectedType.value &&
- creationForm.name &&
- requiredCustomFieldsFilled.value &&
- !submitting.value,
-))
+const canSubmit = computed(() =>
+ Boolean(
+ selectedType.value &&
+ creationForm.name &&
+ requiredCustomFieldsFilled.value &&
+ (!requiresProductSelection.value || creationForm.productId) &&
+ !submitting.value,
+ ),
+)
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
@@ -377,13 +449,18 @@ const toFieldString = (value: unknown): string => {
return ''
}
-const getStructureCustomFields = (structure: PieceModelStructure | null) => Array.isArray(structure?.customFields) ? structure.customFields : []
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
+
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
const clearCreationForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
+ creationForm.productId = null
lastSuggestedName.value = ''
}
@@ -392,6 +469,12 @@ const submitCreation = async () => {
toast.showError('Sélectionnez une catégorie de pièce.')
return
}
+
+ if (requiresProductSelection.value && !creationForm.productId) {
+ toast.showError('Sélectionnez un produit conforme au squelette.')
+ return
+ }
+
const payload: Record
= {
name: creationForm.name.trim(),
typePieceId: selectedType.value.id,
@@ -406,6 +489,14 @@ const submitCreation = async () => {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
+ const selectedProductId =
+ typeof creationForm.productId === 'string'
+ ? creationForm.productId.trim()
+ : ''
+ if (selectedProductId) {
+ payload.productId = selectedProductId
+ }
+
const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined
diff --git a/app/pages/product-catalog.vue b/app/pages/product-catalog.vue
new file mode 100644
index 0000000..65b929a
--- /dev/null
+++ b/app/pages/product-catalog.vue
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+ Recherche
+
+
+
+ Trier par
+
+ Nom
+ Date de création
+
+
+
+ Ordre
+
+ Ascendant
+ Descendant
+
+
+
+
+ {{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
+
+
+
+
+
+
+
+
+
+ Impossible de charger les produits
+ {{ errorMessage }}
+
+
+ Réessayer
+
+
+
+
+ Chargement du catalogue…
+
+
+
+ Aucun produit n'a encore été enregistré.
+
+
+
+ Aucun produit ne correspond à votre recherche.
+
+
+
+
+
+
+ Nom
+ Référence
+ Type de produit
+ Fournisseurs
+ Prix indicatif
+ Actions
+
+
+
+
+ {{ product.name }}
+ {{ product.reference || '—' }}
+ {{ product.typeProduct?.name || '—' }}
+
+
+ {{ formatConstructeurs(product.constructeurs) }}
+
+ —
+
+
+ {{ formatPrice(product.supplierPrice) }}
+
+
+
+ Modifier
+
+
+ Supprimer
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/product-category/[id]/edit.vue b/app/pages/product-category/[id]/edit.vue
new file mode 100644
index 0000000..421db16
--- /dev/null
+++ b/app/pages/product-category/[id]/edit.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
{{ title }}
+
+ Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
+
+
+
+ Retour au catalogue
+
+
+
+
+
+
+
+ Chargement de la catégorie…
+
+
+
+
+
+
+
diff --git a/app/pages/product-category/index.vue b/app/pages/product-category/index.vue
new file mode 100644
index 0000000..9e64e3f
--- /dev/null
+++ b/app/pages/product-category/index.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/pages/product-category/new.vue b/app/pages/product-category/new.vue
new file mode 100644
index 0000000..fc6e8b8
--- /dev/null
+++ b/app/pages/product-category/new.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
Nouvelle catégorie de produit
+
+ Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
+
+
+
+ Retour au catalogue
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue
new file mode 100644
index 0000000..5282a83
--- /dev/null
+++ b/app/pages/product/[id]/edit.vue
@@ -0,0 +1,747 @@
+
+
+
+
+
+
Chargement du produit…
+
+
+
+
+
+
Produit introuvable
+
+ Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
+
+
+
+
+ Retour au catalogue
+
+
+
+
+
+
+
+
+
+
+
+
+ Nom du produit
+
+
+
+
+
+
+
+
+ Référence
+
+
+
+
+
+
+ Fournisseurs
+
+
+
+
+
+
+
+
+ Prix fournisseur indicatif (€)
+
+
+
+
+
+
+
+
+
Champs définis par la catégorie
+
+ {{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
+
+
+
{{ structurePreview }}
+
+
+
+
+
+
+
+
+
+
+
+ Téléversement des documents en cours…
+
+
+ Chargement des documents…
+
+
+
+
+
+
+
+
+
+
+
+ {{ document.name }}
+
+
+ {{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
+
+
+
+
+
+ Consulter
+
+
+ Télécharger
+
+
+ Supprimer
+
+
+
+
+
+ Aucun document n'est associé à ce produit pour le moment.
+
+
+
+
+
+ Annuler
+
+
+
+ Enregistrer les modifications
+
+
+
+ Merci de renseigner tous les champs personnalisés obligatoires.
+
+
+
+
+
+
+
diff --git a/app/pages/product/create.vue b/app/pages/product/create.vue
new file mode 100644
index 0000000..a030fb4
--- /dev/null
+++ b/app/pages/product/create.vue
@@ -0,0 +1,518 @@
+
+
+
+
+
+
+
+
+
+
+
+ Nom du produit
+
+
+
+
+
+
+
+
+ Référence
+
+
+
+
+
+
+ Fournisseurs
+
+
+
+
+
+
+
+
+ Prix fournisseur indicatif (€)
+
+
+
+
+
+
+
+
+
Squelette sélectionné
+
+ {{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
+
+
+
{{ formatProductStructurePreview(selectedType.structure) }}
+
+
+
+ Cette catégorie ne définit pas encore de champs personnalisés.
+
+
+
+
+
+
+
+
+
+
+
+ Téléversement des documents en cours…
+
+
+
+
+
+ Annuler
+
+
+
+ Créer le produit
+
+
+
+ Merci de renseigner tous les champs personnalisés obligatoires.
+
+
+
+
+
+
+
diff --git a/app/pages/type/[id].vue b/app/pages/type/[id].vue
index fc78acb..5566943 100644
--- a/app/pages/type/[id].vue
+++ b/app/pages/type/[id].vue
@@ -93,6 +93,38 @@
+
+
+
+
+ Produits requis
+
+
+
+
+
+
+ {{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
+
+
+ Type : {{ requirement.typeProduct?.name || 'Non défini' }}
+
+
+
+ Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }} •
+ Max {{ toDisplayCount(requirement.maxCount, '∞') }}
+
+
+
+ {{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
+
+
+
+
@@ -141,6 +173,7 @@ const typePageTitle = computed(() => {
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
+const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) {
diff --git a/app/pages/type/edit/[id].vue b/app/pages/type/edit/[id].vue
index dcaba98..824b9db 100644
--- a/app/pages/type/edit/[id].vue
+++ b/app/pages/type/edit/[id].vue
@@ -70,7 +70,8 @@ const editedType = ref({
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
- pieceRequirements: []
+ pieceRequirements: [],
+ productRequirements: []
})
const parseOptions = (field = {}) => {
@@ -140,6 +141,21 @@ const normalizePieceRequirements = (requirements = []) =>
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
+const normalizeProductRequirements = (requirements = []) =>
+ requirements
+ .filter(req => req?.typeProductId)
+ .map((req, index) => ({
+ typeProductId: req.typeProductId,
+ label: req.label?.trim() ? req.label.trim() : undefined,
+ minCount: toIntegerOrNull(req.minCount, 0),
+ maxCount: toIntegerOrNull(req.maxCount, null),
+ required: req.required ?? false,
+ allowNewModels: req.allowNewModels ?? true,
+ orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
+ }))
+ .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
+ .map((req, index) => ({ ...req, orderIndex: index }))
+
const saveChanges = async () => {
try {
saving.value = true
@@ -151,7 +167,8 @@ const saveChanges = async () => {
...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
- pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements)
+ pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
+ productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
}
const result = await updateMachineType(type.value.id, updatedType)
@@ -192,7 +209,8 @@ onMounted(async () => {
maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [],
- pieceRequirements: type.value.pieceRequirements || []
+ pieceRequirements: type.value.pieceRequirements || [],
+ productRequirements: type.value.productRequirements || [],
}
} else {
console.error('Failed to load type:', result.error)
diff --git a/app/services/modelTypes.ts b/app/services/modelTypes.ts
index 0a046ad..2c54e6f 100644
--- a/app/services/modelTypes.ts
+++ b/app/services/modelTypes.ts
@@ -3,11 +3,16 @@ import type { FetchOptions } from 'ofetch';
import type {
ComponentModelStructure,
PieceModelStructure,
+ ProductModelStructure,
} from '~/shared/types/inventory';
-export type ModelCategory = 'COMPONENT' | 'PIECE';
+export type ModelCategory = 'COMPONENT' | 'PIECE' | 'PRODUCT';
-export type ModelTypeStructure = ComponentModelStructure | PieceModelStructure | null;
+export type ModelTypeStructure =
+ | ComponentModelStructure
+ | PieceModelStructure
+ | ProductModelStructure
+ | null;
export interface BaseModelTypePayload {
name: string;
@@ -26,7 +31,15 @@ export interface PieceModelTypePayload extends BaseModelTypePayload {
structure?: PieceModelStructure | null;
}
-export type ModelTypePayload = ComponentModelTypePayload | PieceModelTypePayload;
+export interface ProductModelTypePayload extends BaseModelTypePayload {
+ category: 'PRODUCT';
+ structure?: ProductModelStructure | null;
+}
+
+export type ModelTypePayload =
+ | ComponentModelTypePayload
+ | PieceModelTypePayload
+ | ProductModelTypePayload;
export interface ModelType extends BaseModelTypePayload {
id: string;
diff --git a/app/shared/modelUtils.ts b/app/shared/modelUtils.ts
index fbe1522..8e0fcef 100644
--- a/app/shared/modelUtils.ts
+++ b/app/shared/modelUtils.ts
@@ -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
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 = {}
+ 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 = {}
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)) {
- 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)
diff --git a/app/shared/types/inventory.ts b/app/shared/types/inventory.ts
index d842b5b..c0d3504 100644
--- a/app/shared/types/inventory.ts
+++ b/app/shared/types/inventory.ts
@@ -20,18 +20,28 @@ export interface ComponentModelPiece {
role?: string
}
+export interface ComponentModelProduct {
+ typeProductId?: string
+ typeProductLabel?: string
+ reference?: string
+ familyCode?: string
+ role?: string
+}
+
export interface ComponentModelStructureNode {
typeComposantId?: string
typeComposantLabel?: string
modelId?: string
familyCode?: string
alias?: string
+ products?: ComponentModelProduct[]
subcomponents: ComponentModelStructureNode[]
}
export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[]
+ products: ComponentModelProduct[]
}
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
@@ -44,8 +54,17 @@ export interface PieceModelCustomField {
orderIndex?: number
}
+export interface PieceModelProduct {
+ typeProductId?: string
+ typeProductLabel?: string
+ reference?: string
+ familyCode?: string
+ role?: string
+}
+
export interface PieceModelStructure {
customFields: PieceModelCustomField[]
+ products?: PieceModelProduct[]
[key: string]: unknown
}
@@ -55,9 +74,13 @@ export interface PieceModelStructureEditorField extends PieceModelCustomField {
export interface PieceModelStructureForEditor {
customFields: PieceModelStructureEditorField[]
+ products?: PieceModelProduct[]
[key: string]: unknown
}
+export type ProductModelCustomField = PieceModelCustomField
+export type ProductModelStructure = PieceModelStructure
+
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record => {
@@ -252,9 +275,15 @@ export const componentModelStructureValidator = {
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [],
pieces: [],
+ products: [],
subcomponents: [],
})
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
customFields: [],
+ products: [],
+})
+
+export const createEmptyProductModelStructure = (): ProductModelStructure => ({
+ customFields: [],
})
diff --git a/app/utils/printTemplates/machineReport.js b/app/utils/printTemplates/machineReport.js
index 0e3b898..a2273bf 100644
--- a/app/utils/printTemplates/machineReport.js
+++ b/app/utils/printTemplates/machineReport.js
@@ -4,6 +4,23 @@ import {
formatConstructeurContact,
} from '~/shared/constructeurUtils'
+const currencyFormatter = new Intl.NumberFormat('fr-FR', {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'narrowSymbol',
+})
+
+const formatCurrency = (value) => {
+ if (value === undefined || value === null || value === '') {
+ return null
+ }
+ const number = Number(value)
+ if (Number.isNaN(number)) {
+ return null
+ }
+ return currencyFormatter.format(number)
+}
+
const formatSize = (size) => {
if (size === undefined || size === null) { return '—' }
if (size === 0) { return '0 B' }
@@ -55,6 +72,49 @@ const renderPrintDocuments = (documents = [], title, sectionClass = 'print-secti
`
}
+const renderPrintProductSummary = (product, title = 'Produit catalogue', sectionClass = 'print-piece-section') => {
+ if (!product) { return '' }
+
+ const infoEntries = [
+ { label: 'Nom', value: product.name || '—' },
+ { label: 'Référence', value: product.reference || '—' },
+ { label: 'Catégorie', value: product.typeName || '—' },
+ {
+ label: 'Prix indicatif',
+ value: product.supplierPrice || '—',
+ },
+ {
+ label: 'Fournisseur(s)',
+ value: product.constructeurs?.length
+ ? product.constructeurs.map((constructeur) => constructeur.name).filter(Boolean).join(', ') || '—'
+ : '—',
+ },
+ ]
+
+ const infoMarkup = infoEntries
+ .map((field) => `${field.label} ${field.value || '—'}
`)
+ .join('')
+
+ const customFieldsBlock = product.customFields?.length
+ ? renderPrintCustomFields(product.customFields, 'Champs personnalisés du produit', 'print-subsection')
+ : ''
+
+ const documentsBlock = product.documents?.length
+ ? renderPrintDocuments(product.documents, 'Documents du produit', 'print-subsection')
+ : ''
+
+ return `
+
+
${title}
+
+ ${infoMarkup}
+
+ ${customFieldsBlock}
+ ${documentsBlock}
+
+ `
+}
+
const renderPrintPieces = (
pieces = [],
title = 'Pièces indépendantes',
@@ -94,6 +154,8 @@ const renderPrintPieces = (
.join('')}`
: ''
+ const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
+
return `
+ ${productBlock}
${customFieldsBlock}
${documentsBlock}
@@ -154,6 +217,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
const currentIndex = [...indexPath, idx + 1]
const indexLabel = currentIndex.join('.')
+ const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
return `
@@ -162,6 +226,7 @@ const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
${component.description ? `
${component.description}
` : ''}
${badges.length ? `
${badges.map(badge => `${badge} `).join('')}
` : ''}
+ ${productBlock}
${renderPrintCustomFields(
component.customFields,
'Champs personnalisés',
@@ -233,7 +298,28 @@ const normalizeConstructeurList = (...sources) => {
.filter(Boolean)
}
+const normalizeProduct = (product) => {
+ if (!product) { return null }
+ const constructeurs = normalizeConstructeurList(
+ product.constructeurs,
+ product.constructeur,
+ product.constructeurIds,
+ product.constructeurId,
+ )
+ return {
+ id: product.id || null,
+ name: product.name || 'Produit sans nom',
+ reference: product.reference || '',
+ supplierPrice: formatCurrency(product.supplierPrice),
+ typeName: product.typeProduct?.name || null,
+ constructeurs,
+ customFields: normalizeCustomFields(product.customFieldValues || []),
+ documents: normalizeDocuments(product.documents || []),
+ }
+}
+
const normalizePiece = piece => {
+ const rawProduct = piece.product || null
const constructeurs = normalizeConstructeurList(
piece.constructeurs,
piece.constructeur,
@@ -241,7 +327,12 @@ const normalizePiece = piece => {
piece.originalPiece?.constructeur,
piece.constructeurIds,
piece.constructeurId,
+ rawProduct?.constructeurs,
+ rawProduct?.constructeur,
+ rawProduct?.constructeurIds,
+ rawProduct?.constructeurId,
)
+ const product = normalizeProduct(rawProduct)
return {
id: piece.id,
@@ -252,11 +343,13 @@ const normalizePiece = piece => {
documents: normalizeDocuments(piece.documents || []),
constructeurs,
constructeur: constructeurs[0] || null,
+ product,
indexPath: piece.indexPath || null
}
}
const normalizeComponent = component => {
+ const rawProduct = component.product || null
const constructeurs = normalizeConstructeurList(
component.constructeurs,
component.constructeur,
@@ -264,7 +357,12 @@ const normalizeComponent = component => {
component.originalComposant?.constructeur,
component.constructeurIds,
component.constructeurId,
+ rawProduct?.constructeurs,
+ rawProduct?.constructeur,
+ rawProduct?.constructeurIds,
+ rawProduct?.constructeurId,
)
+ const product = normalizeProduct(rawProduct)
return {
id: component.id,
@@ -276,6 +374,7 @@ const normalizeComponent = component => {
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
constructeurs,
constructeur: constructeurs[0] || null,
+ product,
}
}