The save functions (savePieceSlotSelection, saveProductSlotSelection, saveSubcomponentSlotSelection) were not checking result.success before updating local state and showing success toast. Since useApi.patch() never throws, the catch block was dead code and errors were silently ignored while the UI showed success. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
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 type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
|
|
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<Record<string, any>>
|
|
}
|
|
|
|
const historyFieldLabels: Record<string, string> = {
|
|
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, patch } = 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<any | null>(null)
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const selectedFiles = ref<File[]>([])
|
|
const uploadingDocuments = ref(false)
|
|
const loadingDocuments = ref(false)
|
|
const componentDocuments = ref<any[]>([])
|
|
const previewDocument = ref<any | null>(null)
|
|
const previewVisible = ref(false)
|
|
|
|
const selectedTypeId = ref<string>('')
|
|
const editionForm = reactive({
|
|
name: '' as string,
|
|
description: '' as string,
|
|
reference: '' as string,
|
|
constructeurIds: [] as string[],
|
|
prix: '' as string,
|
|
})
|
|
|
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
|
const pieceTypeLabelMap = computed(() =>
|
|
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
|
)
|
|
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
|
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<ComponentCatalogType[]>(() =>
|
|
(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<ComponentModelStructure | null>(() => {
|
|
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<string, any>) =>
|
|
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
|
|
|
const resolveProductLabel = (product: Record<string, any>) =>
|
|
_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,
|
|
}
|
|
})
|
|
|
|
// --- Slot selection entries (for selectors) ---
|
|
|
|
const pieceSlotEntries = computed(() => {
|
|
const structure = component.value?.structure
|
|
if (!structure?.pieces) return []
|
|
return (structure.pieces as any[]).map((slot: any, i: number) => ({
|
|
slotId: slot.slotId,
|
|
typePieceId: slot.typePieceId,
|
|
selectedPieceId: slot.selectedPieceId ?? null,
|
|
quantity: slot.quantity ?? 1,
|
|
position: slot.position ?? i,
|
|
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
|
}))
|
|
})
|
|
|
|
const productSlotEntries = computed(() => {
|
|
const structure = component.value?.structure
|
|
if (!structure?.products) return []
|
|
return (structure.products as any[]).map((slot: any, i: number) => ({
|
|
slotId: slot.slotId,
|
|
typeProductId: slot.typeProductId,
|
|
selectedProductId: slot.selectedProductId ?? null,
|
|
familyCode: slot.familyCode,
|
|
position: slot.position ?? i,
|
|
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
|
}))
|
|
})
|
|
|
|
const subcomponentSlotEntries = computed(() => {
|
|
const structure = component.value?.structure
|
|
if (!structure?.subcomponents) return []
|
|
return (structure.subcomponents as any[]).map((slot: any, i: number) => ({
|
|
slotId: slot.slotId,
|
|
typeComposantId: slot.typeComposantId,
|
|
selectedComponentId: slot.selectedComponentId ?? null,
|
|
alias: slot.alias,
|
|
familyCode: slot.familyCode,
|
|
position: slot.position ?? i,
|
|
label: slot.alias || `Sous-composant #${i + 1}`,
|
|
}))
|
|
})
|
|
|
|
const savePieceSlotSelection = async (slotId: string, selectedPieceId: string | null) => {
|
|
const result = await patch(`/composant-piece-slots/${slotId}`, { selectedPieceId })
|
|
if (result.success) {
|
|
const structure = component.value?.structure
|
|
if (structure?.pieces) {
|
|
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
|
|
if (slot) slot.selectedPieceId = selectedPieceId
|
|
}
|
|
toast.showSuccess('Pièce mise à jour')
|
|
}
|
|
}
|
|
|
|
const saveProductSlotSelection = async (slotId: string, selectedProductId: string | null) => {
|
|
const result = await patch(`/composant-product-slots/${slotId}`, { selectedProductId })
|
|
if (result.success) {
|
|
const structure = component.value?.structure
|
|
if (structure?.products) {
|
|
const slot = (structure.products as any[]).find((s: any) => s.slotId === slotId)
|
|
if (slot) slot.selectedProductId = selectedProductId
|
|
}
|
|
toast.showSuccess('Produit mis à jour')
|
|
}
|
|
}
|
|
|
|
const saveSubcomponentSlotSelection = async (slotId: string, selectedComposantId: string | null) => {
|
|
const result = await patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId })
|
|
if (result.success) {
|
|
const structure = component.value?.structure
|
|
if (structure?.subcomponents) {
|
|
const slot = (structure.subcomponents as any[]).find((s: any) => s.slotId === slotId)
|
|
if (slot) slot.selectedComponentId = selectedComposantId
|
|
}
|
|
toast.showSuccess('Sous-composant mis à jour')
|
|
}
|
|
}
|
|
|
|
const saveSlotQuantity = async (entry: SelectionEntry) => {
|
|
const slotId = entry.slotId
|
|
const quantity = typeof entry._definition?.quantity === 'number'
|
|
? Math.max(1, entry._definition.quantity)
|
|
: null
|
|
if (!slotId || quantity === null) return
|
|
try {
|
|
await patch(`/composant-piece-slots/${slotId}`, { quantity })
|
|
toast.showSuccess('Quantité mise à jour')
|
|
}
|
|
catch (error: any) {
|
|
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
|
|
}
|
|
}
|
|
|
|
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<string, any> = {
|
|
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<string, any>
|
|
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
|
|
|
|
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
|
|
Promise.allSettled([
|
|
loadPieces({ itemsPerPage: 200, force: true }),
|
|
loadProducts({ itemsPerPage: 200, force: true }),
|
|
loadComposants({ itemsPerPage: 200, force: true }),
|
|
]).catch(() => {})
|
|
})
|
|
|
|
return {
|
|
// State
|
|
component,
|
|
loading,
|
|
saving,
|
|
selectedFiles,
|
|
uploadingDocuments,
|
|
loadingDocuments,
|
|
componentDocuments,
|
|
previewDocument,
|
|
previewVisible,
|
|
selectedTypeId,
|
|
editionForm,
|
|
customFieldInputs,
|
|
historyFieldLabels,
|
|
|
|
// Computed
|
|
canEdit,
|
|
canSubmit,
|
|
componentTypeList,
|
|
selectedType,
|
|
selectedTypeStructure,
|
|
structureSelections,
|
|
pieceSlotEntries,
|
|
productSlotEntries,
|
|
subcomponentSlotEntries,
|
|
|
|
// History
|
|
history,
|
|
historyLoading,
|
|
historyError,
|
|
|
|
// Methods
|
|
openPreview,
|
|
closePreview,
|
|
removeDocument,
|
|
handleFilesAdded,
|
|
refreshDocuments,
|
|
submitEdition,
|
|
saveSlotQuantity,
|
|
savePieceSlotSelection,
|
|
saveProductSlotSelection,
|
|
saveSubcomponentSlotSelection,
|
|
resolvePieceLabel,
|
|
resolveProductLabel,
|
|
resolveSubcomponentLabel,
|
|
formatStructurePreview,
|
|
}
|
|
}
|