Added custom fields for clients

X-Lovable-Edit-ID: edt-96db0b90-3364-47ab-ac3a-cd861e5a0aa6
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 17:14:52 +00:00
co-authored by renee-png
8 changed files with 497 additions and 1 deletions
BIN
View File
Binary file not shown.
@@ -0,0 +1,116 @@
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 ClientCustomFieldsTab({ clientId }: { clientId: 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_client_fields")
.select("id,key,label,field_type,description")
.eq("active", true)
.order("sort_order"),
supabase.from("client_field_values").select("field_id,value").eq("client_id", clientId),
]);
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);
})();
}, [clientId]);
const save = async () => {
setSaving(true);
const rows = defs.map((d) => ({
client_id: clientId,
field_id: d.id,
value: values[d.id] ?? "",
}));
for (const row of rows) {
const { error } = await supabase
.from("client_field_values")
.upsert(row, { onConflict: "client_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 client fields are configured. An admin can add them in <strong>Settings → Custom client fields</strong>.
</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">{`{{client.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>
);
}
+81
View File
@@ -412,6 +412,48 @@ export type Database = {
},
]
}
client_field_values: {
Row: {
client_id: string
created_at: string
field_id: string
id: string
updated_at: string
value: string | null
}
Insert: {
client_id: string
created_at?: string
field_id: string
id?: string
updated_at?: string
value?: string | null
}
Update: {
client_id?: string
created_at?: string
field_id?: string
id?: string
updated_at?: string
value?: string | null
}
Relationships: [
{
foreignKeyName: "client_field_values_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_field_values_field_id_fkey"
columns: ["field_id"]
isOneToOne: false
referencedRelation: "custom_client_fields"
referencedColumns: ["id"]
},
]
}
clients: {
Row: {
address_line1: string | null
@@ -1007,6 +1049,45 @@ export type Database = {
}
Relationships: []
}
custom_client_fields: {
Row: {
active: boolean
created_at: string
created_by: string | null
description: string | null
field_type: string
id: string
key: string
label: string
sort_order: number
updated_at: string
}
Insert: {
active?: boolean
created_at?: string
created_by?: string | null
description?: string | null
field_type?: string
id?: string
key: string
label: string
sort_order?: number
updated_at?: string
}
Update: {
active?: boolean
created_at?: string
created_by?: string | null
description?: string | null
field_type?: string
id?: string
key?: string
label?: string
sort_order?: number
updated_at?: string
}
Relationships: []
}
custom_form_templates: {
Row: {
body_html: string
+21
View File
@@ -36,6 +36,7 @@ import { Route as SettingsImapRouteImport } from './routes/settings.imap'
import { Route as SettingsFormTemplatesRouteImport } from './routes/settings.form-templates'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields'
import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
@@ -184,6 +185,11 @@ const SettingsCustomFieldsRoute = SettingsCustomFieldsRouteImport.update({
path: '/custom-fields',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsClientFieldsRoute = SettingsClientFieldsRouteImport.update({
id: '/client-fields',
path: '/client-fields',
getParentRoute: () => SettingsRoute,
} as any)
const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
id: '/invoices/$invoiceId',
path: '/invoices/$invoiceId',
@@ -259,6 +265,7 @@ export interface FileRoutesByFullPath {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/form-templates': typeof SettingsFormTemplatesRoute
@@ -299,6 +306,7 @@ export interface FileRoutesByTo {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/form-templates': typeof SettingsFormTemplatesRoute
@@ -341,6 +349,7 @@ export interface FileRoutesById {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/client-fields': typeof SettingsClientFieldsRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/form-templates': typeof SettingsFormTemplatesRoute
@@ -384,6 +393,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/client-fields'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/form-templates'
@@ -424,6 +434,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/client-fields'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/form-templates'
@@ -465,6 +476,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/client-fields'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/form-templates'
@@ -717,6 +729,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsCustomFieldsRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/client-fields': {
id: '/settings/client-fields'
path: '/client-fields'
fullPath: '/settings/client-fields'
preLoaderRoute: typeof SettingsClientFieldsRouteImport
parentRoute: typeof SettingsRoute
}
'/invoices/$invoiceId': {
id: '/invoices/$invoiceId'
path: '/invoices/$invoiceId'
@@ -805,6 +824,7 @@ declare module '@tanstack/react-router' {
}
interface SettingsRouteChildren {
SettingsClientFieldsRoute: typeof SettingsClientFieldsRoute
SettingsCustomFieldsRoute: typeof SettingsCustomFieldsRoute
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsFormTemplatesRoute: typeof SettingsFormTemplatesRoute
@@ -818,6 +838,7 @@ interface SettingsRouteChildren {
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsClientFieldsRoute: SettingsClientFieldsRoute,
SettingsCustomFieldsRoute: SettingsCustomFieldsRoute,
SettingsFeesRoute: SettingsFeesRoute,
SettingsFormTemplatesRoute: SettingsFormTemplatesRoute,
+7 -1
View File
@@ -9,8 +9,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { supabase } from "@/integrations/supabase/client";
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore } from "lucide-react";
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore, ListPlus } from "lucide-react";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { ClientCustomFieldsTab } from "@/components/clients/client-custom-fields-tab";
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
import { toast } from "sonner";
import { downloadStatusReport } from "@/lib/status-pdf";
@@ -235,6 +236,7 @@ function ClientDetail() {
<TabsTrigger value="cases" className="flex-1"><Briefcase className="h-3.5 w-3.5 mr-1.5" />Cases</TabsTrigger>
<TabsTrigger value="status" className="flex-1"><Activity className="h-3.5 w-3.5 mr-1.5" />Status</TabsTrigger>
<TabsTrigger value="contacts" className="flex-1"><Contact className="h-3.5 w-3.5 mr-1.5" />Contacts</TabsTrigger>
<TabsTrigger value="custom" className="flex-1"><ListPlus className="h-3.5 w-3.5 mr-1.5" />Fields</TabsTrigger>
</TabsList>
<TabsContent value="cases">
@@ -308,6 +310,10 @@ function ClientDetail() {
<TabsContent value="contacts">
<ContactsLinkTab parentId={clientId} parentTable="client_contacts" />
</TabsContent>
<TabsContent value="custom">
<ClientCustomFieldsTab clientId={clientId} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
+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/client-fields")({
component: ClientFieldsPage,
});
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 ClientFieldsPage() {
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_client_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_client_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_client_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 client values will be removed.")) return;
const { error } = await supabase.from("custom_client_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 client fields.</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Add a custom client 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. Tax ID"
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">{`{{client.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 client 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">{`{{client.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>
);
}
+1
View File
@@ -14,6 +14,7 @@ const TABS = [
{ to: "/settings", label: "Company", exact: true },
{ to: "/settings/fees", label: "Fee schedule" },
{ to: "/settings/custom-fields", label: "Custom case fields" },
{ to: "/settings/client-fields", label: "Custom client fields" },
{ to: "/settings/form-templates", label: "Form templates" },
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
@@ -0,0 +1,63 @@
-- Custom client fields (admin-defined)
CREATE TABLE public.custom_client_fields (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text NOT NULL UNIQUE,
label text NOT NULL,
field_type text NOT NULL DEFAULT 'text',
description text,
sort_order integer NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.custom_client_fields ENABLE ROW LEVEL SECURITY;
CREATE POLICY ccf_client_select_auth ON public.custom_client_fields
FOR SELECT TO authenticated USING (true);
CREATE POLICY ccf_client_insert_admin ON public.custom_client_fields
FOR INSERT TO authenticated WITH CHECK (is_admin(auth.uid()));
CREATE POLICY ccf_client_update_admin ON public.custom_client_fields
FOR UPDATE TO authenticated USING (is_admin(auth.uid()));
CREATE POLICY ccf_client_delete_admin ON public.custom_client_fields
FOR DELETE TO authenticated USING (is_admin(auth.uid()));
CREATE TRIGGER trg_ccf_client_updated_at
BEFORE UPDATE ON public.custom_client_fields
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Per-client values
CREATE TABLE public.client_field_values (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES public.clients(id) ON DELETE CASCADE,
field_id uuid NOT NULL REFERENCES public.custom_client_fields(id) ON DELETE CASCADE,
value text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (client_id, field_id)
);
ALTER TABLE public.client_field_values ENABLE ROW LEVEL SECURITY;
-- Anyone authenticated can view; admins or client creator can manage
CREATE POLICY clfv_select_auth ON public.client_field_values
FOR SELECT TO authenticated USING (true);
CREATE POLICY clfv_insert_auth ON public.client_field_values
FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY clfv_update_auth ON public.client_field_values
FOR UPDATE TO authenticated USING (
is_admin(auth.uid())
OR EXISTS (SELECT 1 FROM public.clients c WHERE c.id = client_id AND c.created_by = auth.uid())
);
CREATE POLICY clfv_delete_auth ON public.client_field_values
FOR DELETE TO authenticated USING (
is_admin(auth.uid())
OR EXISTS (SELECT 1 FROM public.clients c WHERE c.id = client_id AND c.created_by = auth.uid())
);
CREATE TRIGGER trg_clfv_updated_at
BEFORE UPDATE ON public.client_field_values
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
CREATE INDEX idx_client_field_values_client ON public.client_field_values(client_id);