diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx index 4376e6c..a2c7d62 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"; @@ -51,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(""); @@ -88,6 +90,7 @@ export function EstoppelForm() { useEffect(() => { fetchFirm().then(setFirm); + loadFormTemplate("estoppel").then(setTemplate); }, []); const total1 = @@ -106,33 +109,37 @@ 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) { 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), @@ -151,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; @@ -180,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)}`; @@ -190,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], @@ -217,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], @@ -243,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/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/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.

- 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. +

+
- 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/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; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f44751d..8faeb78 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as SettingsSmtpRouteImport } from './routes/settings.smtp' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' import { Route as SettingsImportRouteImport } from './routes/settings.import' import { Route as SettingsImapRouteImport } from './routes/settings.imap' +import { Route as SettingsFormTemplatesRouteImport } from './routes/settings.form-templates' import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' @@ -168,6 +169,11 @@ const SettingsImapRoute = SettingsImapRouteImport.update({ path: '/imap', getParentRoute: () => SettingsRoute, } as any) +const SettingsFormTemplatesRoute = SettingsFormTemplatesRouteImport.update({ + id: '/form-templates', + path: '/form-templates', + getParentRoute: () => SettingsRoute, +} as any) const SettingsFeesRoute = SettingsFeesRouteImport.update({ id: '/fees', path: '/fees', @@ -255,6 +261,7 @@ export interface FileRoutesByFullPath { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/form-templates': typeof SettingsFormTemplatesRoute '/settings/imap': typeof SettingsImapRoute '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute @@ -294,6 +301,7 @@ export interface FileRoutesByTo { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/form-templates': typeof SettingsFormTemplatesRoute '/settings/imap': typeof SettingsImapRoute '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute @@ -335,6 +343,7 @@ export interface FileRoutesById { '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/form-templates': typeof SettingsFormTemplatesRoute '/settings/imap': typeof SettingsImapRoute '/settings/import': typeof SettingsImportRoute '/settings/profile': typeof SettingsProfileRoute @@ -377,6 +386,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/custom-fields' | '/settings/fees' + | '/settings/form-templates' | '/settings/imap' | '/settings/import' | '/settings/profile' @@ -416,6 +426,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/custom-fields' | '/settings/fees' + | '/settings/form-templates' | '/settings/imap' | '/settings/import' | '/settings/profile' @@ -456,6 +467,7 @@ export interface FileRouteTypes { | '/invoices/$invoiceId' | '/settings/custom-fields' | '/settings/fees' + | '/settings/form-templates' | '/settings/imap' | '/settings/import' | '/settings/profile' @@ -684,6 +696,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsImapRouteImport parentRoute: typeof SettingsRoute } + '/settings/form-templates': { + id: '/settings/form-templates' + path: '/form-templates' + fullPath: '/settings/form-templates' + preLoaderRoute: typeof SettingsFormTemplatesRouteImport + parentRoute: typeof SettingsRoute + } '/settings/fees': { id: '/settings/fees' path: '/fees' @@ -788,6 +807,7 @@ declare module '@tanstack/react-router' { interface SettingsRouteChildren { SettingsCustomFieldsRoute: typeof SettingsCustomFieldsRoute SettingsFeesRoute: typeof SettingsFeesRoute + SettingsFormTemplatesRoute: typeof SettingsFormTemplatesRoute SettingsImapRoute: typeof SettingsImapRoute SettingsImportRoute: typeof SettingsImportRoute SettingsProfileRoute: typeof SettingsProfileRoute @@ -800,6 +820,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsCustomFieldsRoute: SettingsCustomFieldsRoute, SettingsFeesRoute: SettingsFeesRoute, + SettingsFormTemplatesRoute: SettingsFormTemplatesRoute, SettingsImapRoute: SettingsImapRoute, SettingsImportRoute: SettingsImportRoute, SettingsProfileRoute: SettingsProfileRoute, 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 #" + /> +
+
+ +
+ +