Files
Inventory/frontend/app/composables/useComponentEdit.ts
2026-04-04 13:18:16 +02:00

595 lines
20 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 { 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 { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } 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 { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
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 { fetchLinks, syncLinks } = useConstructeurLinks()
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
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 {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
refresh: refreshCustomFieldInputs,
} = useCustomFieldInputs({
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
values: computed(() => component.value?.customFieldValues ?? []),
entityType: 'composant',
entityId: computed(() => component.value?.id ?? null),
onValueCreated: (newValue) => {
if (component.value && Array.isArray(component.value.customFieldValues)) {
component.value.customFieldValues.push(newValue)
}
},
})
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 : []
// The watcher on useCustomFieldInputs will auto-refresh when component.value changes
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]
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
return {
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId,
selectedPieceName: slot.selectedPieceName ?? null,
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
isEmpty: !selectedPieceId,
}
})
})
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]
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
return {
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId,
selectedProductName: slot.selectedProductName ?? null,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
isEmpty: !selectedProductId,
}
})
})
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]
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
return {
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId,
selectedComponentName: slot.selectedComponentName ?? null,
alias: slot.alias,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`,
isEmpty: !selectedComponentId,
}
})
})
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
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>
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
}
// 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 = {}
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
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 || ''
// Load constructeur links
fetchLinks('composant', componentId).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
initialized.value = true
}
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
},
{ 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,
constructeurLinks,
originalConstructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
// Computed
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
refreshDocuments,
submitEdition,
fetchComponent,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
}
}