Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
6deb196d2f
commit
59a889d5ab
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FieldDef {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
field_type: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export function CaseCustomFieldsTab({ caseId }: { caseId: string }) {
|
||||
const [defs, setDefs] = useState<FieldDef[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const [{ data: d }, { data: v }] = await Promise.all([
|
||||
supabase
|
||||
.from("custom_case_fields")
|
||||
.select("id,key,label,field_type,description")
|
||||
.eq("active", true)
|
||||
.order("sort_order"),
|
||||
supabase.from("case_field_values").select("field_id,value").eq("case_id", caseId),
|
||||
]);
|
||||
const list = (d ?? []) as FieldDef[];
|
||||
setDefs(list);
|
||||
const map: Record<string, string> = {};
|
||||
(v ?? []).forEach((row: any) => {
|
||||
map[row.field_id] = row.value ?? "";
|
||||
});
|
||||
setValues(map);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [caseId]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const rows = defs.map((d) => ({
|
||||
case_id: caseId,
|
||||
field_id: d.id,
|
||||
value: values[d.id] ?? "",
|
||||
}));
|
||||
// Upsert each row by (case_id, field_id)
|
||||
for (const row of rows) {
|
||||
const { error } = await supabase
|
||||
.from("case_field_values")
|
||||
.upsert(row, { onConflict: "case_id,field_id" });
|
||||
if (error) {
|
||||
toast.error("Save failed", { description: error.message });
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
toast.success("Custom fields saved");
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
|
||||
if (defs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No custom fields are configured. An admin can add them in <strong>Settings → Custom case fields</strong>.
|
||||
Once defined, they'll appear here and can be used as variables in Custom Forms.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{defs.map((d) => (
|
||||
<div key={d.id} className={d.field_type === "textarea" ? "md:col-span-2" : ""}>
|
||||
<Label className="flex items-center gap-2">
|
||||
{d.label}
|
||||
<code className="text-[10px] font-mono text-muted-foreground">{`{{custom.${d.key}}}`}</code>
|
||||
</Label>
|
||||
{d.field_type === "textarea" ? (
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={values[d.id] ?? ""}
|
||||
onChange={(e) => setValues((m) => ({ ...m, [d.id]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={d.field_type === "number" ? "number" : d.field_type === "date" ? "date" : "text"}
|
||||
value={values[d.id] ?? ""}
|
||||
onChange={(e) => setValues((m) => ({ ...m, [d.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
{d.description && <p className="text-[11px] text-muted-foreground mt-1">{d.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user