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>
413 lines
13 KiB
TypeScript
413 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 { useDocuments } from '~/composables/useDocuments'
|
||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||
import {
|
||
getStructurePieces,
|
||
resolvePieceLabel as _resolvePieceLabel,
|
||
resolveProductLabel as _resolveProductLabel,
|
||
resolveSubcomponentLabel,
|
||
fetchModelTypeNames,
|
||
buildTypeLabelMap,
|
||
} from '~/shared/utils/structureDisplayUtils'
|
||
import {
|
||
hasAssignments,
|
||
initializeStructureAssignments,
|
||
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 { uploadDocuments } = useDocuments()
|
||
const { syncLinks } = useConstructeurLinks()
|
||
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||
const lastSuggestedName = ref('')
|
||
const createdComponentId = ref<string | null>(null)
|
||
|
||
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(
|
||
() => !submitting.value && (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 {
|
||
fields: customFieldInputs,
|
||
requiredFilled: requiredCustomFieldsFilled,
|
||
saveAll: saveAllCustomFields,
|
||
refresh: refreshCustomFieldInputs,
|
||
} = useCustomFieldInputs({
|
||
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||
values: computed(() => []),
|
||
entityType: 'composant',
|
||
entityId: createdComponentId,
|
||
context: 'standalone',
|
||
})
|
||
|
||
const structureHasRequirements = computed(() =>
|
||
hasAssignments(structureAssignments.value),
|
||
)
|
||
|
||
const structureSelectionsComplete = computed(() => true)
|
||
|
||
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()
|
||
structureAssignments.value = null
|
||
return
|
||
}
|
||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||
creationForm.name = type.name
|
||
}
|
||
lastSuggestedName.value = creationForm.name
|
||
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||
refreshCustomFieldInputs()
|
||
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
|
||
}
|
||
|
||
// constructeurIds are handled via link entities, not in the main payload
|
||
|
||
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()
|
||
}
|
||
|
||
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>
|
||
createdComponentId.value = createdComponent.id
|
||
const failedFields = await saveAllCustomFields()
|
||
if (failedFields.length) {
|
||
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||
}
|
||
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 = []
|
||
}
|
||
// Sync constructeur links after creation
|
||
if (constructeurLinks.value.length) {
|
||
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
|
||
}
|
||
toast.showSuccess('Composant créé avec succès')
|
||
await router.replace(`/component/${createdComponent.id}?edit=true`)
|
||
}
|
||
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,
|
||
constructeurLinks,
|
||
constructeurIdsFromForm,
|
||
customFieldInputs,
|
||
structureAssignments,
|
||
selectedDocuments,
|
||
uploadingDocuments,
|
||
|
||
// Computed
|
||
loadingTypes,
|
||
componentTypeList,
|
||
selectedType,
|
||
selectedTypeStructure,
|
||
availablePieces,
|
||
availableProducts,
|
||
availableComponents,
|
||
piecesLoading,
|
||
productsLoading,
|
||
componentsLoading,
|
||
structureDataLoading,
|
||
pieceTypeLabelMap,
|
||
productTypeLabelMap,
|
||
componentTypeLabelMap,
|
||
structureHasRequirements,
|
||
structureSelectionsComplete,
|
||
canEdit,
|
||
canSubmit,
|
||
requiredCustomFieldsFilled,
|
||
|
||
// Functions
|
||
typeOptionLabel,
|
||
typeOptionDescription,
|
||
formatStructurePreview,
|
||
resolvePieceLabel,
|
||
resolveProductLabel,
|
||
resolveSubcomponentLabel,
|
||
submitCreation,
|
||
}
|
||
}
|