Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 16:41:57 +00:00
co-authored by renee-png
parent 1dd6dbefff
commit ac26cbdafb
+390
View File
@@ -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<FormKey, string> = {
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<FormKey, FormTemplateConfig> = {
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, string | number | undefined>): 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<FormTemplateConfig> {
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<string, string | number | undefined>;
// 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<string, string | number | undefined>;
}): Record<string, string | number> {
const base: Record<string, string | number> = {
"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;
}