diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js
index 037ff33..13ec585 100644
--- a/app/composables/usePieces.js
+++ b/app/composables/usePieces.js
@@ -56,6 +56,15 @@ export function usePieces () {
piece.productId = productId
}
}
+ const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
+ if (productIds.length === 0 && piece.productId) {
+ piece.productIds = [piece.productId]
+ } else if (productIds.length > 0) {
+ piece.productIds = productIds.map((id) => String(id))
+ if (!piece.productId) {
+ piece.productId = piece.productIds[0] || null
+ }
+ }
const ids = uniqueConstructeurIds(
piece.constructeurIds,
piece.constructeurs,
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index d0f38a5..d550d27 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -176,6 +176,18 @@
+
+
Produits imposés
+
+ -
+ {{ resolveProductLabel(product) }}
+
+
+
+
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
@@ -198,6 +210,50 @@
+
+
+
+
+
+
Pièces choisies
+
+ -
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Produits choisis
+
+ -
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Sous-composants choisis
+
+ -
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Champs personnalisés
@@ -401,6 +457,9 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
+import { useProductTypes } from '~/composables/useProductTypes'
+import { usePieces } from '~/composables/usePieces'
+import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
@@ -436,7 +495,10 @@ const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
-const { updateComposant } = useComposants()
+const { productTypes, loadProductTypes } = useProductTypes()
+const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
+const { pieces, loadPieces } = usePieces()
+const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
@@ -512,6 +574,36 @@ const pieceTypeLabelMap = computed(() => ({
),
...fetchedPieceTypeMap.value,
}))
+const fetchedProductTypeMap = ref>({})
+const productTypeLabelMap = computed(() => ({
+ ...Object.fromEntries(
+ (productTypes.value || [])
+ .filter((type: any) => type?.id)
+ .map((type: any) => [type.id, type.name || type.code || '']),
+ ),
+ ...fetchedProductTypeMap.value,
+}))
+const pieceCatalogMap = computed(() =>
+ new Map(
+ (pieces.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
+const productCatalogMap = computed(() =>
+ new Map(
+ (products.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
+const componentCatalogMap = computed(() =>
+ new Map(
+ (componentCatalogRef.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
@@ -1018,6 +1110,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
@@ -1026,6 +1122,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
return Array.isArray(legacy) ? legacy : []
}
+const isNonEmptyString = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0
+
const resolvePieceLabel = (piece: Record) => {
const parts: string[] = []
if (piece.role) {
@@ -1069,16 +1168,65 @@ const fetchPieceTypeNames = async (ids: string[]) => {
fetchedPieceTypeMap.value = next
}
+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.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
+ parts.push(productTypeLabelMap.value[product.typeProductId])
+ } 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 fetchProductTypeNames = async (ids: string[]) => {
+ const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
+ if (!missing.length) {
+ return
+ }
+ const results = await Promise.allSettled(
+ missing.map((id) => get(`/model_types/${id}`)),
+ )
+ const next = { ...fetchedProductTypeMap.value }
+ results.forEach((result, index) => {
+ if (result.status !== 'fulfilled') {
+ return
+ }
+ const data = result.value?.data
+ const name = data?.name || data?.code
+ if (name) {
+ next[missing[index]] = name
+ }
+ })
+ fetchedProductTypeMap.value = next
+}
+
watch(
selectedTypeStructure,
(structure) => {
- const ids = getStructurePieces(structure)
+ const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
- if (!ids.length) {
- return
+ if (pieceIds.length) {
+ fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
+ }
+
+ const productIds = getStructureProducts(structure)
+ .map((product: any) => product?.typeProductId)
+ .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
+ if (productIds.length) {
+ fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
}
- fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
},
{ immediate: true },
)
@@ -1109,6 +1257,104 @@ const resolveSubcomponentLabel = (node: Record) => {
return parts.length ? parts.join(' • ') : 'Sous-composant'
}
+type SelectionEntry = {
+ id: string
+ path: string
+ requirementLabel: string
+ resolvedName: string
+}
+
+const collectStructureSelections = (root: any): {
+ pieces: SelectionEntry[]
+ products: SelectionEntry[]
+ components: SelectionEntry[]
+} => {
+ const piecesSelected: SelectionEntry[] = []
+ const productsSelected: SelectionEntry[] = []
+ const componentsSelected: SelectionEntry[] = []
+
+ if (!root || typeof root !== 'object') {
+ return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
+ }
+
+ const visitNode = (node: any, fallbackPath = 'racine') => {
+ if (!node || typeof node !== 'object') {
+ return
+ }
+
+ const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
+
+ const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
+ nodePieces.forEach((entry: any, index: number) => {
+ const selectedId = entry?.selectedPieceId
+ if (!isNonEmptyString(selectedId)) {
+ return
+ }
+ const definition = entry?.definition ?? entry
+ const catalogPiece = pieceCatalogMap.value.get(selectedId)
+ piecesSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
+ requirementLabel: resolvePieceLabel(definition),
+ resolvedName: catalogPiece?.name || selectedId,
+ })
+ })
+
+ const nodeProducts = Array.isArray(node.products) ? node.products : []
+ nodeProducts.forEach((entry: any, index: number) => {
+ const selectedId = entry?.selectedProductId
+ if (!isNonEmptyString(selectedId)) {
+ return
+ }
+ const definition = entry?.definition ?? entry
+ const catalogProduct = productCatalogMap.value.get(selectedId)
+ productsSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
+ requirementLabel: resolveProductLabel(definition),
+ resolvedName: catalogProduct?.name || selectedId,
+ })
+ })
+
+ const nodeChildren = Array.isArray(node.subcomponents)
+ ? node.subcomponents
+ : Array.isArray(node.subComponents)
+ ? node.subComponents
+ : []
+
+ nodeChildren.forEach((child: any, index: number) => {
+ const selectedId = child?.selectedComponentId
+ if (isNonEmptyString(selectedId)) {
+ const definition = child?.definition ?? child
+ const catalogComponent = componentCatalogMap.value.get(selectedId)
+ componentsSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
+ requirementLabel: resolveSubcomponentLabel(definition),
+ resolvedName: catalogComponent?.name || selectedId,
+ })
+ }
+
+ visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
+ })
+ }
+
+ visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
+
+ return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
+}
+
+const structureSelections = computed(() => {
+ const selections = collectStructureSelections(component.value?.structure)
+ const total =
+ selections.pieces.length + selections.products.length + selections.components.length
+ return {
+ ...selections,
+ total,
+ hasAny: total > 0,
+ }
+})
+
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
@@ -1208,7 +1454,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
}
onMounted(async () => {
- await Promise.allSettled([loadComponentTypes(), loadPieceTypes(), fetchComponent()])
+ await Promise.allSettled([
+ loadComponentTypes(),
+ loadPieceTypes(),
+ loadProductTypes(),
+ loadPieces({ itemsPerPage: 500 }),
+ loadProducts({ itemsPerPage: 500, force: true }),
+ loadComposants({ itemsPerPage: 500 }),
+ fetchComponent(),
+ ])
loading.value = false
if (component.value?.id) {
await refreshDocuments()
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index 5df0bed..44ddbe6 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -146,12 +146,26 @@
{{ description }}
-
+
@@ -448,8 +462,8 @@ const editionForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
- productId: null as string | null,
})
+const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref
([])
const documentIcon = (doc: any) =>
@@ -592,14 +606,18 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
+
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
+
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
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}`
@@ -628,6 +646,50 @@ const productRequirementDescriptions = computed(() =>
),
)
+const ensureProductSelections = (count: number) => {
+ const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
+ productSelections.value = next
+}
+
+let pendingProductIds: string[] = []
+
+const productRequirementEntries = computed(() =>
+ structureProducts.value.map((requirement, index) => ({
+ index,
+ key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
+ label: describeProductRequirement(requirement, index),
+ typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
+ })),
+)
+
+const productSelectionsFilled = computed(() =>
+ !requiresProductSelection.value ||
+ productRequirementEntries.value.every((entry) => {
+ const value = productSelections.value[entry.index]
+ return typeof value === 'string' && value.trim().length > 0
+ }),
+)
+
+const setProductSelection = (index: number, value: string | null) => {
+ const normalized = typeof value === 'string' ? value : null
+ const next = [...productSelections.value]
+ next[index] = normalized
+ productSelections.value = next
+}
+
+watch(structureProducts, (products) => {
+ ensureProductSelections(products.length)
+ if (!pendingProductIds.length || products.length === 0) {
+ return
+ }
+ const next = Array.from(
+ { length: products.length },
+ (_, index) => pendingProductIds[index] ?? null,
+ )
+ productSelections.value = next
+ pendingProductIds = []
+})
+
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
@@ -645,7 +707,7 @@ const canSubmit = computed(() =>
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
- (!requiresProductSelection.value || editionForm.productId) &&
+ productSelectionsFilled.value &&
!saving.value,
),
)
@@ -730,11 +792,26 @@ watch(
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
- editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
+ const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
+ ? currentPiece.productIds.map((id: unknown) => String(id))
+ : currentPiece.product?.id || currentPiece.productId
+ ? [String(currentPiece.product?.id || currentPiece.productId)]
+ : []
+ pendingProductIds = existingProductIds
+ ensureProductSelections(structureProducts.value.length)
+ if (existingProductIds.length && structureProducts.value.length) {
+ const next = Array.from(
+ { length: structureProducts.value.length },
+ (_, index) => existingProductIds[index] ?? null,
+ )
+ productSelections.value = next
+ pendingProductIds = []
+ }
+
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
@@ -755,6 +832,7 @@ watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
+ ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
@@ -763,7 +841,7 @@ const submitEdition = async () => {
return
}
- if (requiresProductSelection.value && !editionForm.productId) {
+ if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
@@ -784,11 +862,13 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
- const selectedProductId =
- typeof editionForm.productId === 'string'
- ? editionForm.productId.trim()
- : ''
- payload.productId = selectedProductId || null
+ const normalizedProductIds = productRequirementEntries.value
+ .map((entry) => productSelections.value[entry.index])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .map((value) => value.trim())
+
+ payload.productIds = normalizedProductIds
+ payload.productId = normalizedProductIds[0] || null
if (rawPrice) {
const parsed = Number(rawPrice)
@@ -981,12 +1061,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue)
}
-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,
customFieldType: field.type,
diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue
index 950c65d..b744e2d 100644
--- a/app/pages/pieces/create.vue
+++ b/app/pages/pieces/create.vue
@@ -118,12 +118,26 @@
{{ description }}
-
+
@@ -317,8 +331,8 @@ const creationForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
- productId: null as string | null,
})
+const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('')
const customFieldInputs = ref([])
@@ -364,14 +378,18 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
+
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
+
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}`
@@ -400,6 +418,39 @@ const productRequirementDescriptions = computed(() =>
),
)
+const ensureProductSelections = (count: number) => {
+ const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
+ productSelections.value = next
+}
+
+const productRequirementEntries = computed(() =>
+ structureProducts.value.map((requirement, index) => ({
+ index,
+ key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
+ label: describeProductRequirement(requirement, index),
+ typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
+ })),
+)
+
+const productSelectionsFilled = computed(() =>
+ !requiresProductSelection.value ||
+ productRequirementEntries.value.every((entry) => {
+ const value = productSelections.value[entry.index]
+ return typeof value === 'string' && value.trim().length > 0
+ }),
+)
+
+const setProductSelection = (index: number, value: string | null) => {
+ const normalized = typeof value === 'string' ? value : null
+ const next = [...productSelections.value]
+ next[index] = normalized
+ productSelections.value = next
+}
+
+watch(structureProducts, (products) => {
+ ensureProductSelections(products.length)
+})
+
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
@@ -411,7 +462,7 @@ watch(selectedType, (type) => {
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
- creationForm.productId = null
+ productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
})
const requiredCustomFieldsFilled = computed(() =>
@@ -431,7 +482,7 @@ const canSubmit = computed(() =>
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&
- (!requiresProductSelection.value || creationForm.productId) &&
+ productSelectionsFilled.value &&
!submitting.value,
),
)
@@ -449,18 +500,12 @@ const toFieldString = (value: unknown): string => {
return ''
}
-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
+ productSelections.value = []
lastSuggestedName.value = ''
}
@@ -470,7 +515,7 @@ const submitCreation = async () => {
return
}
- if (requiresProductSelection.value && !creationForm.productId) {
+ if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
@@ -487,12 +532,13 @@ const submitCreation = async () => {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
- const selectedProductId =
- typeof creationForm.productId === 'string'
- ? creationForm.productId.trim()
- : ''
- if (selectedProductId) {
- payload.productId = selectedProductId
+ const normalizedProductIds = productRequirementEntries.value
+ .map((entry) => productSelections.value[entry.index])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .map((value) => value.trim())
+ if (normalizedProductIds.length) {
+ payload.productIds = normalizedProductIds
+ payload.productId = normalizedProductIds[0]
}
const rawPrice = typeof creationForm.prix === 'string'