Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
fe465b9e2b
commit
512813a393
@@ -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 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user