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>
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
/**
|
||
* Component creation page – orchestration composable.
|
||
*
|
||
* Pure structure-assignment helpers live in
|
||
* `~/shared/utils/structureAssignmentHelpers.ts`.
|
||
*/
|
||
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from '#imports'
|
||
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
|
||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||
import { useComposants } from '~/composables/useComposants'
|
||
import { usePieces } from '~/composables/usePieces'
|
||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||
import { useProducts } from '~/composables/useProducts'
|
||
import { useProductTypes } from '~/composables/useProductTypes'
|
||
import { useApi } from '~/composables/useApi'
|
||
import { useToast } from '~/composables/useToast'
|
||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||
import { useCustomFields } from '~/composables/useCustomFields'
|
||
import { useDocuments } from '~/composables/useDocuments'
|
||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||
import {
|
||
type CustomFieldInput,
|
||
normalizeCustomFieldInputs,
|
||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||
saveCustomFieldValues as _saveCustomFieldValues,
|
||
} from '~/shared/utils/customFieldFormUtils'
|
||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||
import {
|
||
getStructurePieces,
|
||
resolvePieceLabel as _resolvePieceLabel,
|
||
resolveProductLabel as _resolveProductLabel,
|
||
resolveSubcomponentLabel,
|
||
fetchModelTypeNames,
|
||
buildTypeLabelMap,
|
||
} from '~/shared/utils/structureDisplayUtils'
|
||
import {
|
||
hasAssignments,
|
||
initializeStructureAssignments,
|
||
isAssignmentNodeComplete,
|
||
serializeStructureAssignments,
|
||
} from '~/shared/utils/structureAssignmentHelpers'
|
||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||
import type { ModelType } from '~/services/modelTypes'
|
||
|
||
interface ComponentCatalogType extends ModelType {
|
||
structure: ComponentModelStructure | null
|
||
customFields?: Array<Record<string, any>>
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main composable
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function useComponentCreate() {
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { get } = useApi()
|
||
|
||
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||
const { productTypes, loadProductTypes } = useProductTypes()
|
||
const {
|
||
createComposant,
|
||
composants: componentCatalogRef,
|
||
loading: componentsLoading,
|
||
} = useComposants()
|
||
const {
|
||
pieces: pieceCatalogRef,
|
||
loading: piecesLoading,
|
||
} = usePieces()
|
||
const {
|
||
products: productCatalogRef,
|
||
loading: productsLoading,
|
||
} = useProducts()
|
||
const toast = useToast()
|
||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||
const { uploadDocuments } = useDocuments()
|
||
const { canEdit } = usePermissions()
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Local state
|
||
// -------------------------------------------------------------------------
|
||
|
||
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||
const submitting = ref(false)
|
||
const creationForm = reactive({
|
||
name: '' as string,
|
||
description: '' as string,
|
||
reference: '' as string,
|
||
constructeurIds: [] as string[],
|
||
prix: '' as string,
|
||
})
|
||
const lastSuggestedName = ref('')
|
||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||
const selectedDocuments = ref<File[]>([])
|
||
const uploadingDocuments = ref(false)
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Computed
|
||
// -------------------------------------------------------------------------
|
||
|
||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||
const structureDataLoading = computed(
|
||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||
)
|
||
|
||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||
const pieceTypeLabelMap = computed(() =>
|
||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||
)
|
||
const productTypeLabelMap = computed(() =>
|
||
buildTypeLabelMap(productTypes.value),
|
||
)
|
||
const componentTypeLabelMap = computed(() =>
|
||
buildTypeLabelMap(componentTypes.value),
|
||
)
|
||
|
||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||
(componentTypes.value || [])
|
||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||
)
|
||
|
||
const typeOptionLabel = (type?: ComponentCatalogType) =>
|
||
type?.name || 'Catégorie'
|
||
|
||
const typeOptionDescription = (type?: ComponentCatalogType) =>
|
||
type?.description ? String(type.description) : ''
|
||
|
||
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 structureHasRequirements = computed(() =>
|
||
hasAssignments(structureAssignments.value),
|
||
)
|
||
|
||
const structureSelectionsComplete = computed(() => {
|
||
if (!structureHasRequirements.value) {
|
||
return true
|
||
}
|
||
if (structureDataLoading.value) {
|
||
return false
|
||
}
|
||
if (!structureAssignments.value) {
|
||
return false
|
||
}
|
||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
||
})
|
||
|
||
const requiredCustomFieldsFilled = computed(() =>
|
||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||
)
|
||
|
||
const canSubmit = computed(() => Boolean(
|
||
canEdit.value
|
||
&& selectedType.value
|
||
&& creationForm.name
|
||
&& requiredCustomFieldsFilled.value
|
||
&& structureSelectionsComplete.value
|
||
&& !submitting.value,
|
||
))
|
||
|
||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||
|
||
const resolveProductLabel = (product: Record<string, any>) =>
|
||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Watchers
|
||
// -------------------------------------------------------------------------
|
||
|
||
watch(
|
||
() => route.query.typeId,
|
||
(value) => {
|
||
if (typeof value === 'string') {
|
||
selectedTypeId.value = value
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(selectedTypeId, (id) => {
|
||
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
|
||
if ((id || '') === current) {
|
||
return
|
||
}
|
||
const nextQuery = { ...route.query }
|
||
if (id) {
|
||
nextQuery.typeId = id
|
||
}
|
||
else {
|
||
delete nextQuery.typeId
|
||
}
|
||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||
})
|
||
|
||
const clearCreationForm = () => {
|
||
creationForm.name = ''
|
||
creationForm.description = ''
|
||
creationForm.reference = ''
|
||
creationForm.constructeurIds = []
|
||
creationForm.prix = ''
|
||
lastSuggestedName.value = ''
|
||
structureAssignments.value = null
|
||
}
|
||
|
||
watch(selectedType, (type) => {
|
||
if (!type) {
|
||
clearCreationForm()
|
||
customFieldInputs.value = []
|
||
structureAssignments.value = null
|
||
return
|
||
}
|
||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||
creationForm.name = type.name
|
||
}
|
||
lastSuggestedName.value = creationForm.name
|
||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||
})
|
||
|
||
watch(
|
||
selectedTypeStructure,
|
||
(structure) => {
|
||
const ids = getStructurePieces(structure)
|
||
.map((piece: any) => piece?.typePieceId)
|
||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||
if (!ids.length) {
|
||
return
|
||
}
|
||
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
|
||
.then((additions) => {
|
||
if (Object.keys(additions).length) {
|
||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Submission
|
||
// -------------------------------------------------------------------------
|
||
|
||
const submitCreation = async () => {
|
||
if (!selectedType.value) {
|
||
toast.showError('Sélectionnez une catégorie de composant.')
|
||
return
|
||
}
|
||
const payload: Record<string, any> = {
|
||
name: creationForm.name.trim(),
|
||
typeComposantId: selectedType.value.id,
|
||
}
|
||
|
||
const description = creationForm.description.trim()
|
||
if (description) {
|
||
payload.description = description
|
||
}
|
||
|
||
const reference = creationForm.reference.trim()
|
||
if (reference) {
|
||
payload.reference = reference
|
||
}
|
||
|
||
if (creationForm.constructeurIds.length) {
|
||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||
}
|
||
|
||
const rawPrice = typeof creationForm.prix === 'string'
|
||
? creationForm.prix.trim()
|
||
: creationForm.prix === null || creationForm.prix === undefined
|
||
? ''
|
||
: String(creationForm.prix).trim()
|
||
|
||
if (rawPrice) {
|
||
const parsed = Number(rawPrice)
|
||
if (!Number.isNaN(parsed)) {
|
||
payload.prix = String(parsed)
|
||
}
|
||
}
|
||
|
||
const rootProductSelection
|
||
= structureAssignments.value?.products?.find(
|
||
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
|
||
) ?? null
|
||
|
||
if (rootProductSelection?.selectedProductId) {
|
||
payload.productId = rootProductSelection.selectedProductId.trim()
|
||
}
|
||
|
||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||
return
|
||
}
|
||
|
||
const serializedStructure = structureHasRequirements.value
|
||
? serializeStructureAssignments(structureAssignments.value)
|
||
: null
|
||
|
||
if (serializedStructure) {
|
||
payload.structure = serializedStructure
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
const result = await createComposant(payload)
|
||
if (result.success) {
|
||
const createdComponent = result.data as Record<string, any>
|
||
await _saveCustomFieldValues(
|
||
'composant',
|
||
createdComponent.id,
|
||
[createdComponent?.typeComposant?.structure?.customFields],
|
||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||
)
|
||
if (selectedDocuments.value.length && result.data?.id) {
|
||
uploadingDocuments.value = true
|
||
const uploadResult = await uploadDocuments(
|
||
{
|
||
files: selectedDocuments.value,
|
||
context: { composantId: result.data.id },
|
||
},
|
||
{ updateStore: false },
|
||
)
|
||
if (!uploadResult.success) {
|
||
const message = uploadResult.error
|
||
? `Documents non ajoutés : ${uploadResult.error}`
|
||
: 'Documents non ajoutés : une erreur est survenue.'
|
||
toast.showError(message)
|
||
}
|
||
selectedDocuments.value = []
|
||
}
|
||
toast.showSuccess('Composant créé avec succès')
|
||
await router.push(`/component/${createdComponent.id}/edit`)
|
||
}
|
||
else if (result.error) {
|
||
toast.showError(result.error)
|
||
}
|
||
}
|
||
catch (error: any) {
|
||
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||
}
|
||
finally {
|
||
submitting.value = false
|
||
uploadingDocuments.value = false
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Initialization
|
||
// -------------------------------------------------------------------------
|
||
|
||
onMounted(async () => {
|
||
await Promise.allSettled([
|
||
loadComponentTypes(),
|
||
loadPieceTypes(),
|
||
loadProductTypes(),
|
||
])
|
||
})
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Public API
|
||
// -------------------------------------------------------------------------
|
||
|
||
return {
|
||
// State
|
||
selectedTypeId,
|
||
submitting,
|
||
creationForm,
|
||
customFieldInputs,
|
||
structureAssignments,
|
||
selectedDocuments,
|
||
uploadingDocuments,
|
||
|
||
// Computed
|
||
loadingTypes,
|
||
componentTypeList,
|
||
selectedType,
|
||
selectedTypeStructure,
|
||
availablePieces,
|
||
availableProducts,
|
||
availableComponents,
|
||
piecesLoading,
|
||
productsLoading,
|
||
componentsLoading,
|
||
structureDataLoading,
|
||
pieceTypeLabelMap,
|
||
productTypeLabelMap,
|
||
componentTypeLabelMap,
|
||
structureHasRequirements,
|
||
structureSelectionsComplete,
|
||
canEdit,
|
||
canSubmit,
|
||
|
||
// Functions
|
||
typeOptionLabel,
|
||
typeOptionDescription,
|
||
formatStructurePreview,
|
||
resolvePieceLabel,
|
||
resolveProductLabel,
|
||
resolveSubcomponentLabel,
|
||
submitCreation,
|
||
}
|
||
}
|