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