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; +}