/** * Unified reactive custom field management composable. * * Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts, * and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit. * * DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that * save operations can update `customFieldValueId` in place without being * overwritten on the next reactivity cycle. Call `refresh()` to re-merge * from the source definitions + values (e.g. after fetching fresh data). */ import { ref, watch, computed, type MaybeRef, toValue } from 'vue' import { useCustomFields } from '~/composables/useCustomFields' import { useToast } from '~/composables/useToast' import { mergeDefinitionsWithValues, filterByContext, formatValueForSave, shouldPersist, requiredFieldsFilled, type CustomFieldDefinition, type CustomFieldValue, type CustomFieldInput, } from '~/shared/utils/customFields' export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput } export type CustomFieldEntityType = | 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink' export interface UseCustomFieldInputsOptions { /** Custom field definitions (from ModelType structure or machine.customFields) */ definitions: MaybeRef /** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */ values: MaybeRef /** Entity type for API upsert calls */ entityType: CustomFieldEntityType /** Entity ID for API upsert calls */ entityId: MaybeRef /** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */ context?: 'standalone' | 'machine' /** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */ onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void } export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) { const { entityType, context } = options const { updateCustomFieldValue: updateApi, upsertCustomFieldValue, } = useCustomFields() const { showSuccess, showError } = useToast() // Internal mutable state — NOT a computed, so save can mutate in place const _allFields = ref([]) // Re-merge from source definitions + values const refresh = () => { const defs = toValue(options.definitions) const vals = toValue(options.values) _allFields.value = mergeDefinitionsWithValues(defs, vals) } // Auto-refresh when reactive sources change watch( () => [toValue(options.definitions), toValue(options.values)], () => refresh(), { immediate: true, deep: true }, ) // Filtered by context (standalone vs machine) const fields = computed(() => { if (!context) return _allFields.value return filterByContext(_allFields.value, context) }) // Validation const requiredFilled = computed(() => requiredFieldsFilled(fields.value)) // Build metadata for upsert when no customFieldId is available (legacy fallback) const _buildMetadata = (field: CustomFieldInput) => ({ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required, customFieldOptions: field.options, }) // Update a single field value const update = async (field: CustomFieldInput): Promise => { const id = toValue(options.entityId) if (!id) { showError(`Impossible de sauvegarder le champ "${field.name}"`) return false } const value = formatValueForSave(field) // Update existing value if (field.customFieldValueId) { const result: any = await updateApi(field.customFieldValueId, { value }) if (result.success) { showSuccess(`Champ "${field.name}" mis à jour`) return true } showError(`Erreur lors de la mise à jour du champ "${field.name}"`) return false } // Create new value via upsert — with metadata fallback when no ID const metadata = field.customFieldId ? undefined : _buildMetadata(field) const result: any = await upsertCustomFieldValue( field.customFieldId, entityType, id, value, metadata, ) if (result.success) { // Mutate in place (safe — _allFields is a ref, not computed) if (result.data?.id) { field.customFieldValueId = result.data.id } if (result.data?.customField?.id) { field.customFieldId = result.data.customField.id } // Notify parent to update its reactive source if (options.onValueCreated && result.data) { options.onValueCreated(result.data) } showSuccess(`Champ "${field.name}" enregistré`) return true } showError(`Erreur lors de l'enregistrement du champ "${field.name}"`) return false } // Save all fields that have values const saveAll = async (): Promise => { const id = toValue(options.entityId) if (!id) return ['(entity ID missing)'] const failed: string[] = [] for (const field of fields.value) { if (!shouldPersist(field)) continue const value = formatValueForSave(field) if (field.customFieldValueId) { const result: any = await updateApi(field.customFieldValueId, { value }) if (!result.success) failed.push(field.name) continue } // Upsert with metadata fallback when no customFieldId const metadata = field.customFieldId ? undefined : _buildMetadata(field) const result: any = await upsertCustomFieldValue( field.customFieldId, entityType, id, value, metadata, ) if (result.success) { if (result.data?.id) { field.customFieldValueId = result.data.id } if (result.data?.customField?.id) { field.customFieldId = result.data.customField.id } if (options.onValueCreated && result.data) { options.onValueCreated(result.data) } } else { failed.push(field.name) } } return failed } return { /** All merged fields filtered by context */ fields, /** All merged fields (unfiltered) */ allFields: _allFields, /** Whether all required fields have values */ requiredFilled, /** Update a single field value via API */ update, /** Save all fields with values, returns list of failed field names */ saveAll, /** Re-merge from source definitions + values (call after fetching fresh data) */ refresh, } }