From 1dd6dbefff548f70160ba480673061605a3cd2be 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 16:40:41 +0000 Subject: [PATCH 1/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 27 +++++++++++++++++++ ...8_ec776a26-9dc6-4b5b-bc29-2f3a362a6a49.sql | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 supabase/migrations/20260418164038_ec776a26-9dc6-4b5b-bc29-2f3a362a6a49.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 7d2b0f3..2cbfb13 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1473,6 +1473,33 @@ export type Database = { } Relationships: [] } + form_settings: { + Row: { + config: Json + created_at: string + form_key: string + id: string + updated_at: string + updated_by: string | null + } + Insert: { + config?: Json + created_at?: string + form_key: string + id?: string + updated_at?: string + updated_by?: string | null + } + Update: { + config?: Json + created_at?: string + form_key?: string + id?: string + updated_at?: string + updated_by?: string | null + } + Relationships: [] + } generated_documents: { Row: { body: string | null diff --git a/supabase/migrations/20260418164038_ec776a26-9dc6-4b5b-bc29-2f3a362a6a49.sql b/supabase/migrations/20260418164038_ec776a26-9dc6-4b5b-bc29-2f3a362a6a49.sql new file mode 100644 index 0000000..c83a750 --- /dev/null +++ b/supabase/migrations/20260418164038_ec776a26-9dc6-4b5b-bc29-2f3a362a6a49.sql @@ -0,0 +1,27 @@ + +CREATE TABLE public.form_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + form_key text NOT NULL UNIQUE, + config jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.form_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY form_settings_select_auth ON public.form_settings + FOR SELECT TO authenticated USING (true); + +CREATE POLICY form_settings_insert_admin ON public.form_settings + FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid())); + +CREATE POLICY form_settings_update_admin ON public.form_settings + FOR UPDATE TO authenticated USING (public.is_admin(auth.uid())); + +CREATE POLICY form_settings_delete_admin ON public.form_settings + FOR DELETE TO authenticated USING (public.is_admin(auth.uid())); + +CREATE TRIGGER tg_form_settings_updated_at + BEFORE UPDATE ON public.form_settings + FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); From ac26cbdafb3fa44a548979e7a16184709eacd416 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 16:41:57 +0000 Subject: [PATCH 2/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/form-templates.ts | 390 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 src/lib/form-templates.ts diff --git a/src/lib/form-templates.ts b/src/lib/form-templates.ts new file mode 100644 index 0000000..c062681 --- /dev/null +++ b/src/lib/form-templates.ts @@ -0,0 +1,390 @@ +// Shared template definitions for the built-in legal forms (NOLA, ITL, ITF, Estoppel, Letter). +// Each template is stored in the `form_settings` table (one row per form_key) so admins +// can edit titles, body text, fonts, margins, line-item labels, signature blocks, etc., +// without code changes. + +import { supabase } from "@/integrations/supabase/client"; +import { jsPDF } from "jspdf"; +import { fmtCurrency, fmtDateLong } from "./forms-shared"; + +export type FormKey = "nola" | "itl" | "itf" | "estoppel" | "letter"; + +/** Visual + content config for a single form template. All fields are optional with sensible defaults. */ +export interface FormTemplateConfig { + /** Centered title printed near the top of the document (after the recipient block). */ + title?: string; + /** Main body paragraph(s). Supports `\n\n` for paragraph breaks and merge tokens like {{client.name}}. */ + body?: string; + /** Closing/signature paragraph printed after the totals or body. */ + closing?: string; + /** Multi-line signature block (e.g. "Sincerely,\n\n{{firm.name}}"). */ + signature?: string; + /** Font family used for the whole document. jsPDF builtins: helvetica | times | courier. */ + font?: "helvetica" | "times" | "courier"; + /** Body font size in points. */ + fontSizePt?: number; + /** Title font size in points. */ + titleFontSizePt?: number; + /** Page margin in points (1 inch = 72 pt). Default 54 (0.75"). */ + marginPt?: number; + /** Line height in points for body text. */ + lineHeightPt?: number; + /** Default line items shown on the form (NOLA only by default but reusable). */ + defaultLineItems?: { description: string; amount: string }[]; + /** Labels for the fixed itemized rows used by ITL/ITF/Estoppel. */ + itemLabels?: { + assessments?: string; + interest?: string; + lateFees?: string; + adminFees?: string; + lessPayments?: string; + totalDue?: string; + }; + /** Show the "U.S. Certified Mail #" line when a number is provided. */ + showCertifiedMail?: boolean; + /** Header label for the certified-mail line. */ + certifiedMailLabel?: string; +} + +export const FORM_LABELS: Record = { + nola: "Notice of Late Assessment (NOLA)", + itl: "Notice of Intent to Lien (ITL)", + itf: "Notice of Intent to Foreclose (ITF)", + estoppel: "Estoppel Certificate", + letter: "Letter", +}; + +export const DEFAULT_TEMPLATES: Record = { + nola: { + title: "NOTICE OF LATE ASSESSMENT", + body: + "This letter serves as formal notice that your account with {{client.name}} is delinquent. " + + "The amounts owed as of {{date}} are itemized below. Payment in full must be received on or " + + "before {{dueDate}} to avoid further collection action, including the filing of a Notice of " + + "Intent to Lien.", + closing: "", + signature: "Sincerely,\n\n{{firm.name}}", + font: "helvetica", + fontSizePt: 10, + titleFontSizePt: 13, + marginPt: 54, + lineHeightPt: 13, + defaultLineItems: [ + { description: "Assessments", amount: "0.00" }, + { description: "Late Fees", amount: "0.00" }, + { description: "Interest", amount: "0.00" }, + ], + showCertifiedMail: true, + certifiedMailLabel: "U.S. Certified Mail #", + }, + itl: { + title: "NOTICE OF INTENT TO RECORD A CLAIM OF LIEN", + body: + "Pursuant to the governing documents of {{client.name}} and applicable Florida law, you are " + + "hereby notified that the following amounts are past due in connection with the property " + + "identified above.\n\nUnless payment in full of the total amount stated below is received on " + + "or before {{dueDate}} (forty-five (45) days from the date of this letter), a Claim of Lien " + + "will be recorded against the subject property and additional collection costs and " + + "attorneys' fees will be incurred for which you may be responsible.", + closing: + "Payment must be made payable to {{client.name}} and remitted to the address shown above. " + + "If you have any questions regarding this notice, please contact our office.", + signature: "Sincerely,\n\n{{firm.name}}", + font: "helvetica", + fontSizePt: 10, + titleFontSizePt: 12, + marginPt: 54, + lineHeightPt: 13, + itemLabels: { + assessments: "Assessments", + interest: "Interest", + lateFees: "Late Fees", + adminFees: "Admin / Legal Fees", + lessPayments: "Less Payments", + totalDue: "TOTAL DUE", + }, + showCertifiedMail: true, + certifiedMailLabel: "U.S. Certified Mail #", + }, + itf: { + title: "NOTICE OF INTENT TO FORECLOSE CLAIM OF LIEN", + body: + "Pursuant to the governing documents of {{client.name}} and applicable Florida law, you are " + + "hereby notified that the assessments and other amounts secured by the previously recorded " + + "Claim of Lien against the property identified above remain past due and unpaid.{{lienRef}}" + + "\n\nUnless payment in full of the total amount stated below is received on or before " + + "{{dueDate}} (forty-five (45) days from the date of this letter), the Association will " + + "proceed with the filing of a foreclosure action on its Claim of Lien. Additional " + + "collection costs and attorneys' fees will be incurred for which you may be responsible, " + + "and your interest in the property may be sold at public sale.", + closing: + "Payment must be made payable to {{client.name}} and remitted to the address shown above. " + + "If you have any questions regarding this notice, please contact our office immediately to " + + "avoid the filing of a foreclosure action.", + signature: "Sincerely,\n\n{{firm.name}}", + font: "helvetica", + fontSizePt: 10, + titleFontSizePt: 12, + marginPt: 54, + lineHeightPt: 13, + itemLabels: { + assessments: "Assessments", + interest: "Interest", + lateFees: "Late Fees", + adminFees: "Admin / Legal Fees", + lessPayments: "Less Payments", + totalDue: "TOTAL DUE", + }, + showCertifiedMail: true, + certifiedMailLabel: "U.S. Certified Mail #", + }, + estoppel: { + title: "ESTOPPEL CERTIFICATE", + body: "", + closing: "", + signature: "Sincerely,\n\n{{firm.name}}", + font: "helvetica", + fontSizePt: 10, + titleFontSizePt: 16, + marginPt: 54, + lineHeightPt: 13, + itemLabels: { + totalDue: "GRAND TOTAL DUE AT CLOSING", + }, + }, + letter: { + title: "", + body: "Dear {{owner.name}},\n\nThis letter is to inform you that…\n\nIf you have any questions, please contact our office.", + closing: "", + signature: "Sincerely,\n\n{{firm.name}}", + font: "times", + fontSizePt: 11, + titleFontSizePt: 11, + marginPt: 60, + lineHeightPt: 14, + }, +}; + +/** Replace {{token}} placeholders. Unknown tokens become empty strings. */ +export function fillTokens(text: string, tokens: Record): string { + if (!text) return ""; + return text.replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g, (_, k: string) => { + const v = tokens[k]; + if (v === undefined || v === null) return ""; + return String(v); + }); +} + +/** Load the saved template for a form, merged with defaults so missing fields fall back gracefully. */ +export async function loadFormTemplate(key: FormKey): Promise { + const { data } = await supabase + .from("form_settings") + .select("config") + .eq("form_key", key) + .maybeSingle(); + const stored = (data?.config ?? {}) as FormTemplateConfig; + return { ...DEFAULT_TEMPLATES[key], ...stored, itemLabels: { ...DEFAULT_TEMPLATES[key].itemLabels, ...stored.itemLabels } }; +} + +export async function saveFormTemplate(key: FormKey, config: FormTemplateConfig, userId?: string | null) { + const { data: existing } = await supabase + .from("form_settings") + .select("id") + .eq("form_key", key) + .maybeSingle(); + if (existing?.id) { + const { error } = await supabase + .from("form_settings") + .update({ config: config as any, updated_by: userId ?? null }) + .eq("id", existing.id); + if (error) throw error; + } else { + const { error } = await supabase + .from("form_settings") + .insert({ form_key: key, config: config as any, updated_by: userId ?? null }); + if (error) throw error; + } +} + +// ---------- Generic letter-style PDF renderer ---------- +// Used by NOLA / ITL / ITF / Letter so admins can change titles, body, fonts, margins +// from one place. The Estoppel form has a unique multi-section layout and stays separate. + +export interface LetterRenderInput { + template: FormTemplateConfig; + // Top-left return-address block lines (e.g. firm/client header). + returnAddress: string[]; + // Top-right line — typically the long-form date. + topRightLine?: string; + // Recipient block on the left. + recipient: string[]; + // Optional certified mail / tracking number printed right-aligned beneath the recipient. + certifiedMailNumber?: string; + // Token map for {{...}} substitution in body / closing / signature. + tokens: Record; + // Optional itemized rows printed before the closing paragraph. + items?: { description: string; amount: number | string }[]; + // Optional total row printed under the items. + total?: { label: string; amount: number }; + // Filename to save (without .pdf). + filename: string; +} + +export function renderLetterPdf(input: LetterRenderInput): jsPDF { + const t = input.template; + const font = t.font ?? "helvetica"; + const fontSize = t.fontSizePt ?? 10; + const titleSize = t.titleFontSizePt ?? 13; + const margin = t.marginPt ?? 54; + const lh = t.lineHeightPt ?? 13; + + const doc = new jsPDF({ unit: "pt", format: "letter" }); + const pageW = doc.internal.pageSize.getWidth(); + const pageH = doc.internal.pageSize.getHeight(); + const maxW = pageW - margin * 2; + + const ensureSpace = (needed: number, y: number) => { + if (y + needed > pageH - margin) { + doc.addPage(); + return margin; + } + return y; + }; + + let y = margin + 6; + + // Return address (top-left) + doc.setFont(font, "normal"); + doc.setFontSize(fontSize); + input.returnAddress.forEach((line) => { + doc.text(line, margin, y); + y += lh - 1; + }); + + // Top-right date + if (input.topRightLine) { + doc.setFont(font, "bold"); + doc.text(input.topRightLine, pageW - margin - doc.getTextWidth(input.topRightLine), margin + 6); + doc.setFont(font, "normal"); + } + + // Recipient + y = Math.max(y + 24, margin + 110); + input.recipient.forEach((line) => { + doc.text(line, margin, y); + y += lh - 1; + }); + + // Certified mail line + if (t.showCertifiedMail !== false && input.certifiedMailNumber) { + doc.setFont(font, "bolditalic"); + const txt = `${t.certifiedMailLabel ?? "U.S. Certified Mail #"} ${input.certifiedMailNumber}`; + doc.text(txt, pageW - margin - doc.getTextWidth(txt), y); + doc.setFont(font, "normal"); + y += lh + 2; + } + + // Title + if (t.title) { + y += 24; + doc.setFont(font, "bold"); + doc.setFontSize(titleSize); + const title = fillTokens(t.title, input.tokens); + doc.text(title, (pageW - doc.getTextWidth(title)) / 2, y); + y += titleSize + 12; + doc.setFont(font, "normal"); + doc.setFontSize(fontSize); + } + + // Body + if (t.body) { + const body = fillTokens(t.body, input.tokens); + body.split("\n\n").forEach((para) => { + const lines = doc.splitTextToSize(para, maxW); + y = ensureSpace(lines.length * lh, y); + doc.text(lines, margin, y); + y += lines.length * lh + 6; + }); + y += 6; + } + + // Items table + if (input.items && input.items.length > 0) { + y = ensureSpace(60, y); + doc.setFont(font, "bold"); + doc.text("Description", margin, y); + doc.text("Amount", pageW - margin - 60, y); + y += 6; + doc.line(margin, y, pageW - margin, y); + y += lh; + doc.setFont(font, "normal"); + input.items.forEach((it) => { + y = ensureSpace(lh, y); + doc.text(it.description || "—", margin, y); + const amtStr = `$${fmtCurrency(it.amount)}`; + doc.text(amtStr, pageW - margin - doc.getTextWidth(amtStr), y); + y += lh; + }); + y += 4; + doc.line(margin, y, pageW - margin, y); + y += lh + 2; + if (input.total) { + doc.setFont(font, "bold"); + doc.text(input.total.label, margin, y); + const totalTxt = `$${fmtCurrency(input.total.amount)}`; + doc.text(totalTxt, pageW - margin - doc.getTextWidth(totalTxt), y); + doc.setFont(font, "normal"); + y += lh + 14; + } + } + + // Closing paragraph + if (t.closing) { + const closing = fillTokens(t.closing, input.tokens); + closing.split("\n\n").forEach((para) => { + const lines = doc.splitTextToSize(para, maxW); + y = ensureSpace(lines.length * lh, y); + doc.text(lines, margin, y); + y += lines.length * lh + 6; + }); + } + + // Signature + if (t.signature) { + y += 12; + const sig = fillTokens(t.signature, input.tokens); + sig.split("\n").forEach((line) => { + y = ensureSpace(lh, y); + doc.text(line, margin, y); + y += lh; + }); + } + + return doc; +} + +/** Common token map builder used by the form components. */ +export function buildLetterTokens(opts: { + clientName?: string; + ownerName?: string; + firmName?: string; + date?: string; // ISO yyyy-mm-dd + dueDate?: string; // ISO yyyy-mm-dd + total?: number; + extra?: Record; +}): Record { + const base: Record = { + "client.name": opts.clientName ?? "", + "owner.name": opts.ownerName ?? "", + "firm.name": opts.firmName ?? "", + date: opts.date ? fmtDateLong(opts.date) : "", + dueDate: opts.dueDate ? fmtDateLong(opts.dueDate) : "", + total: opts.total !== undefined ? `$${fmtCurrency(opts.total)}` : "", + }; + if (opts.extra) { + for (const [k, v] of Object.entries(opts.extra)) { + if (v !== undefined && v !== null) base[k] = v as string | number; + } + } + return base; +} From 559f9450a01021bde8f16da7c3fc27f2a9535163 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 16:43:08 +0000 Subject: [PATCH 3/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/itf-form.tsx | 128 ++++++++++------------------- src/components/forms/itl-form.tsx | 125 ++++++++++------------------ src/components/forms/nola-form.tsx | 127 ++++++++++------------------ 3 files changed, 130 insertions(+), 250 deletions(-) diff --git a/src/components/forms/itf-form.tsx b/src/components/forms/itf-form.tsx index 44323fc..47e2a24 100644 --- a/src/components/forms/itf-form.tsx +++ b/src/components/forms/itf-form.tsx @@ -15,7 +15,7 @@ import { type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; -import { jsPDF } from "jspdf"; +import { buildLetterTokens, loadFormTemplate, renderLetterPdf, type FormTemplateConfig } from "@/lib/form-templates"; import { toast } from "sonner"; export function ItfForm() { @@ -24,6 +24,7 @@ export function ItfForm() { const [clientId, setClientId] = useState(""); const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); + const [template, setTemplate] = useState(null); const [letterDate, setLetterDate] = useState(new Date().toISOString().slice(0, 10)); const [certifiedNo, setCertifiedNo] = useState(""); @@ -37,9 +38,9 @@ export function ItfForm() { useEffect(() => { fetchFirm().then(setFirm); + loadFormTemplate("itf").then(setTemplate); }, []); - // 45-day cure window prior to filing foreclosure const dueDate = useMemo(() => { const d = new Date(letterDate + "T12:00:00"); d.setDate(d.getDate() + 45); @@ -54,101 +55,56 @@ export function ItfForm() { (parseFloat(lessPayments) || 0); const handleExport = () => { - if (!client) { + if (!client || !template) { toast.error("Select a client first"); return; } - const doc = new jsPDF({ unit: "pt", format: "letter" }); - const pageW = doc.internal.pageSize.getWidth(); - const margin = 54; - let y = 60; - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - doc.text(client.name, margin, y); - y += 12; - if (firm?.company_name) { - doc.text(`c/o ${firm.company_name}`, margin, y); - y += 12; - } - clientAddressLines(client).forEach((l) => { - doc.text(l, margin, y); - y += 12; - }); + const returnAddress = [client.name]; + if (firm?.company_name) returnAddress.push(`c/o ${firm.company_name}`); + returnAddress.push(...clientAddressLines(client)); - doc.setFont("helvetica", "bold"); - doc.text(fmtDateLong(letterDate), pageW - margin - doc.getTextWidth(fmtDateLong(letterDate)), 60); + const recipient: string[] = [ownerFullName(homeowner) || "[Homeowner]"]; + if (homeowner?.address) recipient.push(homeowner.address); - y = Math.max(y + 30, 175); - doc.setFont("helvetica", "normal"); - doc.text(ownerFullName(homeowner) || "[Homeowner]", margin, y); - y += 12; - if (homeowner?.address) { - doc.text(homeowner.address, margin, y); - y += 12; - } - - if (certifiedNo) { - doc.setFont("helvetica", "bolditalic"); - const t = `U.S. Certified Mail # ${certifiedNo}`; - doc.text(t, pageW - margin - doc.getTextWidth(t), y); - y += 16; - } - - y += 24; - doc.setFont("helvetica", "bold"); - doc.setFontSize(12); - const title = "NOTICE OF INTENT TO FORECLOSE CLAIM OF LIEN"; - doc.text(title, (pageW - doc.getTextWidth(title)) / 2, y); - y += 26; - - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); + // Compose lien reference inline so users don't need to edit raw template. const lienRef = lienBookPage || lienRecordedDate ? ` A Claim of Lien was recorded against the subject property${ lienRecordedDate ? ` on ${fmtDateLong(lienRecordedDate)}` : "" }${lienBookPage ? ` (recorded at ${lienBookPage})` : ""}.` : ""; - const body = `Pursuant to the governing documents of ${client.name} and applicable Florida law, you are hereby notified that the assessments and other amounts secured by the previously recorded Claim of Lien against the property identified above remain past due and unpaid.${lienRef}\n\nUnless payment in full of the total amount stated below is received on or before ${fmtDateLong(dueDate)} (forty-five (45) days from the date of this letter), the Association will proceed with the filing of a foreclosure action on its Claim of Lien. Additional collection costs and attorneys' fees will be incurred for which you may be responsible, and your interest in the property may be sold at public sale.`; - const lines = doc.splitTextToSize(body, pageW - margin * 2); - doc.text(lines, margin, y); - y += lines.length * 13 + 14; - // Itemized - const rows: [string, string][] = [ - ["Assessments", assessments], - ["Interest", interest], - ["Late Fees", lateFees], - ["Admin / Legal Fees", adminFees], - ["Less Payments", `-${lessPayments}`], + const labels = template.itemLabels ?? {}; + const items = [ + { description: labels.assessments ?? "Assessments", amount: assessments }, + { description: labels.interest ?? "Interest", amount: interest }, + { description: labels.lateFees ?? "Late Fees", amount: lateFees }, + { description: labels.adminFees ?? "Admin / Legal Fees", amount: adminFees }, + { description: labels.lessPayments ?? "Less Payments", amount: `-${lessPayments}` }, ]; - doc.setFont("helvetica", "bold"); - doc.text("Description", margin, y); - doc.text("Amount", pageW - margin - 60, y); - y += 6; - doc.line(margin, y, pageW - margin, y); - y += 14; - doc.setFont("helvetica", "normal"); - rows.forEach(([d, a]) => { - doc.text(d, margin, y); - const txt = `$${fmtCurrency(a)}`; - doc.text(txt, pageW - margin - doc.getTextWidth(txt), y); - y += 14; - }); - y += 4; - doc.line(margin, y, pageW - margin, y); - y += 16; - doc.setFont("helvetica", "bold"); - doc.text("TOTAL DUE", margin, y); - const totalTxt = `$${fmtCurrency(total)}`; - doc.text(totalTxt, pageW - margin - doc.getTextWidth(totalTxt), y); - y += 30; - doc.setFont("helvetica", "normal"); - const closing = `Payment must be made payable to ${client.name} and remitted to the address shown above. If you have any questions regarding this notice, please contact our office immediately to avoid the filing of a foreclosure action.`; - const cl = doc.splitTextToSize(closing, pageW - margin * 2); - doc.text(cl, margin, y); + const tokens = buildLetterTokens({ + clientName: client.name, + ownerName: ownerFullName(homeowner), + firmName: firm?.company_name ?? "", + date: letterDate, + dueDate, + total, + extra: { lienRef }, + }); + + const doc = renderLetterPdf({ + template, + returnAddress, + topRightLine: tokens.date as string, + recipient, + certifiedMailNumber: certifiedNo, + tokens, + items, + total: { label: labels.totalDue ?? "TOTAL DUE", amount: total }, + filename: `ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}`, + }); doc.save(`ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}.pdf`); toast.success("ITF PDF downloaded"); @@ -256,9 +212,15 @@ export function ItfForm() { +

+ Title, body, fonts, margins, item labels and signature can be customized in{" "} + Settings → Form templates. Use {`{{lienRef}}`} in the + body to insert the recorded-lien reference. +

+
Total due: ${fmtCurrency(total)} - diff --git a/src/components/forms/itl-form.tsx b/src/components/forms/itl-form.tsx index 5c2d4e3..74dedd9 100644 --- a/src/components/forms/itl-form.tsx +++ b/src/components/forms/itl-form.tsx @@ -15,7 +15,7 @@ import { type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; -import { jsPDF } from "jspdf"; +import { buildLetterTokens, loadFormTemplate, renderLetterPdf, type FormTemplateConfig } from "@/lib/form-templates"; import { toast } from "sonner"; export function ItlForm() { @@ -24,6 +24,7 @@ export function ItlForm() { const [clientId, setClientId] = useState(""); const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); + const [template, setTemplate] = useState(null); const [letterDate, setLetterDate] = useState(new Date().toISOString().slice(0, 10)); const [certifiedNo, setCertifiedNo] = useState(""); @@ -35,6 +36,7 @@ export function ItlForm() { useEffect(() => { fetchFirm().then(setFirm); + loadFormTemplate("itl").then(setTemplate); }, []); const dueDate = useMemo(() => { @@ -51,95 +53,47 @@ export function ItlForm() { (parseFloat(lessPayments) || 0); const handleExport = () => { - if (!client) { + if (!client || !template) { toast.error("Select a client first"); return; } - const doc = new jsPDF({ unit: "pt", format: "letter" }); - const pageW = doc.internal.pageSize.getWidth(); - const margin = 54; - let y = 60; - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - doc.text(client.name, margin, y); - y += 12; - if (firm?.company_name) { - doc.text(`c/o ${firm.company_name}`, margin, y); - y += 12; - } - clientAddressLines(client).forEach((l) => { - doc.text(l, margin, y); - y += 12; - }); + const returnAddress = [client.name]; + if (firm?.company_name) returnAddress.push(`c/o ${firm.company_name}`); + returnAddress.push(...clientAddressLines(client)); - doc.setFont("helvetica", "bold"); - doc.text(fmtDateLong(letterDate), pageW - margin - doc.getTextWidth(fmtDateLong(letterDate)), 60); + const recipient: string[] = [ownerFullName(homeowner) || "[Homeowner]"]; + if (homeowner?.address) recipient.push(homeowner.address); - y = Math.max(y + 30, 175); - doc.setFont("helvetica", "normal"); - doc.text(ownerFullName(homeowner) || "[Homeowner]", margin, y); - y += 12; - if (homeowner?.address) { - doc.text(homeowner.address, margin, y); - y += 12; - } - - if (certifiedNo) { - doc.setFont("helvetica", "bolditalic"); - const t = `U.S. Certified Mail # ${certifiedNo}`; - doc.text(t, pageW - margin - doc.getTextWidth(t), y); - y += 16; - } - - y += 24; - doc.setFont("helvetica", "bold"); - doc.setFontSize(12); - const title = "NOTICE OF INTENT TO RECORD A CLAIM OF LIEN"; - doc.text(title, (pageW - doc.getTextWidth(title)) / 2, y); - y += 26; - - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - const body = `Pursuant to the governing documents of ${client.name} and applicable Florida law, you are hereby notified that the following amounts are past due in connection with the property identified above.\n\nUnless payment in full of the total amount stated below is received on or before ${fmtDateLong(dueDate)} (forty-five (45) days from the date of this letter), a Claim of Lien will be recorded against the subject property and additional collection costs and attorneys' fees will be incurred for which you may be responsible.`; - const lines = doc.splitTextToSize(body, pageW - margin * 2); - doc.text(lines, margin, y); - y += lines.length * 13 + 14; - - // Itemized - const rows: [string, string][] = [ - ["Assessments", assessments], - ["Interest", interest], - ["Late Fees", lateFees], - ["Admin / Legal Fees", adminFees], - ["Less Payments", `-${lessPayments}`], + const labels = template.itemLabels ?? {}; + const items = [ + { description: labels.assessments ?? "Assessments", amount: assessments }, + { description: labels.interest ?? "Interest", amount: interest }, + { description: labels.lateFees ?? "Late Fees", amount: lateFees }, + { description: labels.adminFees ?? "Admin / Legal Fees", amount: adminFees }, + { description: labels.lessPayments ?? "Less Payments", amount: `-${lessPayments}` }, ]; - doc.setFont("helvetica", "bold"); - doc.text("Description", margin, y); - doc.text("Amount", pageW - margin - 60, y); - y += 6; - doc.line(margin, y, pageW - margin, y); - y += 14; - doc.setFont("helvetica", "normal"); - rows.forEach(([d, a]) => { - doc.text(d, margin, y); - const txt = `$${fmtCurrency(a)}`; - doc.text(txt, pageW - margin - doc.getTextWidth(txt), y); - y += 14; - }); - y += 4; - doc.line(margin, y, pageW - margin, y); - y += 16; - doc.setFont("helvetica", "bold"); - doc.text("TOTAL DUE", margin, y); - const totalTxt = `$${fmtCurrency(total)}`; - doc.text(totalTxt, pageW - margin - doc.getTextWidth(totalTxt), y); - y += 30; - doc.setFont("helvetica", "normal"); - const closing = `Payment must be made payable to ${client.name} and remitted to the address shown above. If you have any questions regarding this notice, please contact our office.`; - const cl = doc.splitTextToSize(closing, pageW - margin * 2); - doc.text(cl, margin, y); + const tokens = buildLetterTokens({ + clientName: client.name, + ownerName: ownerFullName(homeowner), + firmName: firm?.company_name ?? "", + date: letterDate, + dueDate, + total, + }); + + const doc = renderLetterPdf({ + template, + returnAddress, + topRightLine: tokens.date as string, + recipient, + certifiedMailNumber: certifiedNo, + tokens, + items, + total: { label: labels.totalDue ?? "TOTAL DUE", amount: total }, + filename: `ITL_${ownerFullName(homeowner) || "draft"}_${letterDate}`, + }); doc.save(`ITL_${ownerFullName(homeowner) || "draft"}_${letterDate}.pdf`); toast.success("ITL PDF downloaded"); @@ -228,9 +182,14 @@ export function ItlForm() {
+

+ Title, body, fonts, margins, item labels and signature can be customized in{" "} + Settings → Form templates. +

+
Total due: ${fmtCurrency(total)} - diff --git a/src/components/forms/nola-form.tsx b/src/components/forms/nola-form.tsx index 4702a2c..7f1ca25 100644 --- a/src/components/forms/nola-form.tsx +++ b/src/components/forms/nola-form.tsx @@ -9,13 +9,12 @@ import { clientAddressLines, fetchFirm, fmtCurrency, - fmtDateLong, ownerFullName, type ClientLite, type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; -import { jsPDF } from "jspdf"; +import { buildLetterTokens, loadFormTemplate, renderLetterPdf, type FormTemplateConfig } from "@/lib/form-templates"; import { toast } from "sonner"; interface Item { @@ -29,6 +28,7 @@ export function NolaForm() { const [clientId, setClientId] = useState(""); const [homeownerId, setHomeownerId] = useState(""); const [firm, setFirm] = useState(null); + const [template, setTemplate] = useState(null); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [dueDate, setDueDate] = useState(() => { @@ -37,14 +37,19 @@ export function NolaForm() { return d.toISOString().slice(0, 10); }); const [certifiedNo, setCertifiedNo] = useState(""); - const [items, setItems] = useState([ - { description: "Assessments", amount: "0.00" }, - { description: "Late Fees", amount: "0.00" }, - { description: "Interest", amount: "0.00" }, - ]); + const [items, setItems] = useState([]); useEffect(() => { fetchFirm().then(setFirm); + loadFormTemplate("nola").then((t) => { + setTemplate(t); + setItems( + (t.defaultLineItems ?? []).map((it) => ({ + description: it.description, + amount: it.amount, + })), + ); + }); }, []); const total = items.reduce((s, it) => s + (parseFloat(it.amount) || 0), 0); @@ -56,89 +61,38 @@ export function NolaForm() { const removeItem = (i: number) => setItems((p) => p.filter((_, idx) => idx !== i)); const handleExport = () => { - if (!client) { + if (!client || !template) { toast.error("Select a client first"); return; } - const doc = new jsPDF({ unit: "pt", format: "letter" }); - const pageW = doc.internal.pageSize.getWidth(); - const margin = 54; - // Header — return address - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - let y = 60; - doc.text(client.name, margin, y); - y += 12; - if (firm?.company_name) { - doc.text(`c/o ${firm.company_name}`, margin, y); - y += 12; - } - clientAddressLines(client).forEach((l) => { - doc.text(l, margin, y); - y += 12; + const returnAddress = [client.name]; + if (firm?.company_name) returnAddress.push(`c/o ${firm.company_name}`); + returnAddress.push(...clientAddressLines(client)); + + const recipient: string[] = [ownerFullName(homeowner) || "[Homeowner]"]; + if (homeowner?.address) recipient.push(homeowner.address); + + const tokens = buildLetterTokens({ + clientName: client.name, + ownerName: ownerFullName(homeowner), + firmName: firm?.company_name ?? "", + date, + dueDate, + total, }); - // Date right - doc.setFont("helvetica", "bold"); - doc.text(fmtDateLong(date), pageW - margin - doc.getTextWidth(fmtDateLong(date)), 60); - - // Recipient - y = Math.max(y + 30, 175); - doc.setFont("helvetica", "normal"); - doc.text(ownerFullName(homeowner) || "[Homeowner]", margin, y); - y += 12; - if (homeowner?.address) { - doc.text(homeowner.address, margin, y); - y += 12; - } - - // Certified mail - if (certifiedNo) { - doc.setFont("helvetica", "bold"); - doc.setFontSize(9); - const txt = `U.S. Certified Mail #: ${certifiedNo}`; - doc.text(txt, pageW - margin - doc.getTextWidth(txt), y); - y += 14; - } - - // Title - y += 30; - doc.setFont("helvetica", "bold"); - doc.setFontSize(13); - const title = "NOTICE OF LATE ASSESSMENT"; - doc.text(title, (pageW - doc.getTextWidth(title)) / 2, y); - y += 30; - - // Body - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); - const body = `This letter serves as formal notice that your account with ${client.name} is delinquent. The amounts owed as of ${fmtDateLong(date)} are itemized below. Payment in full must be received on or before ${fmtDateLong(dueDate)} to avoid further collection action, including the filing of a Notice of Intent to Lien.`; - const bodyLines = doc.splitTextToSize(body, pageW - margin * 2); - doc.text(bodyLines, margin, y); - y += bodyLines.length * 13 + 16; - - // Items table - doc.setFont("helvetica", "bold"); - doc.text("Description", margin, y); - doc.text("Amount", pageW - margin - 60, y); - y += 6; - doc.line(margin, y, pageW - margin, y); - y += 14; - doc.setFont("helvetica", "normal"); - items.forEach((it) => { - doc.text(it.description || "—", margin, y); - const amt = `$${fmtCurrency(it.amount)}`; - doc.text(amt, pageW - margin - doc.getTextWidth(amt), y); - y += 14; + const doc = renderLetterPdf({ + template, + returnAddress, + topRightLine: tokens.date as string, + recipient, + certifiedMailNumber: certifiedNo, + tokens, + items: items.map((it) => ({ description: it.description, amount: it.amount })), + total: { label: template.itemLabels?.totalDue ?? "TOTAL DUE", amount: total }, + filename: `NOLA_${ownerFullName(homeowner) || "draft"}_${date}`, }); - y += 4; - doc.line(margin, y, pageW - margin, y); - y += 16; - doc.setFont("helvetica", "bold"); - doc.text("TOTAL DUE", margin, y); - const totalTxt = `$${fmtCurrency(total)}`; - doc.text(totalTxt, pageW - margin - doc.getTextWidth(totalTxt), y); doc.save(`NOLA_${ownerFullName(homeowner) || "draft"}_${date}.pdf`); toast.success("NOLA PDF downloaded"); @@ -206,8 +160,13 @@ export function NolaForm() {
+

+ Title, body, fonts, margins, and signature can be customized in{" "} + Settings → Form templates. +

+
- From bae323c7df14f6aad482a47a3dde87648ec68eea 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 16:43:57 +0000 Subject: [PATCH 4/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/letter-generator.tsx | 80 +++++++++++++++-------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/src/components/forms/letter-generator.tsx b/src/components/forms/letter-generator.tsx index 88ffae8..3a9e027 100644 --- a/src/components/forms/letter-generator.tsx +++ b/src/components/forms/letter-generator.tsx @@ -16,19 +16,11 @@ import { type HomeownerLite, type FirmInfo, } from "@/lib/forms-shared"; +import { fillTokens, loadFormTemplate, type FormTemplateConfig } from "@/lib/form-templates"; import { supabase } from "@/integrations/supabase/client"; import { jsPDF } from "jspdf"; import { toast } from "sonner"; -const DEFAULT_BODY = `Dear {{ownerName}}, - -This letter is to inform you that… - -If you have any questions, please contact our office. - -Sincerely, -{{firmName}}`; - async function loadLogoDataUrl(path: string | null | undefined): Promise { if (!path) return null; const { data: signed } = await supabase.storage.from("firm-logos").createSignedUrl(path, 60); @@ -55,8 +47,9 @@ export function LetterGenerator() { const [logoDataUrl, setLogoDataUrl] = useState(null); const [attorneyName, setAttorneyName] = useState(""); const [subject, setSubject] = useState(""); - const [body, setBody] = useState(DEFAULT_BODY); + const [body, setBody] = useState(""); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [template, setTemplate] = useState(null); useEffect(() => { fetchFirm().then(async (f) => { @@ -64,6 +57,11 @@ export function LetterGenerator() { const url = await loadLogoDataUrl((f as any)?.logo_storage_path); setLogoDataUrl(url); }); + loadFormTemplate("letter").then((t) => { + setTemplate(t); + // Seed the editable body with the template default on first load. + setBody((prev) => prev || t.body || ""); + }); supabase.auth.getUser().then(async ({ data }) => { const uid = data.user?.id; if (!uid) return; @@ -80,12 +78,19 @@ export function LetterGenerator() { }, []); const handleExport = () => { + if (!template) return; const ctx = { client, homeowner, firmName: firm?.company_name ?? "" }; const renderedBody = applyVariables(body, ctx); const renderedSubject = applyVariables(subject, ctx); + + // Use the template's font / size / margin choices for the body. + const bodyFont = (template.font ?? "times") as "helvetica" | "times" | "courier"; + const bodyFontSize = template.fontSizePt ?? 11; + const margin = template.marginPt ?? 60; + const lh = template.lineHeightPt ?? 14; + const doc = new jsPDF({ unit: "pt", format: "letter" }); const pageW = doc.internal.pageSize.getWidth(); - const margin = 60; const maxW = pageW - margin * 2; const centerX = pageW / 2; @@ -103,13 +108,13 @@ export function LetterGenerator() { } } - // Firm name (left) + Attorney name italic (right) on same line + // Firm name (left) + Attorney name italic (right) const firmName = (firm?.company_name ?? "").toUpperCase(); - doc.setFont("times", "bold"); + doc.setFont(bodyFont, "bold"); doc.setFontSize(12); if (firmName) doc.text(firmName, margin, y); if (attorneyName) { - doc.setFont("times", "italic"); + doc.setFont(bodyFont, "italic"); doc.setFontSize(11); doc.text(attorneyName, pageW - margin, y, { align: "right" }); } @@ -126,47 +131,46 @@ export function LetterGenerator() { if (addr) contactParts.push(addr); if (firm?.contact_phone) contactParts.push(firm.contact_phone); if (firm?.contact_email) contactParts.push(firm.contact_email); - doc.setFont("times", "normal"); + doc.setFont(bodyFont, "normal"); doc.setFontSize(10); if (contactParts.length) { doc.text(contactParts.join(" \u00B7 "), centerX, y, { align: "center" }); y += 8; } - // Horizontal rule doc.setLineWidth(0.75); doc.line(margin, y, pageW - margin, y); y += 28; // Centered bold date - doc.setFont("times", "bold"); + doc.setFont(bodyFont, "bold"); doc.setFontSize(11); doc.text(fmtDateLong(date), centerX, y, { align: "center" }); y += 28; // Recipient block - doc.setFont("times", "normal"); - doc.setFontSize(11); + doc.setFont(bodyFont, "normal"); + doc.setFontSize(bodyFontSize); ownerMailingLines(homeowner).forEach((l) => { doc.text(l, margin, y); - y += 13; + y += lh - 1; }); if (!homeowner && client) { doc.text(client.name, margin, y); - y += 13; + y += lh - 1; clientAddressLines(client).forEach((l) => { doc.text(l, margin, y); - y += 13; + y += lh - 1; }); } y += 18; if (renderedSubject) { - doc.setFont("times", "bold"); + doc.setFont(bodyFont, "bold"); const subjLines = doc.splitTextToSize(`Re: ${renderedSubject}`, maxW); doc.text(subjLines, margin, y); - y += subjLines.length * 13 + 14; - doc.setFont("times", "normal"); + y += subjLines.length * lh + 14; + doc.setFont(bodyFont, "normal"); } const bodyLines = doc.splitTextToSize(renderedBody, maxW); @@ -176,7 +180,25 @@ export function LetterGenerator() { y = 60; } doc.text(line, margin, y); - y += 14; + y += lh; + } + + // Optional signature block from the template (admins can edit it). + if (template.signature) { + y += 18; + const sig = fillTokens(template.signature, { + "firm.name": firm?.company_name ?? "", + "owner.name": homeowner ? `${homeowner.first_name} ${homeowner.last_name}`.trim() : "", + "client.name": client?.name ?? "", + }); + sig.split("\n").forEach((line) => { + if (y > 740) { + doc.addPage(); + y = 60; + } + doc.text(line, margin, y); + y += lh; + }); } doc.save(`Letter_${date}.pdf`); @@ -228,11 +250,13 @@ export function LetterGenerator() {

Variables: {"{{ownerName}}"}, {"{{clientName}}"},{" "} {"{{propertyAddress}}"}, {"{{balance}}"},{" "} - {"{{currentDate}}"}, {"{{firmName}}"} + {"{{currentDate}}"}, {"{{firmName}}"}. The default body, + signature, fonts and margins are managed in{" "} + Settings → Form templates.

- From d56dd1d19f0d6a1df75ba59bc89c0bd6c2b1d54a 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 16:44:09 +0000 Subject: [PATCH 5/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/estoppel-form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx index 4376e6c..d947fc5 100644 --- a/src/components/forms/estoppel-form.tsx +++ b/src/components/forms/estoppel-form.tsx @@ -14,6 +14,7 @@ import { type ClientLite, type FirmInfo, } from "@/lib/forms-shared"; +import { fillTokens, loadFormTemplate, type FormTemplateConfig } from "@/lib/form-templates"; import { jsPDF } from "jspdf"; import { toast } from "sonner"; From 09fc48b70b41306a03d33a6a3771ad9ac8185bdb 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 16:44:28 +0000 Subject: [PATCH 6/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/estoppel-form.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx index d947fc5..59d719e 100644 --- a/src/components/forms/estoppel-form.tsx +++ b/src/components/forms/estoppel-form.tsx @@ -52,6 +52,7 @@ export function EstoppelForm() { const [client, setClient] = useState(null); const [clientId, setClientId] = useState(""); const [firm, setFirm] = useState(null); + const [template, setTemplate] = useState(null); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [parcelId, setParcelId] = useState(""); @@ -89,6 +90,7 @@ export function EstoppelForm() { useEffect(() => { fetchFirm().then(setFirm); + loadFormTemplate("estoppel").then(setTemplate); }, []); const total1 = From 46070b958d508d8fe9bf8d9f3b8cbf7494bdd39c 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 16:44:42 +0000 Subject: [PATCH 7/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/estoppel-form.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx index 59d719e..7061c6b 100644 --- a/src/components/forms/estoppel-form.tsx +++ b/src/components/forms/estoppel-form.tsx @@ -109,22 +109,26 @@ export function EstoppelForm() { ); const handleExport = () => { - if (!client) { + if (!client || !template) { toast.error("Select a client first"); return; } + const font = (template.font ?? "helvetica") as "helvetica" | "times" | "courier"; + const fontSize = template.fontSizePt ?? 10; + const titleSize = template.titleFontSizePt ?? 16; + const margin = template.marginPt ?? 54; + const doc = new jsPDF({ unit: "pt", format: "letter" }); const pageW = doc.internal.pageSize.getWidth(); - const margin = 54; let y = 60; // Header - doc.setFont("helvetica", "bold"); - doc.setFontSize(16); - doc.text("ESTOPPEL CERTIFICATE", margin, y); - y += 22; - doc.setFont("helvetica", "normal"); - doc.setFontSize(10); + doc.setFont(font, "bold"); + doc.setFontSize(titleSize); + doc.text(template.title || "ESTOPPEL CERTIFICATE", margin, y); + y += titleSize + 6; + doc.setFont(font, "normal"); + doc.setFontSize(fontSize); doc.text(client.name, margin, y); y += 12; if (firm?.company_name) { From 85f1ce98eff1c757dbf0d2017936a392161a76dd 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 16:45:35 +0000 Subject: [PATCH 8/9] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/estoppel-form.tsx | 36 +-- src/routes/settings.form-templates.tsx | 297 +++++++++++++++++++++++++ src/routes/settings.tsx | 1 + 3 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 src/routes/settings.form-templates.tsx diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx index 7061c6b..a2c7d62 100644 --- a/src/components/forms/estoppel-form.tsx +++ b/src/components/forms/estoppel-form.tsx @@ -135,11 +135,11 @@ export function EstoppelForm() { doc.text(`c/o ${firm.company_name}`, margin, y); y += 12; } - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text(fmtDateLong(date), pageW - margin - doc.getTextWidth(fmtDateLong(date)), 60); y += 16; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); const recipientLines = [ `TO: ${recipientName}`, ...recipientAddress.split("\n").filter(Boolean), @@ -158,24 +158,24 @@ export function EstoppelForm() { ]; meta.forEach(([k, v]) => { if (!v) return; - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text(k, margin, y); - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); doc.text(v, margin + 110, y); y += 13; }); if (legalDescription) { - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("Legal Description:", margin, y); y += 13; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); const ll = doc.splitTextToSize(legalDescription, pageW - margin * 2); doc.text(ll, margin, y); y += ll.length * 13; } y += 10; - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.setFontSize(11); doc.text("FINANCIAL SUMMARY", margin, y); y += 16; @@ -187,7 +187,7 @@ export function EstoppelForm() { ["Delinquency Fee", parseFloat(delinquencyFee) || 0], ["Estoppel Certificate Fee", parseFloat(estoppelFee) || 0], ]; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); finRows.forEach(([d, a]) => { doc.text(d, margin, y); const t = `$${fmtCurrency(a)}`; @@ -197,17 +197,17 @@ export function EstoppelForm() { y += 4; doc.line(margin, y, pageW - margin, y); y += 16; - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("GRAND TOTAL DUE AT CLOSING", margin, y); const gt = `$${fmtCurrency(grandTotal)}`; doc.text(gt, pageW - margin - doc.getTextWidth(gt), y); y += 26; // Q10 payoff - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("CURRENT PAYOFF (delinquency)", margin, y); y += 16; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); const payoffRows: [string, number][] = [ ["Unpaid Assessments", parseFloat(q10Unpaid) || 0], ["Interest", parseFloat(q10Interest) || 0], @@ -224,17 +224,17 @@ export function EstoppelForm() { y += 4; doc.line(margin, y, pageW - margin, y); y += 16; - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("PAYOFF SUBTOTAL", margin, y); const ps = `$${fmtCurrency(q10Sub)}`; doc.text(ps, pageW - margin - doc.getTextWidth(ps), y); y += 26; // Questionnaire - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("QUESTIONNAIRE", margin, y); y += 16; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); const qa: [string, string][] = [ ["Account in collections?", q1], ["Capital contribution due?", q4], @@ -250,18 +250,18 @@ export function EstoppelForm() { y = 60; } doc.text(q, margin, y); - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text(a.toUpperCase(), pageW - margin - 30, y); - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); y += 14; }); if (additionalInfo) { y += 10; - doc.setFont("helvetica", "bold"); + doc.setFont(font, "bold"); doc.text("Additional Information:", margin, y); y += 14; - doc.setFont("helvetica", "normal"); + doc.setFont(font, "normal"); const al = doc.splitTextToSize(additionalInfo, pageW - margin * 2); doc.text(al, margin, y); } diff --git a/src/routes/settings.form-templates.tsx b/src/routes/settings.form-templates.tsx new file mode 100644 index 0000000..c80a139 --- /dev/null +++ b/src/routes/settings.form-templates.tsx @@ -0,0 +1,297 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/lib/auth"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Loader2, RotateCcw, Save } from "lucide-react"; +import { toast } from "sonner"; +import { + DEFAULT_TEMPLATES, + FORM_LABELS, + loadFormTemplate, + saveFormTemplate, + type FormKey, + type FormTemplateConfig, +} from "@/lib/form-templates"; + +export const Route = createFileRoute("/settings/form-templates")({ + component: FormTemplatesPage, +}); + +const KEYS: FormKey[] = ["nola", "itl", "itf", "estoppel", "letter"]; + +function FormTemplatesPage() { + const { isAdmin, user, loading: authLoading } = useAuth(); + const [tab, setTab] = useState("nola"); + + if (!authLoading && !isAdmin) { + throw redirect({ to: "/settings" }); + } + + return ( +
+
+

Form templates

+

+ Edit the title, body text, fonts, margins, item labels and signature block used by each + built-in form. Changes apply the next time anyone generates a PDF for that form. +

+
+ setTab(v as FormKey)}> + + {KEYS.map((k) => ( + + {FORM_LABELS[k]} + + ))} + + {KEYS.map((k) => ( + + + + ))} + +
+ ); +} + +function TemplateEditor({ formKey, userId }: { formKey: FormKey; userId: string | null }) { + const [config, setConfig] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setConfig(null); + loadFormTemplate(formKey).then(setConfig); + }, [formKey]); + + if (!config) { + return ( +
+ Loading template… +
+ ); + } + + const update = (key: K, value: FormTemplateConfig[K]) => + setConfig({ ...config, [key]: value }); + + const updateLabel = ( + key: keyof NonNullable, + value: string, + ) => setConfig({ ...config, itemLabels: { ...(config.itemLabels ?? {}), [key]: value } }); + + const handleSave = async () => { + setSaving(true); + try { + await saveFormTemplate(formKey, config, userId); + toast.success("Template saved"); + } catch (e: any) { + toast.error(e.message ?? "Save failed"); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (!confirm("Reset this template to the built-in defaults? Unsaved changes will be lost.")) return; + setConfig({ ...DEFAULT_TEMPLATES[formKey] }); + }; + + const showItemLabels = ["itl", "itf", "estoppel"].includes(formKey); + const showDefaultLineItems = formKey === "nola"; + + return ( + + +
+
+ + update("title", e.target.value)} + placeholder="e.g. NOTICE OF LATE ASSESSMENT" + /> +
+
+ + update("certifiedMailLabel", e.target.value)} + placeholder="U.S. Certified Mail #" + /> +
+
+ +
+ +