Ajoute context: 'standalone' aux appels useCustomFieldInputs dans les vues composant, pièce et produit (création et édition) pour filtrer les champs perso réservés au contexte machine. Exclut également ces champs de la formule de référence automatique dans le ReferenceFormulaBuilder des catégories. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
597 lines
20 KiB
TypeScript
597 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 { useEntityHistory } from '~/composables/useEntityHistory'
|
|
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,
|
|
} = useEntityHistory('composant')
|
|
|
|
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),
|
|
context: 'standalone',
|
|
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,
|
|
requiredCustomFieldsFilled,
|
|
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,
|
|
}
|
|
}
|