The definitionSources passed to saveCustomFieldValues were pointing at properties not serialized by the API (typeComposant.customFields, typePiece.pieceCustomFields). Changed to structure.customFields which is the correct serialized path, preventing orphan custom field creation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
551 lines
18 KiB
TypeScript
551 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 { 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, composants: componentCatalogRef } = useComposants()
|
|
const { pieces } = usePieces()
|
|
const { products } = 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 (slotId: string, quantity: number) => {
|
|
if (!slotId || quantity < 1) return
|
|
const result = await patch(`/composant-piece-slots/${slotId}`, { quantity: Math.max(1, quantity) })
|
|
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.quantity = quantity
|
|
}
|
|
toast.showSuccess('Quantité mise à jour')
|
|
}
|
|
}
|
|
|
|
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?.structure?.customFields,
|
|
],
|
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
)
|
|
toast.showSuccess('Composant mis à jour avec succès.')
|
|
}
|
|
}
|
|
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
|
|
})
|
|
|
|
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,
|
|
}
|
|
}
|