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

+ +
+

Sous-composants

Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut. @@ -198,6 +210,50 @@ +

+
+

Sélections actuelles

+

+ Voici les pièces, produits et sous-composants réellement choisis pour ce composant. +

+
+ +
+
+

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'