Consolidate create and edit pages into single create pages with edit mode support. Remove obsolete catalog pages, history composables, and fix remaining code review issues. Include migration to relink orphaned custom fields. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
466 lines
14 KiB
TypeScript
466 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 { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
|
import { useEntityHistory } from '~/composables/useEntityHistory'
|
|
import { extractRelationId } from '~/shared/apiRelations'
|
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
|
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
|
import type { ConstructeurLinkEntry } 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 { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
|
|
|
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 toast = useToast()
|
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
|
const { ensureConstructeurs } = useConstructeurs()
|
|
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
|
const {
|
|
history,
|
|
loading: historyLoading,
|
|
error: historyError,
|
|
loadHistory,
|
|
} = useEntityHistory('piece')
|
|
|
|
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
|
const productSelections = ref<(string | null)[]>([])
|
|
|
|
// Declared early so useCustomFieldInputs can reference it.
|
|
// selectedType is defined later but is safely accessed inside a computed (lazy evaluation).
|
|
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
|
pieceTypeDetails.value?.structure ?? null,
|
|
)
|
|
|
|
const {
|
|
fields: customFieldInputs,
|
|
requiredFilled: requiredCustomFieldsFilled,
|
|
saveAll: saveAllCustomFields,
|
|
refresh: refreshCustomFieldInputs,
|
|
} = useCustomFieldInputs({
|
|
definitions: computed(() => resolvedStructure.value?.customFields ?? []),
|
|
values: computed(() => piece.value?.customFieldValues ?? []),
|
|
entityType: 'piece',
|
|
entityId: computed(() => piece.value?.id ?? null),
|
|
onValueCreated: (newValue) => {
|
|
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
|
|
piece.value.customFieldValues.push(newValue)
|
|
}
|
|
},
|
|
})
|
|
|
|
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 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 : []
|
|
|
|
// The watcher on useCustomFieldInputs will auto-refresh when piece.value changes
|
|
|
|
// 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
|
|
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
|
return
|
|
}
|
|
// Fallback: fetch if not in cache (edge case)
|
|
getModelType(typeId).then((type) => {
|
|
if (type && typeof type === 'object') {
|
|
pieceTypeDetails.value = type
|
|
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
|
}
|
|
}).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 || ''
|
|
// Load constructeur links
|
|
fetchLinks('piece', pieceId).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 = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
|
|
|
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 = []
|
|
}
|
|
|
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
|
|
|
|
initialized = true
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// useCustomFieldInputs auto-refreshes when selectedType changes (via resolvedStructure)
|
|
|
|
watch(resolvedStructure, () => {
|
|
if (!piece.value) {
|
|
return
|
|
}
|
|
ensureProductSelections(structureProducts.value.length)
|
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
|
})
|
|
|
|
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 payload: Record<string, any> = {
|
|
name: editionForm.name.trim(),
|
|
description: editionForm.description.trim() || null,
|
|
}
|
|
|
|
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 failedFields = await saveAllCustomFields()
|
|
if (failedFields.length) {
|
|
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
|
}
|
|
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
|
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
|
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,
|
|
constructeurLinks,
|
|
originalConstructeurLinks,
|
|
constructeurIdsFromForm,
|
|
productSelections,
|
|
customFieldInputs,
|
|
canEdit,
|
|
|
|
// Computed
|
|
pieceTypeList,
|
|
selectedType,
|
|
resolvedStructure,
|
|
structureProducts,
|
|
productRequirementDescriptions,
|
|
productRequirementEntries,
|
|
canSubmit,
|
|
historyFieldLabels,
|
|
|
|
// History
|
|
history,
|
|
historyLoading,
|
|
historyError,
|
|
|
|
// Methods
|
|
openPreview,
|
|
closePreview,
|
|
removeDocument,
|
|
handleFilesAdded,
|
|
setProductSelection,
|
|
submitEdition,
|
|
fetchPiece,
|
|
formatPieceStructurePreview,
|
|
}
|
|
}
|