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>
473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useRouter } from '#imports'
|
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
|
import { usePieces } from '~/composables/usePieces'
|
|
import { useCustomFields } from '~/composables/useCustomFields'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { usePieceHistory } from '~/composables/usePieceHistory'
|
|
import { extractRelationId } from '~/shared/apiRelations'
|
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
|
import type { ModelType } from '~/services/modelTypes'
|
|
import {
|
|
getStructureProducts,
|
|
buildProductRequirementDescriptions,
|
|
buildProductRequirementEntries,
|
|
resizeProductSelections,
|
|
areProductSelectionsFilled,
|
|
applyProductSelection,
|
|
collectNormalizedProductIds,
|
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
|
import { getModelType } from '~/services/modelTypes'
|
|
import {
|
|
type CustomFieldInput,
|
|
buildCustomFieldInputs,
|
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
saveCustomFieldValues as _saveCustomFieldValues,
|
|
} from '~/shared/utils/customFieldFormUtils'
|
|
|
|
interface PieceCatalogType extends ModelType {
|
|
structure: PieceModelStructure | null
|
|
customFields?: Array<Record<string, any>>
|
|
}
|
|
|
|
export function usePieceEdit(pieceId: string) {
|
|
const { canEdit } = usePermissions()
|
|
const router = useRouter()
|
|
const { get } = useApi()
|
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
|
const { updatePiece } = usePieces()
|
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
const toast = useToast()
|
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
|
const { ensureConstructeurs } = useConstructeurs()
|
|
const {
|
|
history,
|
|
loading: historyLoading,
|
|
error: historyError,
|
|
loadHistory,
|
|
} = usePieceHistory()
|
|
|
|
const piece = 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 pieceDocuments = ref<any[]>([])
|
|
const previewDocument = ref<any | null>(null)
|
|
const previewVisible = ref(false)
|
|
|
|
const historyFieldLabels: Record<string, string> = {
|
|
name: 'Nom',
|
|
reference: 'Référence',
|
|
prix: 'Prix',
|
|
typePiece: 'Catégorie',
|
|
product: 'Produit lié',
|
|
productIds: 'Produits liés',
|
|
constructeurIds: 'Fournisseurs',
|
|
}
|
|
|
|
const selectedTypeId = ref<string>('')
|
|
const pieceTypeDetails = ref<any | null>(null)
|
|
const editionForm = reactive({
|
|
name: '' as string,
|
|
description: '' as string,
|
|
reference: '' as string,
|
|
constructeurIds: [] as string[],
|
|
prix: '' as string,
|
|
})
|
|
const productSelections = ref<(string | null)[]>([])
|
|
|
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
|
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
|
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
|
)
|
|
|
|
const refreshCustomFieldInputs = (
|
|
structureOverride?: PieceModelStructure | null,
|
|
valuesOverride?: any[] | null,
|
|
) => {
|
|
const structure = structureOverride ?? resolvedStructure.value ?? null
|
|
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
|
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
|
}
|
|
|
|
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) {
|
|
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
|
|
}
|
|
}
|
|
|
|
const handleFilesAdded = async (files: File[]) => {
|
|
if (!files?.length || !piece.value?.id) {
|
|
return
|
|
}
|
|
uploadingDocuments.value = true
|
|
try {
|
|
const result = await uploadDocuments(
|
|
{
|
|
files,
|
|
context: { pieceId: piece.value.id },
|
|
},
|
|
{ updateStore: false },
|
|
)
|
|
if (result.success) {
|
|
selectedFiles.value = []
|
|
await refreshDocuments()
|
|
}
|
|
}
|
|
finally {
|
|
uploadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const refreshDocuments = async () => {
|
|
if (!piece.value?.id) {
|
|
pieceDocuments.value = []
|
|
return
|
|
}
|
|
loadingDocuments.value = true
|
|
try {
|
|
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
|
|
if (result.success) {
|
|
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
|
}
|
|
}
|
|
finally {
|
|
loadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
|
|
|
const selectedType = computed(() => {
|
|
if (!selectedTypeId.value) {
|
|
return null
|
|
}
|
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
|
})
|
|
|
|
const structureProducts = computed(() =>
|
|
getStructureProducts(resolvedStructure.value),
|
|
)
|
|
|
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
|
|
|
const productRequirementDescriptions = computed(() =>
|
|
buildProductRequirementDescriptions(structureProducts.value),
|
|
)
|
|
|
|
const ensureProductSelections = (count: number) => {
|
|
productSelections.value = resizeProductSelections(productSelections.value, count)
|
|
}
|
|
|
|
let pendingProductIds: string[] = []
|
|
|
|
const productRequirementEntries = computed(() =>
|
|
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
|
)
|
|
|
|
const productSelectionsFilled = computed(() =>
|
|
areProductSelectionsFilled(
|
|
requiresProductSelection.value,
|
|
productRequirementEntries.value,
|
|
productSelections.value,
|
|
),
|
|
)
|
|
|
|
const setProductSelection = (index: number, value: string | null) => {
|
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
|
}
|
|
|
|
watch(structureProducts, (products) => {
|
|
ensureProductSelections(products.length)
|
|
if (!pendingProductIds.length || products.length === 0) {
|
|
return
|
|
}
|
|
const next = Array.from(
|
|
{ length: products.length },
|
|
(_, index) => pendingProductIds[index] ?? null,
|
|
)
|
|
productSelections.value = next
|
|
pendingProductIds = []
|
|
})
|
|
|
|
const requiredCustomFieldsFilled = computed(() =>
|
|
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
)
|
|
|
|
const canSubmit = computed(() =>
|
|
Boolean(
|
|
canEdit.value
|
|
&& piece.value
|
|
&& editionForm.name
|
|
&& requiredCustomFieldsFilled.value
|
|
&& productSelectionsFilled.value
|
|
&& !saving.value,
|
|
),
|
|
)
|
|
|
|
const fetchPiece = async () => {
|
|
if (!pieceId || typeof pieceId !== 'string') {
|
|
piece.value = null
|
|
pieceDocuments.value = []
|
|
return
|
|
}
|
|
const result = await get(`/pieces/${pieceId}`)
|
|
if (result.success) {
|
|
piece.value = result.data
|
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
|
|
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
|
refreshCustomFieldInputs(undefined, customValues)
|
|
|
|
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
|
loadPieceTypeDetailsFromCache(result.data)
|
|
|
|
// History is non-blocking — template handles its own loading state
|
|
loadHistory(result.data.id).catch(() => {})
|
|
}
|
|
else {
|
|
piece.value = null
|
|
pieceDocuments.value = []
|
|
}
|
|
}
|
|
|
|
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
|
const typeId = currentPiece?.typePieceId
|
|
|| extractRelationId(currentPiece?.typePiece)
|
|
|| ''
|
|
if (!typeId) {
|
|
pieceTypeDetails.value = null
|
|
return
|
|
}
|
|
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
|
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
|
if (cachedType) {
|
|
pieceTypeDetails.value = cachedType
|
|
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
|
return
|
|
}
|
|
// Fallback: fetch if not in cache (edge case)
|
|
getModelType(typeId).then((type) => {
|
|
if (type && typeof type === 'object') {
|
|
pieceTypeDetails.value = type
|
|
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
|
}
|
|
}).catch(() => {
|
|
pieceTypeDetails.value = null
|
|
})
|
|
}
|
|
|
|
let initialized = false
|
|
|
|
watch(
|
|
[piece, selectedType],
|
|
([currentPiece, _currentType]) => {
|
|
if (!currentPiece || initialized) {
|
|
return
|
|
}
|
|
|
|
const resolvedTypeId = currentPiece.typePieceId
|
|
|| extractRelationId(currentPiece.typePiece)
|
|
|| ''
|
|
if (resolvedTypeId && !currentPiece.typePieceId) {
|
|
currentPiece.typePieceId = resolvedTypeId
|
|
}
|
|
selectedTypeId.value = resolvedTypeId
|
|
|
|
editionForm.name = currentPiece.name || ''
|
|
editionForm.description = currentPiece.description || ''
|
|
editionForm.reference = currentPiece.reference || ''
|
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
|
currentPiece,
|
|
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
|
|
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
|
)
|
|
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
|
if (editionForm.constructeurIds.length) {
|
|
void ensureConstructeurs(editionForm.constructeurIds)
|
|
}
|
|
|
|
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
|
? currentPiece.productIds.map((id: unknown) => String(id))
|
|
: currentPiece.product?.id || currentPiece.productId
|
|
? [String(currentPiece.product?.id || currentPiece.productId)]
|
|
: []
|
|
pendingProductIds = existingProductIds
|
|
ensureProductSelections(structureProducts.value.length)
|
|
if (existingProductIds.length && structureProducts.value.length) {
|
|
const next = Array.from(
|
|
{ length: structureProducts.value.length },
|
|
(_, index) => existingProductIds[index] ?? null,
|
|
)
|
|
productSelections.value = next
|
|
pendingProductIds = []
|
|
}
|
|
|
|
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
|
// the stale destructured currentType which was captured before the ID change.
|
|
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
|
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
|
|
|
initialized = true
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(selectedType, (currentType) => {
|
|
if (!piece.value || !currentType) {
|
|
return
|
|
}
|
|
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
|
})
|
|
|
|
watch(resolvedStructure, (currentStructure) => {
|
|
if (!piece.value) {
|
|
return
|
|
}
|
|
ensureProductSelections(structureProducts.value.length)
|
|
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
|
})
|
|
|
|
const submitEdition = async () => {
|
|
if (!piece.value) {
|
|
return
|
|
}
|
|
|
|
if (!productSelectionsFilled.value) {
|
|
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
return
|
|
}
|
|
|
|
const rawPrice = typeof editionForm.prix === 'string'
|
|
? editionForm.prix.trim()
|
|
: editionForm.prix === null || editionForm.prix === undefined
|
|
? ''
|
|
: String(editionForm.prix).trim()
|
|
|
|
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
|
|
|
const payload: Record<string, any> = {
|
|
name: editionForm.name.trim(),
|
|
description: editionForm.description.trim() || null,
|
|
constructeurIds,
|
|
}
|
|
|
|
const reference = editionForm.reference.trim()
|
|
payload.reference = reference ? reference : null
|
|
|
|
const normalizedProductIds = collectNormalizedProductIds(
|
|
productRequirementEntries.value,
|
|
productSelections.value,
|
|
)
|
|
|
|
payload.productIds = normalizedProductIds
|
|
payload.productId = normalizedProductIds[0] || 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 updatePiece(piece.value.id, payload)
|
|
if (result.success && result.data) {
|
|
const updatedPiece = result.data as Record<string, any>
|
|
await _saveCustomFieldValues(
|
|
'piece',
|
|
updatedPiece.id,
|
|
[
|
|
updatedPiece?.typePiece?.structure?.customFields,
|
|
],
|
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
)
|
|
toast.showSuccess('Pièce mise à jour avec succès.')
|
|
}
|
|
}
|
|
catch (error: any) {
|
|
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
|
|
}
|
|
finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
|
loading.value = false
|
|
})
|
|
|
|
return {
|
|
// State
|
|
piece,
|
|
loading,
|
|
saving,
|
|
selectedFiles,
|
|
uploadingDocuments,
|
|
loadingDocuments,
|
|
pieceDocuments,
|
|
previewDocument,
|
|
previewVisible,
|
|
selectedTypeId,
|
|
editionForm,
|
|
productSelections,
|
|
customFieldInputs,
|
|
canEdit,
|
|
|
|
// Computed
|
|
pieceTypeList,
|
|
selectedType,
|
|
resolvedStructure,
|
|
structureProducts,
|
|
productRequirementDescriptions,
|
|
productRequirementEntries,
|
|
canSubmit,
|
|
historyFieldLabels,
|
|
|
|
// History
|
|
history,
|
|
historyLoading,
|
|
historyError,
|
|
|
|
// Methods
|
|
openPreview,
|
|
closePreview,
|
|
removeDocument,
|
|
handleFilesAdded,
|
|
setProductSelection,
|
|
submitEdition,
|
|
formatPieceStructurePreview,
|
|
}
|
|
}
|