Slot selections (piece, product, subcomponent, quantity) are no longer saved immediately on change. Instead, edits are stored locally and persisted together with base fields and custom fields when the user clicks "Enregistrer les modifications". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
582 lines
19 KiB
TypeScript
582 lines
19 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 local edits (saved on submit, not auto-saved) ---
|
|
|
|
const slotEdits = reactive<{
|
|
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
|
|
products: Record<string, { selectedProductId?: string | null }>
|
|
subcomponents: Record<string, { selectedComposantId?: string | null }>
|
|
}>({ pieces: {}, products: {}, subcomponents: {} })
|
|
|
|
const pieceSlotEntries = computed(() => {
|
|
const structure = component.value?.structure
|
|
if (!structure?.pieces) return []
|
|
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
|
const edits = slotEdits.pieces[slot.slotId]
|
|
return {
|
|
slotId: slot.slotId,
|
|
typePieceId: slot.typePieceId,
|
|
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
|
quantity: edits && 'quantity' in edits ? edits.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) => {
|
|
const edits = slotEdits.products[slot.slotId]
|
|
return {
|
|
slotId: slot.slotId,
|
|
typeProductId: slot.typeProductId,
|
|
selectedProductId: edits && 'selectedProductId' in edits ? edits.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) => {
|
|
const edits = slotEdits.subcomponents[slot.slotId]
|
|
return {
|
|
slotId: slot.slotId,
|
|
typeComposantId: slot.typeComposantId,
|
|
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
|
alias: slot.alias,
|
|
familyCode: slot.familyCode,
|
|
position: slot.position ?? i,
|
|
label: slot.alias || `Sous-composant #${i + 1}`,
|
|
}
|
|
})
|
|
})
|
|
|
|
const setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
|
|
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], selectedPieceId }
|
|
}
|
|
|
|
const setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
|
|
slotEdits.products[slotId] = { ...slotEdits.products[slotId], selectedProductId }
|
|
}
|
|
|
|
const setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
|
|
slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[slotId], selectedComposantId }
|
|
}
|
|
|
|
const setSlotQuantity = (slotId: string, quantity: number) => {
|
|
if (!slotId || quantity < 1) return
|
|
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
|
|
}
|
|
|
|
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 },
|
|
)
|
|
|
|
// Save slot edits
|
|
const slotPromises: Promise<any>[] = []
|
|
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
|
if (Object.keys(edits).length) {
|
|
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
|
|
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
|
|
...'quantity' in edits ? { quantity: edits.quantity } : {},
|
|
}))
|
|
}
|
|
}
|
|
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
|
if ('selectedProductId' in edits) {
|
|
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
|
|
}
|
|
}
|
|
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
|
if ('selectedComposantId' in edits) {
|
|
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
|
|
}
|
|
}
|
|
await Promise.all(slotPromises)
|
|
|
|
// Apply slot edits to local structure so UI reflects saved values
|
|
const structure = component.value?.structure
|
|
if (structure) {
|
|
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
|
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
|
|
if (slot) {
|
|
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
|
|
if ('quantity' in edits) slot.quantity = edits.quantity
|
|
}
|
|
}
|
|
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
|
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
|
|
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
|
|
}
|
|
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
|
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
|
|
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
|
|
}
|
|
}
|
|
|
|
// Reset local slot edits
|
|
slotEdits.pieces = {}
|
|
slotEdits.products = {}
|
|
slotEdits.subcomponents = {}
|
|
|
|
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,
|
|
setSlotQuantity,
|
|
setPieceSlotSelection,
|
|
setProductSlotSelection,
|
|
setSubcomponentSlotSelection,
|
|
resolvePieceLabel,
|
|
resolveProductLabel,
|
|
resolveSubcomponentLabel,
|
|
formatStructurePreview,
|
|
}
|
|
}
|