333 lines
9.3 KiB
TypeScript
333 lines
9.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useCallback, useRef, useState } from 'react';
|
||
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||
|
|
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import { Switch } from '@/components/ui/switch';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
|
||
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface CustomFieldDefinition {
|
||
|
|
id: string;
|
||
|
|
fieldName: string;
|
||
|
|
fieldLabel: string;
|
||
|
|
fieldType: 'text' | 'number' | 'date' | 'boolean' | 'select';
|
||
|
|
selectOptions: string[] | null;
|
||
|
|
isRequired: boolean;
|
||
|
|
sortOrder: number;
|
||
|
|
entityType: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CustomFieldValue {
|
||
|
|
id: string;
|
||
|
|
fieldId: string;
|
||
|
|
entityId: string;
|
||
|
|
value: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FieldEntry {
|
||
|
|
definition: CustomFieldDefinition;
|
||
|
|
value: CustomFieldValue | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CustomFieldsSectionProps {
|
||
|
|
entityType: 'client' | 'interest' | 'berth';
|
||
|
|
entityId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectionProps) {
|
||
|
|
const [collapsed, setCollapsed] = useState(false);
|
||
|
|
const queryClient = useQueryClient();
|
||
|
|
|
||
|
|
// ── Data fetching ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
|
||
|
|
queryKey: ['custom-field-values', entityId],
|
||
|
|
queryFn: async () => {
|
||
|
|
const res = await apiFetch<{ data: FieldEntry[] }>(
|
||
|
|
`/api/v1/custom-fields/${entityId}`,
|
||
|
|
);
|
||
|
|
return res.data;
|
||
|
|
},
|
||
|
|
enabled: !!entityId,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Only show fields for this entity type
|
||
|
|
const filteredEntries =
|
||
|
|
entries?.filter((e) => e.definition.entityType === entityType) ?? [];
|
||
|
|
|
||
|
|
// ── Mutation ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const mutation = useMutation({
|
||
|
|
mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => {
|
||
|
|
await apiFetch(`/api/v1/custom-fields/${entityId}`, {
|
||
|
|
method: 'PUT',
|
||
|
|
body: { values },
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onSuccess: () => {
|
||
|
|
void queryClient.invalidateQueries({ queryKey: ['custom-field-values', entityId] });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-base">Custom Fields</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="space-y-3">
|
||
|
|
{[1, 2].map((i) => (
|
||
|
|
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader
|
||
|
|
className="cursor-pointer select-none"
|
||
|
|
onClick={() => setCollapsed((c) => !c)}
|
||
|
|
>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<CardTitle className="text-base">Custom Fields</CardTitle>
|
||
|
|
{collapsed ? (
|
||
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||
|
|
) : (
|
||
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
|
||
|
|
{!collapsed && (
|
||
|
|
<CardContent>
|
||
|
|
{filteredEntries.length === 0 ? (
|
||
|
|
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{filteredEntries.map((entry) => (
|
||
|
|
<FieldControl
|
||
|
|
key={entry.definition.id}
|
||
|
|
entry={entry}
|
||
|
|
onSave={(fieldId, value) =>
|
||
|
|
mutation.mutate([{ fieldId, value }])
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── FieldControl ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface FieldControlProps {
|
||
|
|
entry: FieldEntry;
|
||
|
|
onSave: (fieldId: string, value: unknown) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function FieldControl({ entry, onSave }: FieldControlProps) {
|
||
|
|
const { definition, value: savedValue } = entry;
|
||
|
|
const initialValue = savedValue?.value ?? null;
|
||
|
|
|
||
|
|
// Debounce timer ref
|
||
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
|
|
||
|
|
function scheduleBlurSave(fieldId: string, val: unknown) {
|
||
|
|
// Immediate debounce cancel then save after 500ms idle
|
||
|
|
if (timer.current) clearTimeout(timer.current);
|
||
|
|
timer.current = setTimeout(() => {
|
||
|
|
onSave(fieldId, val);
|
||
|
|
}, 500);
|
||
|
|
}
|
||
|
|
|
||
|
|
const label = (
|
||
|
|
<Label htmlFor={`cf-${definition.id}`} className="text-sm font-medium">
|
||
|
|
{definition.fieldLabel}
|
||
|
|
{definition.isRequired && (
|
||
|
|
<span className="ml-0.5 text-destructive" aria-label="required">
|
||
|
|
*
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</Label>
|
||
|
|
);
|
||
|
|
|
||
|
|
if (definition.fieldType === 'boolean') {
|
||
|
|
return (
|
||
|
|
<BooleanField
|
||
|
|
definition={definition}
|
||
|
|
initialValue={initialValue as boolean | null}
|
||
|
|
label={label}
|
||
|
|
onSave={onSave}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (definition.fieldType === 'select') {
|
||
|
|
return (
|
||
|
|
<SelectField
|
||
|
|
definition={definition}
|
||
|
|
initialValue={initialValue as string | null}
|
||
|
|
label={label}
|
||
|
|
onSave={onSave}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// text / number / date
|
||
|
|
return (
|
||
|
|
<TextLikeField
|
||
|
|
definition={definition}
|
||
|
|
initialValue={initialValue}
|
||
|
|
label={label}
|
||
|
|
onScheduleSave={scheduleBlurSave}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Sub-controls ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function TextLikeField({
|
||
|
|
definition,
|
||
|
|
initialValue,
|
||
|
|
label,
|
||
|
|
onScheduleSave,
|
||
|
|
}: {
|
||
|
|
definition: CustomFieldDefinition;
|
||
|
|
initialValue: unknown;
|
||
|
|
label: React.ReactNode;
|
||
|
|
onScheduleSave: (fieldId: string, val: unknown) => void;
|
||
|
|
}) {
|
||
|
|
const [localValue, setLocalValue] = useState(
|
||
|
|
initialValue !== null && initialValue !== undefined ? String(initialValue) : '',
|
||
|
|
);
|
||
|
|
|
||
|
|
const inputType =
|
||
|
|
definition.fieldType === 'number'
|
||
|
|
? 'number'
|
||
|
|
: definition.fieldType === 'date'
|
||
|
|
? 'date'
|
||
|
|
: 'text';
|
||
|
|
|
||
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
|
|
const raw = e.target.value;
|
||
|
|
setLocalValue(raw);
|
||
|
|
|
||
|
|
let parsed: unknown = raw;
|
||
|
|
if (definition.fieldType === 'number') {
|
||
|
|
parsed = raw === '' ? null : parseFloat(raw);
|
||
|
|
} else if (raw === '') {
|
||
|
|
parsed = null;
|
||
|
|
}
|
||
|
|
onScheduleSave(definition.id, parsed);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{label}
|
||
|
|
<Input
|
||
|
|
id={`cf-${definition.id}`}
|
||
|
|
type={inputType}
|
||
|
|
value={localValue}
|
||
|
|
onChange={handleChange}
|
||
|
|
placeholder={definition.fieldLabel}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function BooleanField({
|
||
|
|
definition,
|
||
|
|
initialValue,
|
||
|
|
label,
|
||
|
|
onSave,
|
||
|
|
}: {
|
||
|
|
definition: CustomFieldDefinition;
|
||
|
|
initialValue: boolean | null;
|
||
|
|
label: React.ReactNode;
|
||
|
|
onSave: (fieldId: string, val: unknown) => void;
|
||
|
|
}) {
|
||
|
|
const [checked, setChecked] = useState(initialValue ?? false);
|
||
|
|
|
||
|
|
function handleChange(val: boolean) {
|
||
|
|
setChecked(val);
|
||
|
|
onSave(definition.id, val);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-between gap-2">
|
||
|
|
{label}
|
||
|
|
<Switch
|
||
|
|
id={`cf-${definition.id}`}
|
||
|
|
checked={checked}
|
||
|
|
onCheckedChange={handleChange}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function SelectField({
|
||
|
|
definition,
|
||
|
|
initialValue,
|
||
|
|
label,
|
||
|
|
onSave,
|
||
|
|
}: {
|
||
|
|
definition: CustomFieldDefinition;
|
||
|
|
initialValue: string | null;
|
||
|
|
label: React.ReactNode;
|
||
|
|
onSave: (fieldId: string, val: unknown) => void;
|
||
|
|
}) {
|
||
|
|
const options = definition.selectOptions ?? [];
|
||
|
|
const [selected, setSelected] = useState(initialValue ?? '');
|
||
|
|
|
||
|
|
function handleChange(val: string) {
|
||
|
|
setSelected(val);
|
||
|
|
onSave(definition.id, val === '__none__' ? null : val);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{label}
|
||
|
|
<Select value={selected || '__none__'} onValueChange={handleChange}>
|
||
|
|
<SelectTrigger id={`cf-${definition.id}`}>
|
||
|
|
<SelectValue placeholder="Select..." />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{!definition.isRequired && (
|
||
|
|
<SelectItem value="__none__">
|
||
|
|
<span className="text-muted-foreground">None</span>
|
||
|
|
</SelectItem>
|
||
|
|
)}
|
||
|
|
{options.map((opt) => (
|
||
|
|
<SelectItem key={opt} value={opt}>
|
||
|
|
{opt}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|