Added form-based PDF template
X-Lovable-Edit-ID: edt-a8a7b0e9-eaec-40ae-bb47-134bbc3afdad Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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<ClientLite | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [firm, setFirm] = useState<FirmInfo | null>(null);
|
||||
const [template, setTemplate] = useState<FormTemplateConfig | null>(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);
|
||||
}
|
||||
|
||||
@@ -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<FirmInfo | null>(null);
|
||||
const [template, setTemplate] = useState<FormTemplateConfig | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Title, body, fonts, margins, item labels and signature can be customized in{" "}
|
||||
<strong>Settings → Form templates</strong>. Use <code>{`{{lienRef}}`}</code> in the
|
||||
body to insert the recorded-lien reference.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm font-medium">Total due: ${fmtCurrency(total)}</span>
|
||||
<Button onClick={handleExport}>
|
||||
<Button onClick={handleExport} disabled={!template}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
|
||||
@@ -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<FirmInfo | null>(null);
|
||||
const [template, setTemplate] = useState<FormTemplateConfig | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Title, body, fonts, margins, item labels and signature can be customized in{" "}
|
||||
<strong>Settings → Form templates</strong>.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm font-medium">Total due: ${fmtCurrency(total)}</span>
|
||||
<Button onClick={handleExport}>
|
||||
<Button onClick={handleExport} disabled={!template}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null>(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<FormTemplateConfig | null>(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() {
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Variables: <code>{"{{ownerName}}"}</code>, <code>{"{{clientName}}"}</code>,{" "}
|
||||
<code>{"{{propertyAddress}}"}</code>, <code>{"{{balance}}"}</code>,{" "}
|
||||
<code>{"{{currentDate}}"}</code>, <code>{"{{firmName}}"}</code>
|
||||
<code>{"{{currentDate}}"}</code>, <code>{"{{firmName}}"}</code>. The default body,
|
||||
signature, fonts and margins are managed in{" "}
|
||||
<strong>Settings → Form templates</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleExport}>
|
||||
<Button onClick={handleExport} disabled={!template}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
|
||||
@@ -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<FirmInfo | null>(null);
|
||||
const [template, setTemplate] = useState<FormTemplateConfig | null>(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<Item[]>([
|
||||
{ description: "Assessments", amount: "0.00" },
|
||||
{ description: "Late Fees", amount: "0.00" },
|
||||
{ description: "Interest", amount: "0.00" },
|
||||
]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Title, body, fonts, margins, and signature can be customized in{" "}
|
||||
<strong>Settings → Form templates</strong>.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleExport}>
|
||||
<Button onClick={handleExport} disabled={!template}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FormKey>("nola");
|
||||
|
||||
if (!authLoading && !isAdmin) {
|
||||
throw redirect({ to: "/settings" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Form templates</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as FormKey)}>
|
||||
<TabsList className="flex-wrap h-auto">
|
||||
{KEYS.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{FORM_LABELS[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{KEYS.map((k) => (
|
||||
<TabsContent key={k} value={k} className="mt-4">
|
||||
<TemplateEditor formKey={k} userId={user?.id ?? null} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEditor({ formKey, userId }: { formKey: FormKey; userId: string | null }) {
|
||||
const [config, setConfig] = useState<FormTemplateConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(null);
|
||||
loadFormTemplate(formKey).then(setConfig);
|
||||
}, [formKey]);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading template…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const update = <K extends keyof FormTemplateConfig>(key: K, value: FormTemplateConfig[K]) =>
|
||||
setConfig({ ...config, [key]: value });
|
||||
|
||||
const updateLabel = (
|
||||
key: keyof NonNullable<FormTemplateConfig["itemLabels"]>,
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Title (centered, top of document)</Label>
|
||||
<Input
|
||||
value={config.title ?? ""}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="e.g. NOTICE OF LATE ASSESSMENT"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Certified mail label</Label>
|
||||
<Input
|
||||
value={config.certifiedMailLabel ?? ""}
|
||||
onChange={(e) => update("certifiedMailLabel", e.target.value)}
|
||||
placeholder="U.S. Certified Mail #"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Body</Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={config.body ?? ""}
|
||||
onChange={(e) => update("body", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tokens: <code>{`{{client.name}}`}</code>, <code>{`{{owner.name}}`}</code>,{" "}
|
||||
<code>{`{{firm.name}}`}</code>, <code>{`{{date}}`}</code>,{" "}
|
||||
<code>{`{{dueDate}}`}</code>, <code>{`{{total}}`}</code>
|
||||
{formKey === "itf" && <> , <code>{`{{lienRef}}`}</code></>}. Use a blank line to start a
|
||||
new paragraph.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Closing paragraph</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.closing ?? ""}
|
||||
onChange={(e) => update("closing", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Signature block</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={config.signature ?? ""}
|
||||
onChange={(e) => update("signature", e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
placeholder="Sincerely, {{firm.name}}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Font</Label>
|
||||
<Select value={config.font ?? "helvetica"} onValueChange={(v) => update("font", v as any)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="helvetica">Helvetica</SelectItem>
|
||||
<SelectItem value="times">Times</SelectItem>
|
||||
<SelectItem value="courier">Courier</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Body size (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.fontSizePt ?? 10}
|
||||
onChange={(e) => update("fontSizePt", Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Title size (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.titleFontSizePt ?? 13}
|
||||
onChange={(e) => update("titleFontSizePt", Number(e.target.value) || 13)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Margin (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.marginPt ?? 54}
|
||||
onChange={(e) => update("marginPt", Number(e.target.value) || 54)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Line height (pt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.lineHeightPt ?? 13}
|
||||
onChange={(e) => update("lineHeightPt", Number(e.target.value) || 13)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showItemLabels && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Itemized row labels
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{(["assessments", "interest", "lateFees", "adminFees", "lessPayments", "totalDue"] as const).map(
|
||||
(k) => (
|
||||
<div key={k} className="space-y-1.5">
|
||||
<Label className="text-xs capitalize">{k.replace(/([A-Z])/g, " $1")}</Label>
|
||||
<Input
|
||||
value={config.itemLabels?.[k] ?? ""}
|
||||
onChange={(e) => updateLabel(k, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDefaultLineItems && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Default line items (pre-filled when opening this form)
|
||||
</Label>
|
||||
{(config.defaultLineItems ?? []).map((it, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_140px_auto] gap-2 items-center">
|
||||
<Input
|
||||
value={it.description}
|
||||
onChange={(e) => {
|
||||
const next = [...(config.defaultLineItems ?? [])];
|
||||
next[i] = { ...next[i], description: e.target.value };
|
||||
update("defaultLineItems", next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={it.amount}
|
||||
onChange={(e) => {
|
||||
const next = [...(config.defaultLineItems ?? [])];
|
||||
next[i] = { ...next[i], amount: e.target.value };
|
||||
update("defaultLineItems", next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const next = (config.defaultLineItems ?? []).filter((_, idx) => idx !== i);
|
||||
update("defaultLineItems", next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
update("defaultLineItems", [
|
||||
...(config.defaultLineItems ?? []),
|
||||
{ description: "", amount: "0.00" },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add line item
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" /> Reset to defaults
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save template
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const TABS = [
|
||||
{ to: "/settings", label: "Company", exact: true },
|
||||
{ to: "/settings/fees", label: "Fee schedule" },
|
||||
{ to: "/settings/custom-fields", label: "Custom case fields" },
|
||||
{ to: "/settings/form-templates", label: "Form templates" },
|
||||
{ to: "/settings/workflow", label: "Collections workflow" },
|
||||
{ to: "/settings/workflows", label: "Task workflows" },
|
||||
{ to: "/settings/smtp", label: "Email (SMTP)" },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
CREATE TABLE public.form_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
form_key text NOT NULL UNIQUE,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.form_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY form_settings_select_auth ON public.form_settings
|
||||
FOR SELECT TO authenticated USING (true);
|
||||
|
||||
CREATE POLICY form_settings_insert_admin ON public.form_settings
|
||||
FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY form_settings_update_admin ON public.form_settings
|
||||
FOR UPDATE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY form_settings_delete_admin ON public.form_settings
|
||||
FOR DELETE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE TRIGGER tg_form_settings_updated_at
|
||||
BEFORE UPDATE ON public.form_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
Reference in New Issue
Block a user