From 6add5587253651a72a3c90c5483b46de4a8da4a8 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 8 Mar 2026 17:23:46 +0100 Subject: [PATCH] refactor(frontend) : extract component edit page logic into composable Co-Authored-By: Claude Opus 4.6 --- app/composables/useComponentEdit.ts | 461 +++++++++++++++++ app/pages/component/[id]/edit.vue | 518 ++------------------ app/shared/utils/structureSelectionUtils.ts | 107 ++++ 3 files changed, 602 insertions(+), 484 deletions(-) create mode 100644 app/composables/useComponentEdit.ts create mode 100644 app/shared/utils/structureSelectionUtils.ts diff --git a/app/composables/useComponentEdit.ts b/app/composables/useComponentEdit.ts new file mode 100644 index 0000000..cbc0a7c --- /dev/null +++ b/app/composables/useComponentEdit.ts @@ -0,0 +1,461 @@ +import { computed, onMounted, reactive, ref, watch } from 'vue' +import { useRouter } from '#imports' +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' +import { extractRelationId } from '~/shared/apiRelations' +import { useDocuments } from '~/composables/useDocuments' +import { useConstructeurs } from '~/composables/useConstructeurs' +import { useComponentHistory } from '~/composables/useComponentHistory' +import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' +import { uniqueConstructeurIds } from '~/shared/constructeurUtils' +import { + getStructurePieces, + getStructureProducts, + resolvePieceLabel as _resolvePieceLabel, + resolveProductLabel as _resolveProductLabel, + resolveSubcomponentLabel, + fetchModelTypeNames, + buildTypeLabelMap, +} from '~/shared/utils/structureDisplayUtils' +import type { ComponentModelStructure } from '~/shared/types/inventory' +import type { ModelType } from '~/services/modelTypes' +import { canPreviewDocument } from '~/utils/documentPreview' +import { + type CustomFieldInput, + buildCustomFieldInputs, + requiredCustomFieldsFilled as _requiredCustomFieldsFilled, + saveCustomFieldValues as _saveCustomFieldValues, +} from '~/shared/utils/customFieldFormUtils' +import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils' + +interface ComponentCatalogType extends ModelType { + structure: ComponentModelStructure | null + customFields?: Array> +} + +const historyFieldLabels: Record = { + name: 'Nom', + reference: 'Référence', + prix: 'Prix', + structure: 'Structure', + typeComposant: 'Catégorie', + product: 'Produit lié', + constructeurIds: 'Fournisseurs', +} + +export function useComponentEdit(componentId: string) { + const { canEdit } = usePermissions() + const router = useRouter() + const { get } = useApi() + const { componentTypes, loadComponentTypes } = useComponentTypes() + const { pieceTypes, loadPieceTypes } = usePieceTypes() + const { productTypes, loadProductTypes } = useProductTypes() + const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants() + const { pieces, loadPieces } = usePieces() + const { products, loadProducts } = useProducts() + const { ensureConstructeurs } = useConstructeurs() + const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() + const toast = useToast() + const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() + const { + history, + loading: historyLoading, + error: historyError, + loadHistory, + } = useComponentHistory() + + const component = ref(null) + const loading = ref(true) + const saving = ref(false) + const selectedFiles = ref([]) + const uploadingDocuments = ref(false) + const loadingDocuments = ref(false) + const componentDocuments = ref([]) + const previewDocument = ref(null) + const previewVisible = ref(false) + + const selectedTypeId = ref('') + const editionForm = reactive({ + name: '' as string, + description: '' as string, + reference: '' as string, + constructeurIds: [] as string[], + prix: '' as string, + }) + + const customFieldInputs = ref([]) + const fetchedPieceTypeMap = ref>({}) + const pieceTypeLabelMap = computed(() => + buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value), + ) + const fetchedProductTypeMap = ref>({}) + const productTypeLabelMap = computed(() => + buildTypeLabelMap(productTypes.value, 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 openPreview = (doc: any) => { + if (!doc || !canPreviewDocument(doc)) { + return + } + previewDocument.value = doc + previewVisible.value = true + } + + const closePreview = () => { + previewVisible.value = false + previewDocument.value = null + } + + const removeDocument = async (documentId: string | number | null | undefined) => { + if (!documentId) { + return + } + const result = await deleteDocument(documentId, { updateStore: false }) + if (result.success) { + componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId) + } + } + + const refreshDocuments = async () => { + if (!component.value?.id) { + componentDocuments.value = [] + return + } + loadingDocuments.value = true + try { + const result = await loadDocumentsByComponent(component.value.id, { updateStore: false }) + if (result.success) { + componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : [] + } + } + finally { + loadingDocuments.value = false + } + } + + const handleFilesAdded = async (files: File[]) => { + if (!files?.length || !component.value?.id) { + return + } + uploadingDocuments.value = true + try { + const result = await uploadDocuments( + { + files, + context: { composantId: component.value.id }, + }, + { updateStore: false }, + ) + if (result.success) { + selectedFiles.value = [] + await refreshDocuments() + } + } + finally { + uploadingDocuments.value = false + } + } + + const componentTypeList = computed(() => + (componentTypes.value || []) + .filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[], + ) + + const selectedType = computed(() => { + if (!selectedTypeId.value) { + return null + } + return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null + }) + + const selectedTypeStructure = computed(() => { + const structure = selectedType.value?.structure ?? null + return structure ? normalizeStructureForEditor(structure) : null + }) + + const refreshCustomFieldInputs = ( + structureOverride?: ComponentModelStructure | null, + valuesOverride?: any[] | null, + ) => { + const structure = structureOverride ?? selectedTypeStructure.value ?? null + const values = valuesOverride ?? component.value?.customFieldValues ?? null + customFieldInputs.value = buildCustomFieldInputs(structure, values) + } + + const requiredCustomFieldsFilled = computed(() => + _requiredCustomFieldsFilled(customFieldInputs.value), + ) + + const canSubmit = computed(() => Boolean( + canEdit.value + && component.value + && editionForm.name + && requiredCustomFieldsFilled.value + && !saving.value, + )) + + const fetchComponent = async () => { + if (!componentId || typeof componentId !== 'string') { + component.value = null + componentDocuments.value = [] + return + } + const result = await get(`/composants/${componentId}`) + if (result.success) { + component.value = result.data + componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] + + const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : [] + refreshCustomFieldInputs(undefined, customValues) + + loadHistory(result.data.id).catch(() => {}) + } + else { + component.value = null + componentDocuments.value = [] + } + } + + const resolvePieceLabel = (piece: Record) => + _resolvePieceLabel(piece, pieceTypeLabelMap.value) + + const resolveProductLabel = (product: Record) => + _resolveProductLabel(product, productTypeLabelMap.value) + + const structureSelections = computed(() => { + const selections = collectStructureSelections( + component.value?.structure, + { + pieceCatalogMap: pieceCatalogMap.value, + productCatalogMap: productCatalogMap.value, + componentCatalogMap: componentCatalogMap.value, + }, + { resolvePieceLabel, resolveProductLabel, resolveSubcomponentLabel }, + ) + const total + = selections.pieces.length + selections.products.length + selections.components.length + return { + ...selections, + total, + hasAny: total > 0, + } + }) + + const submitEdition = async () => { + if (!component.value) { + return + } + + const rawPrice = typeof editionForm.prix === 'string' + ? editionForm.prix.trim() + : editionForm.prix === null || editionForm.prix === undefined + ? '' + : String(editionForm.prix).trim() + + const payload: Record = { + name: editionForm.name.trim(), + description: editionForm.description.trim() || null, + } + + const reference = editionForm.reference.trim() + payload.reference = reference || null + payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds) + + if (rawPrice) { + const parsed = Number(rawPrice) + if (!Number.isNaN(parsed)) { + payload.prix = String(parsed) + } + } + else { + payload.prix = null + } + + saving.value = true + try { + const result = await updateComposant(component.value.id, payload) + if (result.success && result.data) { + const updatedComponent = result.data as Record + await _saveCustomFieldValues( + 'composant', + updatedComponent.id, + [ + updatedComponent?.typeComposant?.customFields, + ], + { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, + ) + await router.push('/component-catalog') + } + } + catch (error: any) { + toast.showError(error?.message || 'Erreur lors de la mise à jour du composant') + } + finally { + saving.value = false + } + } + + // --- Watchers --- + + const initialized = ref(false) + + watch( + [component, selectedTypeStructure], + ([currentComponent, currentStructure]) => { + if (!currentComponent) { + return + } + + if (!initialized.value) { + const resolvedTypeId = currentComponent.typeComposantId + || extractRelationId(currentComponent.typeComposant) + || '' + if (resolvedTypeId && !currentComponent.typeComposantId) { + currentComponent.typeComposantId = resolvedTypeId + } + selectedTypeId.value = resolvedTypeId + + editionForm.name = currentComponent.name || '' + editionForm.description = currentComponent.description || '' + editionForm.reference = currentComponent.reference || '' + editionForm.constructeurIds = uniqueConstructeurIds( + currentComponent, + Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [], + currentComponent.constructeur ? [currentComponent.constructeur] : [], + ) + editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : '' + if (editionForm.constructeurIds.length) { + void ensureConstructeurs(editionForm.constructeurIds) + } + + initialized.value = true + } + + refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues) + }, + { immediate: true }, + ) + + watch( + selectedTypeStructure, + (structure) => { + const pieceIds = getStructurePieces(structure) + .map((piece: any) => piece?.typePieceId) + .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0) + if (pieceIds.length) { + fetchModelTypeNames(Array.from(new Set(pieceIds)), pieceTypeLabelMap.value, get) + .then((additions) => { + if (Object.keys(additions).length) { + fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions } + } + }) + .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) { + fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get) + .then((additions) => { + if (Object.keys(additions).length) { + fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions } + } + }) + .catch(() => {}) + } + }, + { immediate: true }, + ) + + // --- Lifecycle --- + + onMounted(async () => { + await Promise.allSettled([ + loadComponentTypes(), + loadPieceTypes(), + loadProductTypes(), + fetchComponent(), + ]) + loading.value = false + + // Defer bulk catalog loads — only needed when component has structure selections + if (component.value?.structure) { + Promise.allSettled([ + loadPieces({ itemsPerPage: 200 }), + loadProducts({ itemsPerPage: 200 }), + loadComposants({ itemsPerPage: 200 }), + ]).catch(() => {}) + } + }) + + return { + // State + component, + loading, + saving, + selectedFiles, + uploadingDocuments, + loadingDocuments, + componentDocuments, + previewDocument, + previewVisible, + selectedTypeId, + editionForm, + customFieldInputs, + historyFieldLabels, + + // Computed + canEdit, + canSubmit, + componentTypeList, + selectedType, + selectedTypeStructure, + structureSelections, + + // History + history, + historyLoading, + historyError, + + // Methods + openPreview, + closePreview, + removeDocument, + handleFilesAdded, + refreshDocuments, + submitEdition, + resolvePieceLabel, + resolveProductLabel, + resolveSubcomponentLabel, + formatStructurePreview, + } +} diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue index 65c6ad1..61e39f7 100644 --- a/app/pages/component/[id]/edit.vue +++ b/app/pages/component/[id]/edit.vue @@ -273,492 +273,42 @@ diff --git a/app/shared/utils/structureSelectionUtils.ts b/app/shared/utils/structureSelectionUtils.ts new file mode 100644 index 0000000..df97775 --- /dev/null +++ b/app/shared/utils/structureSelectionUtils.ts @@ -0,0 +1,107 @@ +export type SelectionEntry = { + id: string + path: string + requirementLabel: string + resolvedName: string +} + +export type StructureSelectionResult = { + pieces: SelectionEntry[] + products: SelectionEntry[] + components: SelectionEntry[] +} + +type CatalogMap = Map + +type LabelResolvers = { + resolvePieceLabel: (definition: Record) => string + resolveProductLabel: (definition: Record) => string + resolveSubcomponentLabel: (definition: Record) => string +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 + +export function collectStructureSelections( + root: any, + catalogs: { + pieceCatalogMap: CatalogMap + productCatalogMap: CatalogMap + componentCatalogMap: CatalogMap + }, + resolvers: LabelResolvers, +): StructureSelectionResult { + 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 = catalogs.pieceCatalogMap.get(selectedId) + piecesSelected.push({ + id: selectedId, + path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`, + requirementLabel: resolvers.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 = catalogs.productCatalogMap.get(selectedId) + productsSelected.push({ + id: selectedId, + path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`, + requirementLabel: resolvers.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 = catalogs.componentCatalogMap.get(selectedId) + componentsSelected.push({ + id: selectedId, + path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`, + requirementLabel: resolvers.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 } +}