147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
|
|
/**
|
||
|
|
* CustomFieldRenderer — renders custom fields based on field_type.
|
||
|
|
* Supports read-only and editable modes.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React from 'react';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { Input } from '@/components/ui/Input';
|
||
|
|
import { Select } from '@/components/ui/Select';
|
||
|
|
import type { CustomFieldDefinition } from '@/api/customFields';
|
||
|
|
|
||
|
|
export interface CustomFieldRendererProps {
|
||
|
|
fields: CustomFieldDefinition[];
|
||
|
|
mode: 'read' | 'edit';
|
||
|
|
values?: Record<string, any>;
|
||
|
|
onChange?: (name: string, value: any) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatValue(value: any, fieldType: string): string {
|
||
|
|
if (value === null || value === undefined || value === '') return '—';
|
||
|
|
if (fieldType === 'boolean') return value ? '✓' : '✗';
|
||
|
|
if (fieldType === 'multiselect' && Array.isArray(value)) return value.join(', ');
|
||
|
|
if (fieldType === 'date' && value) {
|
||
|
|
try {
|
||
|
|
return new Date(value).toLocaleDateString();
|
||
|
|
} catch {
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function CustomFieldRenderer({ fields, mode, values = {}, onChange }: CustomFieldRendererProps) {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
|
||
|
|
if (!fields || fields.length === 0) return null;
|
||
|
|
|
||
|
|
if (mode === 'read') {
|
||
|
|
return (
|
||
|
|
<dl className="grid grid-cols-2 gap-3" data-testid="custom-fields-read">
|
||
|
|
{fields.map((field) => {
|
||
|
|
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||
|
|
const value = values[field.name] ?? field.value;
|
||
|
|
return (
|
||
|
|
<div key={field.name}>
|
||
|
|
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
|
||
|
|
<dd className="text-sm text-secondary-900">{formatValue(value, field.field_type)}</dd>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</dl>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="grid grid-cols-2 gap-3" data-testid="custom-fields-edit">
|
||
|
|
{fields.map((field) => {
|
||
|
|
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||
|
|
const value = values[field.name] ?? field.value ?? field.default_value ?? '';
|
||
|
|
|
||
|
|
if (field.field_type === 'boolean') {
|
||
|
|
return (
|
||
|
|
<div key={field.name} className="flex items-center gap-2">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
id={`cf-${field.name}`}
|
||
|
|
checked={!!value}
|
||
|
|
onChange={(e) => onChange?.(field.name, e.target.checked)}
|
||
|
|
className="h-4 w-4 rounded border-secondary-300"
|
||
|
|
/>
|
||
|
|
<label htmlFor={`cf-${field.name}`} className="text-sm text-secondary-700">
|
||
|
|
{label}{field.required && ' *'}
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (field.field_type === 'select') {
|
||
|
|
return (
|
||
|
|
<div key={field.name}>
|
||
|
|
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||
|
|
{label}{field.required && ' *'}
|
||
|
|
</label>
|
||
|
|
<Select
|
||
|
|
id={`cf-${field.name}`}
|
||
|
|
value={value || ''}
|
||
|
|
options={field.options.map((opt) => ({ value: opt, label: opt }))}
|
||
|
|
onChange={(e) => onChange?.(field.name, e.target.value || null)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (field.field_type === 'multiselect') {
|
||
|
|
const selected: string[] = Array.isArray(value) ? value : [];
|
||
|
|
return (
|
||
|
|
<div key={field.name}>
|
||
|
|
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||
|
|
{label}{field.required && ' *'}
|
||
|
|
</label>
|
||
|
|
<div className="flex flex-wrap gap-2 mt-1">
|
||
|
|
{field.options.map((opt) => (
|
||
|
|
<label key={opt} className="flex items-center gap-1 text-sm">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={selected.includes(opt)}
|
||
|
|
onChange={(e) => {
|
||
|
|
if (e.target.checked) {
|
||
|
|
onChange?.(field.name, [...selected, opt]);
|
||
|
|
} else {
|
||
|
|
onChange?.(field.name, selected.filter((v) => v !== opt));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
className="h-4 w-4 rounded border-secondary-300"
|
||
|
|
/>
|
||
|
|
{opt}
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// text, number, date
|
||
|
|
return (
|
||
|
|
<div key={field.name}>
|
||
|
|
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||
|
|
{label}{field.required && ' *'}
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id={`cf-${field.name}`}
|
||
|
|
type={field.field_type === 'number' ? 'number' : field.field_type === 'date' ? 'date' : 'text'}
|
||
|
|
value={value ?? ''}
|
||
|
|
onChange={(e) => {
|
||
|
|
let val: any = e.target.value;
|
||
|
|
if (field.field_type === 'number' && val !== '') val = Number(val);
|
||
|
|
if (val === '') val = null;
|
||
|
|
onChange?.(field.name, val);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|