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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> </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(" ").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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user