Added custom fields builder

X-Lovable-Edit-ID: edt-13da93a9-74ce-495e-8f4f-4c9114b419df
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:38:12 +00:00
co-authored by renee-png
9 changed files with 1315 additions and 68 deletions
+118
View File
@@ -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>
);
}
+709 -63
View File
@@ -1,79 +1,497 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Bold, Italic, Underline, FileDown, Search } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Indent as IndentIcon,
Outdent,
PenLine,
FileDown,
Save,
FolderOpen,
Search,
Trash2,
FileText,
} from "lucide-react";
import { ClientHomeownerPicker } from "./form-pickers";
import {
applyVariables,
fetchFirm,
fetchCustomFieldDefs,
fetchAccessibleCases,
fetchCaseCustomValues,
savePdfToDocuments,
SYSTEM_VARIABLES,
type ClientLite,
type HomeownerLite,
type FirmInfo,
type CustomFieldVar,
type CaseLite,
} from "@/lib/forms-shared";
import { jsPDF } from "jspdf";
import {
Document as DocxDocument,
Packer,
Paragraph as DocxParagraph,
TextRun,
HeadingLevel,
AlignmentType,
} from "docx";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
const FONT_FAMILIES = [
{ value: "Bookman Old Style", label: "Bookman Old Style", pdf: "times" },
{ value: "Times New Roman", label: "Times New Roman", pdf: "times" },
{ value: "Georgia", label: "Georgia", pdf: "times" },
{ value: "Arial", label: "Arial", pdf: "helvetica" },
{ value: "Helvetica", label: "Helvetica", pdf: "helvetica" },
{ value: "Courier New", label: "Courier New", pdf: "courier" },
];
const FONT_SIZES = [9, 10, 11, 12, 13, 14, 16, 18, 20, 24];
interface SavedTemplate {
id: string;
name: string;
description: string | null;
title: string;
hide_title: boolean;
body_html: string;
font_family: string;
font_size_pt: number;
signature_blocks: Array<{ name: string; title?: string }>;
updated_at: string;
}
export function CustomFormBuilder() {
const editorRef = useRef<HTMLDivElement>(null);
const [title, setTitle] = useState("Official Notice");
const [hideTitle, setHideTitle] = useState(false);
const [client, setClient] = useState<ClientLite | null>(null);
const [homeowner, setHomeowner] = useState<HomeownerLite | null>(null);
const [clientId, setClientId] = useState("");
const [homeownerId, setHomeownerId] = useState("");
const [firm, setFirm] = useState<FirmInfo | null>(null);
const [search, setSearch] = useState("");
const [customDefs, setCustomDefs] = useState<CustomFieldVar[]>([]);
const [fontFamily, setFontFamily] = useState("Bookman Old Style");
const [fontSize, setFontSize] = useState(12);
// Template management
const [templates, setTemplates] = useState<SavedTemplate[]>([]);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [loadDialogOpen, setLoadDialogOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState("");
const [saveAsDescription, setSaveAsDescription] = useState("");
// Save-to-case
const [saveToCaseOpen, setSaveToCaseOpen] = useState(false);
const [cases, setCases] = useState<CaseLite[]>([]);
const [selectedCaseId, setSelectedCaseId] = useState("");
const [caseSearch, setCaseSearch] = useState("");
const [saveFormat, setSaveFormat] = useState<"pdf" | "docx">("pdf");
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Underline,
TextAlign.configure({ types: ["heading", "paragraph"] }),
],
content: "<p>Start typing here…</p>",
editorProps: {
attributes: {
class: "prose prose-sm max-w-none focus:outline-none px-10 py-8 bg-white text-black min-h-[500px]",
},
},
});
useEffect(() => {
fetchFirm().then(setFirm);
fetchCustomFieldDefs().then(setCustomDefs);
loadTemplates();
}, []);
const loadTemplates = async () => {
const { data } = await supabase
.from("custom_form_templates")
.select("*")
.order("updated_at", { ascending: false });
setTemplates((data ?? []) as unknown as SavedTemplate[]);
};
// Apply font style to editor DOM
useEffect(() => {
if (!editor) return;
const el = editor.view.dom as HTMLElement;
el.style.fontFamily = `"${fontFamily}", Georgia, serif`;
el.style.fontSize = `${fontSize}pt`;
el.style.lineHeight = "1.5";
}, [editor, fontFamily, fontSize]);
const insertVar = (key: string) => {
if (!editorRef.current) return;
editorRef.current.focus();
document.execCommand("insertText", false, key);
if (!editor) return;
editor.chain().focus().insertContent(key).run();
};
const exec = (cmd: string) => {
editorRef.current?.focus();
document.execCommand(cmd, false);
const insertSignatureLine = (label: string = "Signature") => {
if (!editor) return;
editor.chain().focus().insertContent(`
<p>&nbsp;</p>
<p>______________________________</p>
<p>${label}</p>
`).run();
};
const handleExport = () => {
const raw = editorRef.current?.innerText ?? "";
const body = applyVariables(raw, {
client,
homeowner,
firmName: firm?.company_name ?? "",
});
const indent = () => {
if (!editor) return;
// tiptap doesn't have indent built-in; emulate with non-breaking spaces at start
editor.chain().focus().insertContent("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;").run();
};
const outdent = () => {
if (!editor) return;
// Best-effort: not perfectly removing — let user use undo. We expose for symmetry.
editor.chain().focus().run();
};
// Convert editor HTML into plain text lines for PDF/docx (preserves paragraphs).
const getRenderedSegments = (): { text: string; align: "left" | "center" | "right" | "justify"; bold?: boolean; italic?: boolean }[] => {
if (!editor) return [];
const html = editor.getHTML();
const tmp = document.createElement("div");
tmp.innerHTML = html;
const out: { text: string; align: "left" | "center" | "right" | "justify"; bold?: boolean; italic?: boolean }[] = [];
const walk = (node: ChildNode) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (["p", "h1", "h2", "h3", "li"].includes(tag)) {
const align = (el.style.textAlign as any) || "left";
const text = el.textContent || "";
out.push({ text, align });
return;
}
}
node.childNodes.forEach(walk);
};
tmp.childNodes.forEach(walk);
return out;
};
const getContext = async () => {
let customValues: Record<string, string> = {};
if (selectedCaseId) {
customValues = await fetchCaseCustomValues(selectedCaseId);
}
return { client, homeowner, firmName: firm?.company_name ?? "", customValues };
};
const renderTitle = (ctx: any) => applyVariables(title, ctx);
const exportPDF = async (saveToCase = false) => {
if (!editor) return;
const ctx = await getContext();
const segments = getRenderedSegments();
const doc = new jsPDF({ unit: "pt", format: "letter" });
const margin = 54;
const maxW = doc.internal.pageSize.getWidth() - margin * 2;
doc.setFont("helvetica", "bold");
doc.setFontSize(16);
doc.text(applyVariables(title, { client, homeowner, firmName: firm?.company_name ?? "" }), margin, 80);
doc.setFont("helvetica", "normal");
doc.setFontSize(11);
const lines = doc.splitTextToSize(body || " ", maxW);
doc.text(lines, margin, 110);
doc.save(`${title.replace(/\s+/g, "_")}.pdf`);
toast.success("PDF downloaded");
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const maxW = pageW - margin * 2;
let y = 80;
const fontDef = FONT_FAMILIES.find((f) => f.value === fontFamily);
const pdfFont = fontDef?.pdf ?? "times";
if (!hideTitle && title.trim()) {
doc.setFont(pdfFont, "bold");
doc.setFontSize(Math.max(fontSize + 4, 14));
const titleText = applyVariables(title, ctx);
doc.text(titleText, margin, y);
y += 30;
}
doc.setFontSize(fontSize);
for (const seg of segments) {
const text = applyVariables(seg.text, ctx) || " ";
doc.setFont(pdfFont, "normal");
const lines = doc.splitTextToSize(text, maxW);
for (const line of lines) {
if (y > pageH - margin) {
doc.addPage();
y = margin;
}
let x = margin;
if (seg.align === "center") x = pageW / 2;
else if (seg.align === "right") x = pageW - margin;
doc.text(line, x, y, {
align: seg.align === "center" ? "center" : seg.align === "right" ? "right" : "left",
});
y += fontSize * 1.4;
}
y += 4;
}
const filenameBase = (renderTitle(ctx) || "Custom_Form").replace(/\s+/g, "_");
if (saveToCase && selectedCaseId) {
const blob = doc.output("blob");
await savePdfToDocuments({
blob,
caseId: selectedCaseId,
name: `${filenameBase}.pdf`,
mimeType: "application/pdf",
});
toast.success("Saved to case files");
setSaveToCaseOpen(false);
} else {
doc.save(`${filenameBase}.pdf`);
toast.success("PDF downloaded");
}
};
const filtered = SYSTEM_VARIABLES.filter(
(v) =>
v.key.toLowerCase().includes(search.toLowerCase()) ||
v.description.toLowerCase().includes(search.toLowerCase()),
);
const exportDOCX = async (saveToCase = false) => {
if (!editor) return;
const ctx = await getContext();
const segments = getRenderedSegments();
const children: DocxParagraph[] = [];
if (!hideTitle && title.trim()) {
children.push(
new DocxParagraph({
heading: HeadingLevel.HEADING_1,
alignment: AlignmentType.CENTER,
children: [
new TextRun({
text: applyVariables(title, ctx),
bold: true,
font: fontFamily,
size: (fontSize + 4) * 2,
}),
],
}),
);
children.push(new DocxParagraph({ text: "" }));
}
for (const seg of segments) {
const align =
seg.align === "center"
? AlignmentType.CENTER
: seg.align === "right"
? AlignmentType.RIGHT
: seg.align === "justify"
? AlignmentType.JUSTIFIED
: AlignmentType.LEFT;
children.push(
new DocxParagraph({
alignment: align,
children: [
new TextRun({
text: applyVariables(seg.text, ctx),
font: fontFamily,
size: fontSize * 2,
}),
],
}),
);
}
const docx = new DocxDocument({
sections: [
{
properties: {
page: {
size: { width: 12240, height: 15840 },
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
},
},
children,
},
],
});
const blob = await Packer.toBlob(docx);
const filenameBase = (renderTitle(ctx) || "Custom_Form").replace(/\s+/g, "_");
if (saveToCase && selectedCaseId) {
await savePdfToDocuments({
blob,
caseId: selectedCaseId,
name: `${filenameBase}.docx`,
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
toast.success("Saved to case files");
setSaveToCaseOpen(false);
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filenameBase}.docx`;
a.click();
URL.revokeObjectURL(url);
toast.success("DOCX downloaded");
}
};
// ---------- Templates: save / load / delete ----------
const openSave = () => {
if (activeTemplateId) {
const t = templates.find((x) => x.id === activeTemplateId);
setSaveAsName(t?.name ?? "");
setSaveAsDescription(t?.description ?? "");
} else {
setSaveAsName(title || "Untitled form");
setSaveAsDescription("");
}
setSaveDialogOpen(true);
};
const persistTemplate = async (asNew: boolean) => {
if (!editor) return;
const payload = {
name: saveAsName.trim() || "Untitled form",
description: saveAsDescription.trim() || null,
title,
hide_title: hideTitle,
body_html: editor.getHTML(),
font_family: fontFamily,
font_size_pt: fontSize,
signature_blocks: [],
};
if (asNew || !activeTemplateId) {
const { data: u } = await supabase.auth.getUser();
const { data, error } = await supabase
.from("custom_form_templates")
.insert({ ...payload, created_by: u.user?.id })
.select("id")
.single();
if (error) {
toast.error("Save failed", { description: error.message });
return;
}
setActiveTemplateId(data.id);
toast.success("Form saved");
} else {
const { error } = await supabase
.from("custom_form_templates")
.update(payload)
.eq("id", activeTemplateId);
if (error) {
toast.error("Update failed", { description: error.message });
return;
}
toast.success("Form updated");
}
setSaveDialogOpen(false);
loadTemplates();
};
const loadTemplate = (t: SavedTemplate) => {
if (!editor) return;
setTitle(t.title);
setHideTitle(t.hide_title);
setFontFamily(t.font_family);
setFontSize(t.font_size_pt);
editor.commands.setContent(t.body_html || "<p></p>");
setActiveTemplateId(t.id);
setLoadDialogOpen(false);
toast.success(`Loaded "${t.name}"`);
};
const deleteTemplate = async (id: string) => {
if (!confirm("Delete this saved form?")) return;
const { error } = await supabase.from("custom_form_templates").delete().eq("id", id);
if (error) {
toast.error(error.message);
return;
}
if (activeTemplateId === id) setActiveTemplateId(null);
loadTemplates();
toast.success("Deleted");
};
// ---------- Save-to-case ----------
const openSaveToCase = async () => {
if (cases.length === 0) {
const cs = await fetchAccessibleCases();
setCases(cs);
}
setSaveToCaseOpen(true);
};
const filteredVars = useMemo(() => {
const all = [
...SYSTEM_VARIABLES.map((v) => ({ key: v.key, description: v.description, kind: "system" as const })),
...customDefs.map((d) => ({
key: `{{custom.${d.key}}}`,
description: d.label + (d.description ? ` — ${d.description}` : ""),
kind: "custom" as const,
})),
];
return all.filter(
(v) =>
v.key.toLowerCase().includes(search.toLowerCase()) ||
v.description.toLowerCase().includes(search.toLowerCase()),
);
}, [customDefs, search]);
const filteredCases = useMemo(() => {
const s = caseSearch.toLowerCase();
if (!s) return cases.slice(0, 50);
return cases
.filter((c) => c.case_number.toLowerCase().includes(s) || c.title.toLowerCase().includes(s))
.slice(0, 100);
}, [cases, caseSearch]);
const activeTpl = templates.find((t) => t.id === activeTemplateId);
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
{activeTpl ? (
<>Editing saved form: <strong>{activeTpl.name}</strong></>
) : (
<>Unsaved form</>
)}
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setLoadDialogOpen(true)}>
<FolderOpen className="h-4 w-4 mr-1.5" /> Load
</Button>
<Button size="sm" variant="outline" onClick={openSave}>
<Save className="h-4 w-4 mr-1.5" /> Save
</Button>
</div>
</div>
<ClientHomeownerPicker
clientId={clientId}
homeownerId={homeownerId}
@@ -86,9 +504,37 @@ export function CustomFormBuilder() {
setHomeowner(h);
}}
/>
<div>
<Label>Document title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_auto_auto] gap-3 items-end">
<div>
<Label>Document title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} disabled={hideTitle} />
</div>
<div className="flex items-center gap-2 pb-2">
<Switch checked={hideTitle} onCheckedChange={setHideTitle} id="hide-title" />
<Label htmlFor="hide-title" className="cursor-pointer">Hide title</Label>
</div>
<div>
<Label className="text-xs">Font</Label>
<Select value={fontFamily} onValueChange={setFontFamily}>
<SelectTrigger className="w-[170px] h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{FONT_FAMILIES.map((f) => (
<SelectItem key={f.value} value={f.value} style={{ fontFamily: f.value }}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Size</Label>
<Select value={String(fontSize)} onValueChange={(v) => setFontSize(parseInt(v))}>
<SelectTrigger className="w-[80px] h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{FONT_SIZES.map((s) => <SelectItem key={s} value={String(s)}>{s}pt</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
@@ -97,12 +543,8 @@ export function CustomFormBuilder() {
<Card>
<CardContent className="pt-4 space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Variables
</Label>
<p className="text-xs text-muted-foreground mt-1">
Click to insert at cursor position.
</p>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Variables</Label>
<p className="text-xs text-muted-foreground mt-1">Click to insert at cursor.</p>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -113,20 +555,28 @@ export function CustomFormBuilder() {
className="pl-8 h-8 text-xs"
/>
</div>
<ScrollArea className="h-[360px] pr-2">
<ScrollArea className="h-[420px] pr-2">
<div className="space-y-1">
{filtered.map((v) => (
{filteredVars.map((v) => (
<button
key={v.key}
onClick={() => insertVar(v.key)}
className="w-full text-left px-2.5 py-2 rounded hover:bg-accent transition-colors border border-transparent hover:border-border"
>
<div className="font-mono text-xs font-semibold text-primary">{v.key}</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{v.description}
<div className="font-mono text-xs font-semibold text-primary flex items-center gap-1">
{v.key}
{v.kind === "custom" && (
<span className="text-[9px] px-1 rounded bg-accent text-accent-foreground">custom</span>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{v.description}</div>
</button>
))}
{customDefs.length === 0 && (
<p className="text-[11px] text-muted-foreground italic px-2 pt-2">
Tip: define custom case fields in <strong>Settings → Custom case fields</strong> to use them here as <code>{"{{custom.key}}"}</code>.
</p>
)}
</div>
</ScrollArea>
</CardContent>
@@ -134,33 +584,229 @@ export function CustomFormBuilder() {
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-1 pb-3 border-b mb-3">
<Button variant="ghost" size="icon" onClick={() => exec("bold")}>
<div className="flex flex-wrap items-center gap-1 pb-3 border-b mb-3">
<ToolbarBtn title="Bold" onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}>
<Bold className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => exec("italic")}>
</ToolbarBtn>
<ToolbarBtn title="Italic" onClick={() => editor?.chain().focus().toggleItalic().run()} active={editor?.isActive("italic")}>
<Italic className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Underline" onClick={() => editor?.chain().focus().toggleUnderline().run()} active={editor?.isActive("underline")}>
<UnderlineIcon className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn title="Align left" onClick={() => editor?.chain().focus().setTextAlign("left").run()} active={editor?.isActive({ textAlign: "left" })}>
<AlignLeft className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Center" onClick={() => editor?.chain().focus().setTextAlign("center").run()} active={editor?.isActive({ textAlign: "center" })}>
<AlignCenter className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Align right" onClick={() => editor?.chain().focus().setTextAlign("right").run()} active={editor?.isActive({ textAlign: "right" })}>
<AlignRight className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Justify" onClick={() => editor?.chain().focus().setTextAlign("justify").run()} active={editor?.isActive({ textAlign: "justify" })}>
<AlignJustify className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn title="Bullet list" onClick={() => editor?.chain().focus().toggleBulletList().run()} active={editor?.isActive("bulletList")}>
<List className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Numbered list" onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>
<ListOrdered className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Indent" onClick={indent}>
<IndentIcon className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Outdent (use Undo)" onClick={outdent}>
<Outdent className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<Button
size="sm"
variant="ghost"
onClick={() => insertSignatureLine("Signature")}
title="Insert signature line"
>
<PenLine className="h-4 w-4 mr-1" /> Signature
</Button>
<Button variant="ghost" size="icon" onClick={() => exec("underline")}>
<Underline className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-6 mx-2" />
<Button size="sm" className="ml-auto" onClick={handleExport}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
<Button
size="sm"
variant="ghost"
onClick={() => insertSignatureLine("Print Name & Title")}
title="Insert print-name line"
>
<PenLine className="h-4 w-4 mr-1" /> Name/Title
</Button>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="outline" onClick={openSaveToCase}>
<FileText className="h-4 w-4 mr-1.5" /> Save to case
</Button>
<Button size="sm" variant="outline" onClick={() => exportDOCX(false)}>
<FileDown className="h-4 w-4 mr-1.5" /> DOCX
</Button>
<Button size="sm" onClick={() => exportPDF(false)}>
<FileDown className="h-4 w-4 mr-1.5" /> PDF
</Button>
</div>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="min-h-[400px] outline-none text-sm leading-relaxed p-4 border rounded bg-background"
>
<p className="text-muted-foreground">Start typing here…</p>
<div className="bg-muted/20 overflow-auto">
<div className="max-w-[8.5in] mx-auto my-2 shadow-sm border">
<EditorContent editor={editor} />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Save dialog */}
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save form</DialogTitle>
<DialogDescription>
Save this design as a reusable template. You can load it later from any device.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Name</Label>
<Input value={saveAsName} onChange={(e) => setSaveAsName(e.target.value)} />
</div>
<div>
<Label>Description (optional)</Label>
<Input value={saveAsDescription} onChange={(e) => setSaveAsDescription(e.target.value)} />
</div>
</div>
<DialogFooter className="flex-wrap gap-2">
<Button variant="ghost" onClick={() => setSaveDialogOpen(false)}>Cancel</Button>
{activeTemplateId && (
<Button variant="outline" onClick={() => persistTemplate(true)}>Save as new</Button>
)}
<Button onClick={() => persistTemplate(false)}>
{activeTemplateId ? "Update" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Load dialog */}
<Dialog open={loadDialogOpen} onOpenChange={setLoadDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Load saved form</DialogTitle>
<DialogDescription>Select a previously saved template to load into the editor.</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[400px] pr-2">
{templates.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No saved forms yet.</p>
) : (
<div className="space-y-1">
{templates.map((t) => (
<div
key={t.id}
className="flex items-center justify-between gap-2 p-3 rounded border hover:bg-accent transition-colors"
>
<button className="flex-1 text-left" onClick={() => loadTemplate(t)}>
<div className="font-medium text-sm">{t.name}</div>
{t.description && (
<div className="text-xs text-muted-foreground mt-0.5">{t.description}</div>
)}
<div className="text-[10px] text-muted-foreground mt-1">
{t.font_family} · {t.font_size_pt}pt · updated {new Date(t.updated_at).toLocaleDateString()}
</div>
</button>
<Button size="icon" variant="ghost" onClick={() => deleteTemplate(t.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
{/* Save to case dialog */}
<Dialog open={saveToCaseOpen} onOpenChange={setSaveToCaseOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Save to case files</DialogTitle>
<DialogDescription>
Pick a case to attach this generated form to. Custom case-field values will be merged into the variables.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="Search by case number or title…"
value={caseSearch}
onChange={(e) => setCaseSearch(e.target.value)}
/>
<ScrollArea className="h-[260px] border rounded">
{filteredCases.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">No matching cases.</p>
) : (
<div className="divide-y">
{filteredCases.map((c) => (
<button
key={c.id}
onClick={() => setSelectedCaseId(c.id)}
className={`w-full text-left p-2.5 hover:bg-accent ${selectedCaseId === c.id ? "bg-accent" : ""}`}
>
<div className="text-xs font-mono text-muted-foreground">{c.case_number}</div>
<div className="text-sm">{c.title}</div>
</button>
))}
</div>
)}
</ScrollArea>
<div className="flex items-center gap-2">
<Label className="text-xs">Format:</Label>
<Select value={saveFormat} onValueChange={(v) => setSaveFormat(v as any)}>
<SelectTrigger className="w-[120px] h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="docx">DOCX</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setSaveToCaseOpen(false)}>Cancel</Button>
<Button
disabled={!selectedCaseId}
onClick={() => (saveFormat === "pdf" ? exportPDF(true) : exportDOCX(true))}
>
<FileText className="h-4 w-4 mr-1.5" /> Save to case
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ToolbarBtn({
onClick,
active,
children,
title,
}: {
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
}) {
return (
<Button
type="button"
variant={active ? "secondary" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={onClick}
title={title}
>
{children}
</Button>
);
}
+126
View File
@@ -141,6 +141,48 @@ export type Database = {
},
]
}
case_field_values: {
Row: {
case_id: string
created_at: string
field_id: string
id: string
updated_at: string
value: string | null
}
Insert: {
case_id: string
created_at?: string
field_id: string
id?: string
updated_at?: string
value?: string | null
}
Update: {
case_id?: string
created_at?: string
field_id?: string
id?: string
updated_at?: string
value?: string | null
}
Relationships: [
{
foreignKeyName: "case_field_values_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "case_field_values_field_id_fkey"
columns: ["field_id"]
isOneToOne: false
referencedRelation: "custom_case_fields"
referencedColumns: ["id"]
},
]
}
cases: {
Row: {
assigned_attorney_id: string | null
@@ -859,6 +901,90 @@ export type Database = {
}
Relationships: []
}
custom_case_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
created_at: string
created_by: string | null
description: string | null
font_family: string
font_size_pt: number
hide_title: boolean
id: string
name: string
signature_blocks: Json
title: string
updated_at: string
}
Insert: {
body_html?: string
created_at?: string
created_by?: string | null
description?: string | null
font_family?: string
font_size_pt?: number
hide_title?: boolean
id?: string
name: string
signature_blocks?: Json
title?: string
updated_at?: string
}
Update: {
body_html?: string
created_at?: string
created_by?: string | null
description?: string | null
font_family?: string
font_size_pt?: number
hide_title?: boolean
id?: string
name?: string
signature_blocks?: Json
title?: string
updated_at?: string
}
Relationships: []
}
document_folders: {
Row: {
case_id: string
+59 -4
View File
@@ -78,9 +78,29 @@ export const SYSTEM_VARIABLES = [
{ key: "{{firmName}}", description: "Your firm name" },
] as const;
export interface CustomFieldVar {
key: string;
label: string;
description: string | null;
}
export async function fetchCustomFieldDefs(): Promise<CustomFieldVar[]> {
const { data } = await supabase
.from("custom_case_fields")
.select("key,label,description")
.eq("active", true)
.order("sort_order");
return (data ?? []) as CustomFieldVar[];
}
export function applyVariables(
body: string,
ctx: { client?: ClientLite | null; homeowner?: HomeownerLite | null; firmName?: string },
ctx: {
client?: ClientLite | null;
homeowner?: HomeownerLite | null;
firmName?: string;
customValues?: Record<string, string>;
},
): string {
const today = format(new Date(), "MMMM d, yyyy");
const replacements: Record<string, string> = {
@@ -97,6 +117,12 @@ export function applyVariables(
for (const [k, v] of Object.entries(replacements)) {
out = out.split(k).join(v);
}
// Custom variables like {{custom.mortgage_holder}}
if (ctx.customValues) {
for (const [k, v] of Object.entries(ctx.customValues)) {
out = out.split(`{{custom.${k}}}`).join(v ?? "");
}
}
return out;
}
@@ -136,12 +162,13 @@ export async function savePdfToDocuments(opts: {
caseId: string;
name: string;
folder?: string;
mimeType?: string;
}) {
const { blob, caseId, name, folder = "Forms & Letters" } = opts;
const { blob, caseId, name, folder = "Forms & Letters", mimeType = "application/pdf" } = opts;
const path = `${caseId}/${Date.now()}-${name}`;
const { error: upErr } = await supabase.storage
.from("case-documents")
.upload(path, blob, { contentType: "application/pdf", upsert: false });
.upload(path, blob, { contentType: mimeType, upsert: false });
if (upErr) throw upErr;
const { data: u } = await supabase.auth.getUser();
const { error: insErr } = await supabase.from("documents").insert({
@@ -149,9 +176,37 @@ export async function savePdfToDocuments(opts: {
folder,
name,
storage_path: path,
mime_type: "application/pdf",
mime_type: mimeType,
size_bytes: blob.size,
uploaded_by: u.user?.id ?? null,
});
if (insErr) throw insErr;
}
export interface CaseLite {
id: string;
case_number: string;
title: string;
}
export async function fetchAccessibleCases(): Promise<CaseLite[]> {
const { data } = await supabase
.from("cases")
.select("id,case_number,title")
.order("opened_at", { ascending: false })
.limit(500);
return (data ?? []) as CaseLite[];
}
export async function fetchCaseCustomValues(caseId: string): Promise<Record<string, string>> {
const { data } = await supabase
.from("case_field_values")
.select("value, field:custom_case_fields(key)")
.eq("case_id", caseId);
const map: Record<string, string> = {};
(data ?? []).forEach((row: any) => {
const k = row.field?.key;
if (k) map[k] = row.value ?? "";
});
return map;
}
+21
View File
@@ -34,6 +34,7 @@ import { Route as SettingsProfileRouteImport } from './routes/settings.profile'
import { Route as SettingsImportRouteImport } from './routes/settings.import'
import { Route as SettingsImapRouteImport } from './routes/settings.imap'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-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'
@@ -172,6 +173,11 @@ const SettingsFeesRoute = SettingsFeesRouteImport.update({
path: '/fees',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsCustomFieldsRoute = SettingsCustomFieldsRouteImport.update({
id: '/custom-fields',
path: '/custom-fields',
getParentRoute: () => SettingsRoute,
} as any)
const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
id: '/invoices/$invoiceId',
path: '/invoices/$invoiceId',
@@ -247,6 +253,7 @@ export interface FileRoutesByFullPath {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/import': typeof SettingsImportRoute
@@ -285,6 +292,7 @@ export interface FileRoutesByTo {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/import': typeof SettingsImportRoute
@@ -325,6 +333,7 @@ export interface FileRoutesById {
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/import': typeof SettingsImportRoute
@@ -366,6 +375,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/imap'
| '/settings/import'
@@ -404,6 +414,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/imap'
| '/settings/import'
@@ -443,6 +454,7 @@ export interface FileRouteTypes {
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/custom-fields'
| '/settings/fees'
| '/settings/imap'
| '/settings/import'
@@ -679,6 +691,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsFeesRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/custom-fields': {
id: '/settings/custom-fields'
path: '/custom-fields'
fullPath: '/settings/custom-fields'
preLoaderRoute: typeof SettingsCustomFieldsRouteImport
parentRoute: typeof SettingsRoute
}
'/invoices/$invoiceId': {
id: '/invoices/$invoiceId'
path: '/invoices/$invoiceId'
@@ -767,6 +786,7 @@ declare module '@tanstack/react-router' {
}
interface SettingsRouteChildren {
SettingsCustomFieldsRoute: typeof SettingsCustomFieldsRoute
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsImapRoute: typeof SettingsImapRoute
SettingsImportRoute: typeof SettingsImportRoute
@@ -778,6 +798,7 @@ interface SettingsRouteChildren {
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsCustomFieldsRoute: SettingsCustomFieldsRoute,
SettingsFeesRoute: SettingsFeesRoute,
SettingsImapRoute: SettingsImapRoute,
SettingsImportRoute: SettingsImportRoute,
+4 -1
View File
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone } from "lucide-react";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag } from "lucide-react";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
@@ -19,6 +19,7 @@ import { CaseStatusTab } from "@/components/cases/status-tab";
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCollectionsTab } from "@/components/cases/collections-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
@@ -164,6 +165,7 @@ function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
<TabsTrigger value="calls"><Phone className="h-3.5 w-3.5 mr-1.5" />Calls</TabsTrigger>
<TabsTrigger value="custom"><Tag className="h-3.5 w-3.5 mr-1.5" />Custom fields</TabsTrigger>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsTrigger value="collections"><Users className="h-3.5 w-3.5 mr-1.5" />Collections</TabsTrigger>
)}
@@ -176,6 +178,7 @@ function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
<TabsContent value="custom"><CaseCustomFieldsTab caseId={caseId} /></TabsContent>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsContent value="collections"><CaseCollectionsTab caseRecord={data} /></TabsContent>
)}
+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>
);
}
+1
View File
@@ -13,6 +13,7 @@ const TABS = [
{ to: "/settings/profile", label: "My profile", anyone: true },
{ to: "/settings", label: "Company", exact: true },
{ to: "/settings/fees", label: "Fee schedule" },
{ to: "/settings/custom-fields", label: "Custom case fields" },
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
{ to: "/settings/smtp", label: "Email (SMTP)" },
@@ -0,0 +1,69 @@
-- 1) Firm-wide custom case field definitions
CREATE TABLE public.custom_case_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_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid
);
ALTER TABLE public.custom_case_fields ENABLE ROW LEVEL SECURITY;
CREATE POLICY ccf_select_auth ON public.custom_case_fields FOR SELECT TO authenticated USING (true);
CREATE POLICY ccf_insert_admin ON public.custom_case_fields FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid()));
CREATE POLICY ccf_update_admin ON public.custom_case_fields FOR UPDATE TO authenticated USING (public.is_admin(auth.uid()));
CREATE POLICY ccf_delete_admin ON public.custom_case_fields FOR DELETE TO authenticated USING (public.is_admin(auth.uid()));
CREATE TRIGGER trg_ccf_updated_at BEFORE UPDATE ON public.custom_case_fields
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 2) Per-case values for those fields
CREATE TABLE public.case_field_values (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
case_id uuid NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
field_id uuid NOT NULL REFERENCES public.custom_case_fields(id) ON DELETE CASCADE,
value text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (case_id, field_id)
);
CREATE INDEX idx_case_field_values_case ON public.case_field_values(case_id);
ALTER TABLE public.case_field_values ENABLE ROW LEVEL SECURITY;
CREATE POLICY cfv_select_case ON public.case_field_values FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY cfv_insert_case ON public.case_field_values FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()));
CREATE POLICY cfv_update_case ON public.case_field_values FOR UPDATE TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY cfv_delete_case ON public.case_field_values FOR DELETE TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE TRIGGER trg_cfv_updated_at BEFORE UPDATE ON public.case_field_values
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- 3) Custom-form templates (separate from document_templates so we can store rich settings)
CREATE TABLE public.custom_form_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
title text NOT NULL DEFAULT '',
hide_title boolean NOT NULL DEFAULT false,
body_html text NOT NULL DEFAULT '',
font_family text NOT NULL DEFAULT 'Bookman Old Style',
font_size_pt integer NOT NULL DEFAULT 12,
signature_blocks jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid
);
ALTER TABLE public.custom_form_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY cft_select_auth ON public.custom_form_templates FOR SELECT TO authenticated USING (true);
CREATE POLICY cft_insert_auth ON public.custom_form_templates FOR INSERT TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY cft_update_owner ON public.custom_form_templates FOR UPDATE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE POLICY cft_delete_owner ON public.custom_form_templates FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
CREATE TRIGGER trg_cft_updated_at BEFORE UPDATE ON public.custom_form_templates
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();