Files
neo/frontend/components/fields/FieldRenderer.vue
2025-12-23 23:59:04 +01:00

301 lines
10 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { DatePicker } from '@/components/ui/date-picker'
import { Badge } from '@/components/ui/badge'
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
import { Label } from '@/components/ui/label'
import LookupField from '@/components/fields/LookupField.vue'
interface Props {
field: FieldConfig
modelValue: any
mode: ViewMode
readonly?: boolean
baseUrl?: string // Base URL for API calls
recordData?: any // Full record data to access related objects
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/api/central',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const { $api } = useNuxtApp() as unknown as { $api: Function }
// For relationship fields, store the related record for display
const relatedRecord = ref<any | null>(null)
const loadingRelated = ref(false)
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const isReadOnly = computed(() => props.readonly || props.field.isReadOnly || props.mode === ViewMode.DETAIL)
const isEditMode = computed(() => props.mode === ViewMode.EDIT)
const isListMode = computed(() => props.mode === ViewMode.LIST)
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
// Check if field is a relationship field
const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type)
})
// Get relation object name (e.g., 'tenants' -> singular 'tenant')
const getRelationPropertyName = () => {
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
}
// Fetch related record for display
const fetchRelatedRecord = async () => {
if (!isRelationshipField.value || !props.modelValue) return
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
const displayField = props.field.relationDisplayField || 'name'
loadingRelated.value = true
try {
const record = await $api(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
relatedRecord.value = record
} catch (err) {
console.error('Error fetching related record:', err)
relatedRecord.value = null
} finally {
loadingRelated.value = false
}
}
// Display value for relationship fields
const relationshipDisplayValue = computed(() => {
if (!isRelationshipField.value) return props.modelValue || '-'
// First, check if the parent record data includes the related object
// This happens when backend uses .withGraphFetched()
if (props.recordData) {
const relationPropertyName = getRelationPropertyName()
const relatedObject = props.recordData[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = props.field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || props.modelValue
}
}
// Otherwise use the fetched related record
if (relatedRecord.value) {
const displayField = props.field.relationDisplayField || 'name'
return relatedRecord.value[displayField] || relatedRecord.value.id
}
// Show loading state
if (loadingRelated.value) {
return 'Loading...'
}
// Fallback to ID
return props.modelValue || '-'
})
// Watch for changes in modelValue for relationship fields
watch(() => props.modelValue, () => {
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
// Load related record on mount if needed
onMounted(() => {
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-'
switch (props.field.type) {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
case FieldType.DATETIME:
return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString()
case FieldType.BOOLEAN:
return val ? 'Yes' : 'No'
case FieldType.CURRENCY:
return `${props.field.prefix || '$'}${Number(val).toFixed(2)}${props.field.suffix || ''}`
case FieldType.SELECT:
const option = props.field.options?.find(opt => opt.value === val)
return option?.label || val
case FieldType.MULTI_SELECT:
if (!Array.isArray(val)) return '-'
return val.map(v => {
const opt = props.field.options?.find(o => o.value === v)
return opt?.label || v
}).join(', ')
default:
return String(val)
}
}
</script>
<template>
<div class="field-renderer space-y-2">
<!-- Label (shown in edit and detail modes) -->
<Label v-if="!isListMode" :for="field.id" class="flex items-center gap-2">
{{ field.label }}
<span v-if="field.isRequired && isEditMode" class="text-destructive">*</span>
</Label>
<!-- Help Text -->
<p v-if="field.helpText && !isListMode" class="text-sm text-muted-foreground">
{{ field.helpText }}
</p>
<!-- List View - Simple text display -->
<div v-if="isListMode" class="text-sm truncate">
<Badge v-if="field.type === FieldType.BOOLEAN" :variant="value ? 'default' : 'secondary'">
{{ formatValue(value) }}
</Badge>
<template v-else>
{{ formatValue(value) }}
</template>
</div>
<!-- Detail View - Formatted display -->
<div v-else-if="isDetailMode" class="space-y-1">
<div v-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
<Checkbox :checked="value" disabled />
<span class="text-sm">{{ formatValue(value) }}</span>
</div>
<div v-else-if="field.type === FieldType.MULTI_SELECT" class="flex flex-wrap gap-2">
<Badge v-for="(item, idx) in value" :key="idx" variant="secondary">
{{ props.field.options?.find(opt => opt.value === item)?.label || item }}
</Badge>
</div>
<div v-else-if="field.type === FieldType.URL && value" class="text-sm">
<a :href="value" target="_blank" class="text-primary hover:underline">
{{ value }}
</a>
</div>
<div v-else-if="field.type === FieldType.EMAIL && value" class="text-sm">
<a :href="`mailto:${value}`" class="text-primary hover:underline">
{{ value }}
</a>
</div>
<div v-else-if="field.type === FieldType.MARKDOWN && value" class="prose prose-sm">
<div v-html="value" />
</div>
<div v-else class="text-sm font-medium">
{{ formatValue(value) }}
</div>
</div>
<!-- Edit View - Input components -->
<div v-else-if="isEditMode && !isReadOnly">
<!-- Relationship Field - Lookup -->
<LookupField
v-if="field.type === FieldType.BELONGS_TO"
:field="field"
v-model="value"
:base-url="baseUrl"
/>
<!-- Text Input -->
<Input
v-else-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
:id="field.id"
v-model="value"
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"
:placeholder="field.placeholder"
:required="field.isRequired"
:disabled="field.isReadOnly"
/>
<!-- Textarea -->
<Textarea
v-else-if="field.type === FieldType.TEXTAREA || field.type === FieldType.MARKDOWN"
:id="field.id"
v-model="value"
:placeholder="field.placeholder"
:rows="field.rows || 4"
:required="field.isRequired"
:disabled="field.isReadOnly"
/>
<!-- Number Input -->
<Input
v-else-if="[FieldType.NUMBER, FieldType.CURRENCY].includes(field.type)"
:id="field.id"
v-model.number="value"
type="number"
:placeholder="field.placeholder"
:min="field.min"
:max="field.max"
:step="field.step || (field.type === FieldType.CURRENCY ? 0.01 : 1)"
:required="field.isRequired"
:disabled="field.isReadOnly"
/>
<!-- Select -->
<Select v-else-if="field.type === FieldType.SELECT" v-model="value">
<SelectTrigger :id="field.id">
<SelectValue :placeholder="field.placeholder || 'Select an option'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in field.options" :key="String(option.value)" :value="String(option.value)">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- Boolean - Checkbox -->
<div v-else-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
<Checkbox :id="field.id" v-model:checked="value" :disabled="field.isReadOnly" />
<Label :for="field.id" class="text-sm font-normal cursor-pointer">
{{ field.placeholder || field.label }}
</Label>
</div>
<!-- Date Picker -->
<DatePicker
v-else-if="[FieldType.DATE, FieldType.DATETIME].includes(field.type)"
v-model="value"
:placeholder="field.placeholder"
:disabled="field.isReadOnly"
/>
<!-- Fallback -->
<Input
v-else
:id="field.id"
v-model="value"
:placeholder="field.placeholder"
:required="field.isRequired"
:disabled="field.isReadOnly"
/>
</div>
<!-- Read-only Edit View -->
<div v-else-if="isEditMode && isReadOnly" class="text-sm text-muted-foreground">
{{ formatValue(value) }}
</div>
</div>
</template>
<style scoped>
.field-renderer {
width: 100%;
}
</style>