Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:34:27 +00:00
co-authored by renee-png
parent 6deb196d2f
commit 59a889d5ab
2 changed files with 326 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/custom-fields")({
component: CustomFieldsPage,
});
interface CustomField {
id: string;
key: string;
label: string;
field_type: string;
description: string | null;
sort_order: number;
active: boolean;
}
const TYPES = [
{ value: "text", label: "Text" },
{ value: "number", label: "Number" },
{ value: "date", label: "Date" },
{ value: "textarea", label: "Long text" },
];
function slugify(s: string) {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 40);
}
function CustomFieldsPage() {
const { isAdmin } = useAuth();
const [fields, setFields] = useState<CustomField[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ label: "", key: "", field_type: "text", description: "" });
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("custom_case_fields")
.select("*")
.order("sort_order", { ascending: true })
.order("created_at", { ascending: true });
setFields((data ?? []) as CustomField[]);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const add = async () => {
const label = form.label.trim();
if (!label) {
toast.error("Label is required");
return;
}
const key = (form.key.trim() || slugify(label)).toLowerCase();
if (!key) {
toast.error("Could not derive a key from label");
return;
}
setSaving(true);
const { error } = await supabase.from("custom_case_fields").insert({
label,
key,
field_type: form.field_type,
description: form.description.trim() || null,
sort_order: fields.length,
});
setSaving(false);
if (error) {
toast.error("Could not add field", { description: error.message });
return;
}
setForm({ label: "", key: "", field_type: "text", description: "" });
toast.success("Field added");
load();
};
const update = async (id: string, patch: Partial<CustomField>) => {
const { error } = await supabase.from("custom_case_fields").update(patch).eq("id", id);
if (error) toast.error(error.message);
else load();
};
const remove = async (id: string) => {
if (!confirm("Delete this custom field? Existing case values will be removed.")) return;
const { error } = await supabase.from("custom_case_fields").delete().eq("id", id);
if (error) toast.error(error.message);
else {
toast.success("Field removed");
load();
}
};
if (!isAdmin) {
return <p className="text-sm text-muted-foreground">Only admins can manage custom case fields.</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Add a custom case field</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>Label</Label>
<Input
value={form.label}
placeholder="e.g. Mortgage holder"
onChange={(e) => setForm({ ...form, label: e.target.value, key: form.key || slugify(e.target.value) })}
/>
</div>
<div>
<Label>Variable key</Label>
<Input
value={form.key}
placeholder="auto from label"
onChange={(e) => setForm({ ...form, key: slugify(e.target.value) })}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used in templates as <code className="font-mono">{`{{custom.${form.key || "your_key"}}}`}</code>
</p>
</div>
<div>
<Label>Type</Label>
<Select value={form.field_type} onValueChange={(v) => setForm({ ...form, field_type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Description (optional)</Label>
<Input
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Short helper text"
/>
</div>
</div>
<Button onClick={add} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
Add field
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Existing fields ({fields.length})</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : fields.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields yet. Add one above to get started.</p>
) : (
<div className="divide-y border rounded-md">
{fields.map((f) => (
<div key={f.id} className="p-3 grid grid-cols-1 md:grid-cols-[1fr_1fr_140px_100px_40px] gap-2 items-center">
<Input
value={f.label}
onChange={(e) => setFields((xs) => xs.map((x) => x.id === f.id ? { ...x, label: e.target.value } : x))}
onBlur={(e) => update(f.id, { label: e.target.value })}
/>
<div>
<code className="font-mono text-xs text-primary">{`{{custom.${f.key}}}`}</code>
</div>
<Select value={f.field_type} onValueChange={(v) => update(f.id, { field_type: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch checked={f.active} onCheckedChange={(v) => update(f.id, { active: v })} />
<span className="text-xs text-muted-foreground">Active</span>
</div>
<Button variant="ghost" size="icon" onClick={() => remove(f.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}