From 512813a39358de00e017f000bc5b6e8eb935062f Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:37:26 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/custom-form-builder.tsx | 772 +++++++++++++++++-- 1 file changed, 709 insertions(+), 63 deletions(-) diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index 206b478..2c5d9f1 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -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(null); const [title, setTitle] = useState("Official Notice"); + const [hideTitle, setHideTitle] = useState(false); const [client, setClient] = useState(null); const [homeowner, setHomeowner] = useState(null); const [clientId, setClientId] = useState(""); const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); const [search, setSearch] = useState(""); + const [customDefs, setCustomDefs] = useState([]); + const [fontFamily, setFontFamily] = useState("Bookman Old Style"); + const [fontSize, setFontSize] = useState(12); + + // Template management + const [templates, setTemplates] = useState([]); + const [activeTemplateId, setActiveTemplateId] = useState(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([]); + 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: "

Start typing here…

", + 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(` +

 

+

______________________________

+

${label}

+ `).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 = {}; + 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 || "

"); + 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 (
+
+
+ {activeTpl ? ( + <>Editing saved form: {activeTpl.name} + ) : ( + <>Unsaved form + )} +
+
+ + +
+
-
- - setTitle(e.target.value)} /> +
+
+ + setTitle(e.target.value)} disabled={hideTitle} /> +
+
+ + +
+
+ + +
+
+ + +
@@ -97,12 +543,8 @@ export function CustomFormBuilder() {
- -

- Click to insert at cursor position. -

+ +

Click to insert at cursor.

@@ -113,20 +555,28 @@ export function CustomFormBuilder() { className="pl-8 h-8 text-xs" />
- +
- {filtered.map((v) => ( + {filteredVars.map((v) => ( ))} + {customDefs.length === 0 && ( +

+ Tip: define custom case fields in Settings → Custom case fields to use them here as {"{{custom.key}}"}. +

+ )}
@@ -134,33 +584,229 @@ export function CustomFormBuilder() { -
- - - - - + +
+ + + +
-
-

Start typing here…

+
+
+ +
+ + {/* Save dialog */} + + + + Save form + + Save this design as a reusable template. You can load it later from any device. + + +
+
+ + setSaveAsName(e.target.value)} /> +
+
+ + setSaveAsDescription(e.target.value)} /> +
+
+ + + {activeTemplateId && ( + + )} + + +
+
+ + {/* Load dialog */} + + + + Load saved form + Select a previously saved template to load into the editor. + + + {templates.length === 0 ? ( +

No saved forms yet.

+ ) : ( +
+ {templates.map((t) => ( +
+ + +
+ ))} +
+ )} +
+
+
+ + {/* Save to case dialog */} + + + + Save to case files + + Pick a case to attach this generated form to. Custom case-field values will be merged into the variables. + + +
+ setCaseSearch(e.target.value)} + /> + + {filteredCases.length === 0 ? ( +

No matching cases.

+ ) : ( +
+ {filteredCases.map((c) => ( + + ))} +
+ )} +
+
+ + +
+
+ + + + +
+
); } + +function ToolbarBtn({ + onClick, + active, + children, + title, +}: { + onClick: () => void; + active?: boolean; + children: React.ReactNode; + title: string; +}) { + return ( + + ); +}