Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
332
src/components/shared/custom-fields-section.tsx
Normal file
332
src/components/shared/custom-fields-section.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user