Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 02:40:36 +00:00
co-authored by renee-png
parent 5d71b2073e
commit 4b36efc3fa
3 changed files with 273 additions and 251 deletions
-248
View File
@@ -1,248 +0,0 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FileDown } from "lucide-react";
import { ClientHomeownerPicker } from "./form-pickers";
import {
fetchFirm,
type ClientLite,
type FirmInfo,
} from "@/lib/forms-shared";
import { jsPDF } from "jspdf";
import { format } from "date-fns";
import { toast } from "sonner";
const MEETING_TYPES = [
"Annual Membership Meeting",
"Special Membership Meeting",
"Board of Directors Meeting",
"Special Board Meeting",
"Budget Meeting",
"Turnover Meeting",
"Election Meeting",
];
const NOTICE_DAYS = [
{ value: "60", label: "Sixty (60) Days", word: "sixty (60)" },
{ value: "30", label: "Thirty (30) Days", word: "thirty (30)" },
{ value: "14", label: "Fourteen (14) Days", word: "fourteen (14)" },
];
const STATUTES = [
{ value: "720", label: "§720.306(1)(d)(5) — HOA", text: "§720.306(1)(d)(5)" },
{ value: "718", label: "§718.112(2)(d)3 — Condo", text: "§718.112(2)(d)3" },
];
function ordinal(day: number) {
const s = ["th", "st", "nd", "rd"];
const v = day % 100;
return `${day}${s[(v - 20) % 10] || s[v] || s[0]}`;
}
export function AffidavitForm() {
const [client, setClient] = useState<ClientLite | null>(null);
const [clientId, setClientId] = useState("");
const [, setFirm] = useState<FirmInfo | null>(null);
const [mailingDate, setMailingDate] = useState(new Date().toISOString().slice(0, 10));
const [meetingType, setMeetingType] = useState(MEETING_TYPES[0]);
const [noticeDays, setNoticeDays] = useState("14");
const [statute, setStatute] = useState("720");
const [signerName, setSignerName] = useState("");
const [signerTitle, setSignerTitle] = useState("Manager");
const [signerCredentials, setSignerCredentials] = useState("");
const [county, setCounty] = useState("Brevard");
useEffect(() => {
fetchFirm().then(setFirm);
}, []);
const handleExport = () => {
if (!client) {
toast.error("Select a client first");
return;
}
const dateObj = new Date(mailingDate + "T12:00:00");
const dayWord = ordinal(dateObj.getDate());
const monthName = format(dateObj, "MMMM");
const year = format(dateObj, "yyyy");
const dateWordy = format(dateObj, "MMMM d, yyyy");
const daysWord = NOTICE_DAYS.find((d) => d.value === noticeDays)?.word ?? noticeDays;
const statuteText = STATUTES.find((s) => s.value === statute)?.text ?? "";
const doc = new jsPDF({ unit: "pt", format: "letter" });
const pageW = doc.internal.pageSize.getWidth();
const margin = 60;
const contentW = pageW - margin * 2;
let y = 70;
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text("AFFIDAVIT OF MAILING", margin, y);
y += 26;
doc.setFont("helvetica", "bolditalic");
doc.setFontSize(11);
doc.text(`${year} ${meetingType}`, margin, y);
y += 26;
doc.setFont("helvetica", "bold");
doc.setFontSize(10.5);
doc.text("STATE OF FLORIDA", margin, y);
y += 15;
doc.text(`COUNTY OF ${county.toUpperCase()}`, margin, y);
y += 24;
doc.setFont("helvetica", "normal");
const body = `I, ${signerName.toUpperCase() || "[SIGNER]"}, on behalf of the Secretary of ${client.name}, being first duly sworn, depose and say that the notice of the ${meetingType.toUpperCase()} was mailed, hand delivered, or electronically sent to each unit owner at the address last furnished to the Association in accordance with the requirements of Section ${statuteText} Florida Statutes, at least ${daysWord} days prior to the noticed meeting, on ${dateWordy}.`;
const lines = doc.splitTextToSize(body, contentW);
doc.text(lines, margin, y);
y += lines.length * 15 + 8;
doc.text(`Dated this ${dayWord} day of ${monthName}, ${year}.`, margin, y);
y += 28;
doc.setFont("helvetica", "bold");
doc.text(`BY: ${signerName || "[Signer]"}, ${signerTitle}`, margin, y);
y += 44;
doc.line(margin, y, margin + 250, y);
y += 13;
doc.setFont("helvetica", "italic");
doc.setFontSize(9);
doc.text(signerCredentials || signerName, margin, y);
y += 36;
doc.setFont("helvetica", "bold");
doc.setFontSize(10.5);
doc.text("STATE OF FLORIDA", margin, y);
y += 15;
doc.text(`COUNTY OF ${county.toUpperCase()}`, margin, y);
y += 24;
doc.setFont("helvetica", "normal");
const notary = `The foregoing Affidavit was acknowledged before me this ${dayWord} day of ${monthName}, ${year} by ${signerName || "[Signer]"}, who is personally known to me or produced a Florida Driver's License as identification.`;
const nLines = doc.splitTextToSize(notary, contentW);
doc.text(nLines, margin, y);
y += nLines.length * 15 + 24;
doc.line(margin, y, margin + 270, y);
y += 13;
doc.text("NOTARY PUBLIC", margin, y);
y += 32;
doc.setFont("helvetica", "bold");
doc.text("(SEAL)", margin, y);
doc.save(`Affidavit_of_Mailing_${mailingDate}.pdf`);
toast.success("Affidavit PDF downloaded");
};
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<ClientHomeownerPicker
clientId={clientId}
homeownerId=""
onClientChange={(id, c) => {
setClientId(id);
setClient(c);
}}
onHomeownerChange={() => {}}
showHomeowner={false}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Mailing date</Label>
<Input
type="date"
value={mailingDate}
onChange={(e) => setMailingDate(e.target.value)}
/>
</div>
<div>
<Label>Meeting type</Label>
<Select value={meetingType} onValueChange={setMeetingType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MEETING_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Notice period</Label>
<Select value={noticeDays} onValueChange={setNoticeDays}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTICE_DAYS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Statute</Label>
<Select value={statute} onValueChange={setStatute}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUTES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>County</Label>
<Input value={county} onChange={(e) => setCounty(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Signer name</Label>
<Input value={signerName} onChange={(e) => setSignerName(e.target.value)} />
</div>
<div>
<Label>Signer title</Label>
<Input value={signerTitle} onChange={(e) => setSignerTitle(e.target.value)} />
</div>
<div>
<Label>Credentials</Label>
<Input
value={signerCredentials}
onChange={(e) => setSignerCredentials(e.target.value)}
placeholder="e.g. LCAM"
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleExport}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { FileDown } from "lucide-react";
import { ClientHomeownerPicker } from "./form-pickers";
import {
clientAddressLines,
fetchFirm,
fmtCurrency,
fmtDateLong,
ownerFullName,
type ClientLite,
type HomeownerLite,
type FirmInfo,
} from "@/lib/forms-shared";
import { jsPDF } from "jspdf";
import { toast } from "sonner";
export function ItfForm() {
const [client, setClient] = useState<ClientLite | null>(null);
const [homeowner, setHomeowner] = useState<HomeownerLite | null>(null);
const [clientId, setClientId] = useState("");
const [homeownerId, setHomeownerId] = useState("");
const [firm, setFirm] = useState<FirmInfo | null>(null);
const [letterDate, setLetterDate] = useState(new Date().toISOString().slice(0, 10));
const [certifiedNo, setCertifiedNo] = useState("");
const [lienBookPage, setLienBookPage] = useState("");
const [lienRecordedDate, setLienRecordedDate] = useState("");
const [assessments, setAssessments] = useState("0.00");
const [interest, setInterest] = useState("0.00");
const [lateFees, setLateFees] = useState("0.00");
const [adminFees, setAdminFees] = useState("0.00");
const [lessPayments, setLessPayments] = useState("0.00");
useEffect(() => {
fetchFirm().then(setFirm);
}, []);
// 45-day cure window prior to filing foreclosure
const dueDate = useMemo(() => {
const d = new Date(letterDate + "T12:00:00");
d.setDate(d.getDate() + 45);
return d.toISOString().slice(0, 10);
}, [letterDate]);
const total =
(parseFloat(assessments) || 0) +
(parseFloat(interest) || 0) +
(parseFloat(lateFees) || 0) +
(parseFloat(adminFees) || 0) -
(parseFloat(lessPayments) || 0);
const handleExport = () => {
if (!client) {
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;
});
doc.setFont("helvetica", "bold");
doc.text(fmtDateLong(letterDate), pageW - margin - doc.getTextWidth(fmtDateLong(letterDate)), 60);
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);
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}`],
];
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);
doc.save(`ITF_${ownerFullName(homeowner) || "draft"}_${letterDate}.pdf`);
toast.success("ITF PDF downloaded");
};
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<ClientHomeownerPicker
clientId={clientId}
homeownerId={homeownerId}
onClientChange={(id, c) => {
setClientId(id);
setClient(c);
}}
onHomeownerChange={(id, h) => {
setHomeownerId(id);
setHomeowner(h);
}}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Letter date</Label>
<Input
type="date"
value={letterDate}
onChange={(e) => setLetterDate(e.target.value)}
/>
</div>
<div>
<Label>Cure deadline (auto +45 days)</Label>
<Input value={fmtDateLong(dueDate)} readOnly className="bg-muted" />
</div>
<div>
<Label>Certified mail #</Label>
<Input value={certifiedNo} onChange={(e) => setCertifiedNo(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Lien recorded date</Label>
<Input
type="date"
value={lienRecordedDate}
onChange={(e) => setLienRecordedDate(e.target.value)}
/>
</div>
<div>
<Label>Lien book/page (or instrument #)</Label>
<Input
value={lienBookPage}
onChange={(e) => setLienBookPage(e.target.value)}
placeholder="e.g., Book 1234 / Page 567 or Inst # 2024000123"
/>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div>
<Label>Assessments</Label>
<Input
type="number"
step="0.01"
value={assessments}
onChange={(e) => setAssessments(e.target.value)}
/>
</div>
<div>
<Label>Interest</Label>
<Input
type="number"
step="0.01"
value={interest}
onChange={(e) => setInterest(e.target.value)}
/>
</div>
<div>
<Label>Late fees</Label>
<Input
type="number"
step="0.01"
value={lateFees}
onChange={(e) => setLateFees(e.target.value)}
/>
</div>
<div>
<Label>Admin/legal</Label>
<Input
type="number"
step="0.01"
value={adminFees}
onChange={(e) => setAdminFees(e.target.value)}
/>
</div>
<div>
<Label>Less payments</Label>
<Input
type="number"
step="0.01"
value={lessPayments}
onChange={(e) => setLessPayments(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium">Total due: ${fmtCurrency(total)}</span>
<Button onClick={handleExport}>
<FileDown className="h-4 w-4 mr-2" />
Export PDF
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+3 -3
View File
@@ -7,7 +7,7 @@ import { CustomFormBuilder } from "@/components/forms/custom-form-builder";
import { LetterGenerator } from "@/components/forms/letter-generator";
import { NolaForm } from "@/components/forms/nola-form";
import { ItlForm } from "@/components/forms/itl-form";
import { AffidavitForm } from "@/components/forms/affidavit-form";
import { ItfForm } from "@/components/forms/itf-form";
import { EstoppelForm } from "@/components/forms/estoppel-form";
export const Route = createFileRoute("/forms/")({
@@ -29,7 +29,7 @@ function FormsPage() {
<TabsTrigger value="letters">Letters</TabsTrigger>
<TabsTrigger value="nola">NOLA</TabsTrigger>
<TabsTrigger value="itl">ITL</TabsTrigger>
<TabsTrigger value="affidavit">Affidavit</TabsTrigger>
<TabsTrigger value="itf">ITF</TabsTrigger>
<TabsTrigger value="estoppel">Estoppel</TabsTrigger>
</TabsList>
<div className="mt-6">
@@ -37,7 +37,7 @@ function FormsPage() {
<TabsContent value="letters"><LetterGenerator /></TabsContent>
<TabsContent value="nola"><NolaForm /></TabsContent>
<TabsContent value="itl"><ItlForm /></TabsContent>
<TabsContent value="affidavit"><AffidavitForm /></TabsContent>
<TabsContent value="itf"><ItfForm /></TabsContent>
<TabsContent value="estoppel"><EstoppelForm /></TabsContent>
</div>
</Tabs>