Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
0881a0efe4
commit
c4a0abc898
@@ -0,0 +1,248 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, 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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Bold, Italic, Underline, FileDown, Search } from "lucide-react";
|
||||
import { ClientHomeownerPicker } from "./form-pickers";
|
||||
import {
|
||||
applyVariables,
|
||||
fetchFirm,
|
||||
SYSTEM_VARIABLES,
|
||||
type ClientLite,
|
||||
type HomeownerLite,
|
||||
type FirmInfo,
|
||||
} from "@/lib/forms-shared";
|
||||
import { jsPDF } from "jspdf";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CustomFormBuilder() {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [title, setTitle] = useState("Official Notice");
|
||||
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 [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchFirm().then(setFirm);
|
||||
}, []);
|
||||
|
||||
const insertVar = (key: string) => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.focus();
|
||||
document.execCommand("insertText", false, key);
|
||||
};
|
||||
|
||||
const exec = (cmd: string) => {
|
||||
editorRef.current?.focus();
|
||||
document.execCommand(cmd, false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const raw = editorRef.current?.innerText ?? "";
|
||||
const body = applyVariables(raw, {
|
||||
client,
|
||||
homeowner,
|
||||
firmName: firm?.company_name ?? "",
|
||||
});
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||
const margin = 54;
|
||||
const maxW = doc.internal.pageSize.getWidth() - margin * 2;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
doc.text(applyVariables(title, { client, homeowner, firmName: firm?.company_name ?? "" }), margin, 80);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(11);
|
||||
const lines = doc.splitTextToSize(body || " ", maxW);
|
||||
doc.text(lines, margin, 110);
|
||||
doc.save(`${title.replace(/\s+/g, "_")}.pdf`);
|
||||
toast.success("PDF downloaded");
|
||||
};
|
||||
|
||||
const filtered = SYSTEM_VARIABLES.filter(
|
||||
(v) =>
|
||||
v.key.toLowerCase().includes(search.toLowerCase()) ||
|
||||
v.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
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>
|
||||
<Label>Document title</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Click to insert at cursor position.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search variables..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[360px] pr-2">
|
||||
<div className="space-y-1">
|
||||
{filtered.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
onClick={() => insertVar(v.key)}
|
||||
className="w-full text-left px-2.5 py-2 rounded hover:bg-accent transition-colors border border-transparent hover:border-border"
|
||||
>
|
||||
<div className="font-mono text-xs font-semibold text-primary">{v.key}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{v.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-1 pb-3 border-b mb-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => exec("bold")}>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => exec("italic")}>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => exec("underline")}>
|
||||
<Underline className="h-4 w-4" />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
<Button size="sm" className="ml-auto" onClick={handleExport}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="min-h-[400px] outline-none text-sm leading-relaxed p-4 border rounded bg-background"
|
||||
>
|
||||
<p className="text-muted-foreground">Start typing here…</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { FileDown } from "lucide-react";
|
||||
import { ClientHomeownerPicker } from "./form-pickers";
|
||||
import {
|
||||
fetchFirm,
|
||||
fmtCurrency,
|
||||
fmtDateLong,
|
||||
type ClientLite,
|
||||
type FirmInfo,
|
||||
} from "@/lib/forms-shared";
|
||||
import { jsPDF } from "jspdf";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function YesNoField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 border-b last:border-0">
|
||||
<span className="text-sm">{label}</span>
|
||||
<RadioGroup value={value} onValueChange={onChange} className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="yes" id={`${label}-y`} />
|
||||
<Label htmlFor={`${label}-y`} className="text-xs">
|
||||
Yes
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="no" id={`${label}-n`} />
|
||||
<Label htmlFor={`${label}-n`} className="text-xs">
|
||||
No
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EstoppelForm() {
|
||||
const [client, setClient] = useState<ClientLite | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [firm, setFirm] = useState<FirmInfo | null>(null);
|
||||
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [parcelId, setParcelId] = useState("");
|
||||
const [recipientName, setRecipientName] = useState("");
|
||||
const [recipientAddress, setRecipientAddress] = useState("");
|
||||
const [seller, setSeller] = useState("");
|
||||
const [buyerBank, setBuyerBank] = useState("");
|
||||
const [propertyAddress, setPropertyAddress] = useState("");
|
||||
const [legalDescription, setLegalDescription] = useState("");
|
||||
|
||||
// financials
|
||||
const [balanceAtClose, setBalanceAtClose] = useState("0.00");
|
||||
const [advAssessments, setAdvAssessments] = useState("0.00");
|
||||
const [accountStartupFee, setAccountStartupFee] = useState("0.00");
|
||||
const [delinquencyFee, setDelinquencyFee] = useState("0.00");
|
||||
const [estoppelFee, setEstoppelFee] = useState("250.00");
|
||||
|
||||
// q10 payoff
|
||||
const [q10Unpaid, setQ10Unpaid] = useState("0.00");
|
||||
const [q10Interest, setQ10Interest] = useState("0.00");
|
||||
const [q10Late, setQ10Late] = useState("0.00");
|
||||
const [q10Admin, setQ10Admin] = useState("0.00");
|
||||
const [q10Legal, setQ10Legal] = useState("0.00");
|
||||
|
||||
// questionnaire
|
||||
const [q1, setQ1] = useState("no");
|
||||
const [q4, setQ4] = useState("no");
|
||||
const [q6, setQ6] = useState("no");
|
||||
const [q8, setQ8] = useState("no");
|
||||
const [q17, setQ17] = useState("no");
|
||||
const [q24, setQ24] = useState("no");
|
||||
const [q26, setQ26] = useState("no");
|
||||
|
||||
const [additionalInfo, setAdditionalInfo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchFirm().then(setFirm);
|
||||
}, []);
|
||||
|
||||
const total1 =
|
||||
(parseFloat(balanceAtClose) || 0) + (parseFloat(advAssessments) || 0);
|
||||
const total2 =
|
||||
(parseFloat(accountStartupFee) || 0) + (parseFloat(delinquencyFee) || 0);
|
||||
const q10Sub =
|
||||
(parseFloat(q10Unpaid) || 0) +
|
||||
(parseFloat(q10Interest) || 0) +
|
||||
(parseFloat(q10Late) || 0) +
|
||||
(parseFloat(q10Admin) || 0) +
|
||||
(parseFloat(q10Legal) || 0);
|
||||
const grandTotal = useMemo(
|
||||
() => total1 + total2 + (parseFloat(estoppelFee) || 0),
|
||||
[total1, total2, estoppelFee],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
// Header
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
doc.text("ESTOPPEL CERTIFICATE", margin, y);
|
||||
y += 22;
|
||||
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;
|
||||
}
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(fmtDateLong(date), pageW - margin - doc.getTextWidth(fmtDateLong(date)), 60);
|
||||
|
||||
y += 16;
|
||||
doc.setFont("helvetica", "normal");
|
||||
const recipientLines = [
|
||||
`TO: ${recipientName}`,
|
||||
...recipientAddress.split("\n").filter(Boolean),
|
||||
];
|
||||
recipientLines.forEach((l) => {
|
||||
doc.text(l, margin, y);
|
||||
y += 13;
|
||||
});
|
||||
|
||||
y += 10;
|
||||
const meta: [string, string][] = [
|
||||
["Parcel ID:", parcelId],
|
||||
["Seller:", seller],
|
||||
["Buyer / Bank:", buyerBank],
|
||||
["Property Address:", propertyAddress],
|
||||
];
|
||||
meta.forEach(([k, v]) => {
|
||||
if (!v) return;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(k, margin, y);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(v, margin + 110, y);
|
||||
y += 13;
|
||||
});
|
||||
if (legalDescription) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Legal Description:", margin, y);
|
||||
y += 13;
|
||||
doc.setFont("helvetica", "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.setFontSize(11);
|
||||
doc.text("FINANCIAL SUMMARY", margin, y);
|
||||
y += 16;
|
||||
doc.setFontSize(10);
|
||||
const finRows: [string, number][] = [
|
||||
["Balance at Closing", parseFloat(balanceAtClose) || 0],
|
||||
["Advance Assessments", parseFloat(advAssessments) || 0],
|
||||
["Account Start-up Fee", parseFloat(accountStartupFee) || 0],
|
||||
["Delinquency Fee", parseFloat(delinquencyFee) || 0],
|
||||
["Estoppel Certificate Fee", parseFloat(estoppelFee) || 0],
|
||||
];
|
||||
doc.setFont("helvetica", "normal");
|
||||
finRows.forEach(([d, a]) => {
|
||||
doc.text(d, margin, y);
|
||||
const t = `$${fmtCurrency(a)}`;
|
||||
doc.text(t, pageW - margin - doc.getTextWidth(t), y);
|
||||
y += 14;
|
||||
});
|
||||
y += 4;
|
||||
doc.line(margin, y, pageW - margin, y);
|
||||
y += 16;
|
||||
doc.setFont("helvetica", "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.text("CURRENT PAYOFF (delinquency)", margin, y);
|
||||
y += 16;
|
||||
doc.setFont("helvetica", "normal");
|
||||
const payoffRows: [string, number][] = [
|
||||
["Unpaid Assessments", parseFloat(q10Unpaid) || 0],
|
||||
["Interest", parseFloat(q10Interest) || 0],
|
||||
["Late Fees", parseFloat(q10Late) || 0],
|
||||
["Admin Fees", parseFloat(q10Admin) || 0],
|
||||
["Legal Fees", parseFloat(q10Legal) || 0],
|
||||
];
|
||||
payoffRows.forEach(([d, a]) => {
|
||||
doc.text(d, margin, y);
|
||||
const t = `$${fmtCurrency(a)}`;
|
||||
doc.text(t, pageW - margin - doc.getTextWidth(t), y);
|
||||
y += 14;
|
||||
});
|
||||
y += 4;
|
||||
doc.line(margin, y, pageW - margin, y);
|
||||
y += 16;
|
||||
doc.setFont("helvetica", "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.text("QUESTIONNAIRE", margin, y);
|
||||
y += 16;
|
||||
doc.setFont("helvetica", "normal");
|
||||
const qa: [string, string][] = [
|
||||
["Account in collections?", q1],
|
||||
["Capital contribution due?", q4],
|
||||
["Special assessments pending?", q6],
|
||||
["Litigation pending?", q8],
|
||||
["Open violations?", q17],
|
||||
["Foreclosure pending?", q24],
|
||||
["Liens recorded?", q26],
|
||||
];
|
||||
qa.forEach(([q, a]) => {
|
||||
if (y > 740) {
|
||||
doc.addPage();
|
||||
y = 60;
|
||||
}
|
||||
doc.text(q, margin, y);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(a.toUpperCase(), pageW - margin - 30, y);
|
||||
doc.setFont("helvetica", "normal");
|
||||
y += 14;
|
||||
});
|
||||
|
||||
if (additionalInfo) {
|
||||
y += 10;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Additional Information:", margin, y);
|
||||
y += 14;
|
||||
doc.setFont("helvetica", "normal");
|
||||
const al = doc.splitTextToSize(additionalInfo, pageW - margin * 2);
|
||||
doc.text(al, margin, y);
|
||||
}
|
||||
|
||||
doc.save(`Estoppel_${parcelId || "draft"}_${date}.pdf`);
|
||||
toast.success("Estoppel 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>Date</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Parcel ID</Label>
|
||||
<Input value={parcelId} onChange={(e) => setParcelId(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Recipient name (TO:)</Label>
|
||||
<Input value={recipientName} onChange={(e) => setRecipientName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Property address</Label>
|
||||
<Input
|
||||
value={propertyAddress}
|
||||
onChange={(e) => setPropertyAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Seller</Label>
|
||||
<Input value={seller} onChange={(e) => setSeller(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Buyer / Bank</Label>
|
||||
<Input value={buyerBank} onChange={(e) => setBuyerBank(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Recipient mailing address</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={recipientAddress}
|
||||
onChange={(e) => setRecipientAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legal description</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={legalDescription}
|
||||
onChange={(e) => setLegalDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="font-semibold">Financials</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>Balance at closing</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={balanceAtClose}
|
||||
onChange={(e) => setBalanceAtClose(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Adv. assessments</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={advAssessments}
|
||||
onChange={(e) => setAdvAssessments(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account start-up fee</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={accountStartupFee}
|
||||
onChange={(e) => setAccountStartupFee(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Delinquency fee</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={delinquencyFee}
|
||||
onChange={(e) => setDelinquencyFee(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Estoppel fee</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={estoppelFee}
|
||||
onChange={(e) => setEstoppelFee(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-right">
|
||||
Grand total: ${fmtCurrency(grandTotal)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="font-semibold">Current payoff (delinquency)</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Unpaid assess.</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={q10Unpaid}
|
||||
onChange={(e) => setQ10Unpaid(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Interest</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={q10Interest}
|
||||
onChange={(e) => setQ10Interest(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Late fees</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={q10Late}
|
||||
onChange={(e) => setQ10Late(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Admin fees</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={q10Admin}
|
||||
onChange={(e) => setQ10Admin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legal fees</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={q10Legal}
|
||||
onChange={(e) => setQ10Legal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-right">Subtotal: ${fmtCurrency(q10Sub)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-1">
|
||||
<h3 className="font-semibold mb-2">Questionnaire</h3>
|
||||
<YesNoField label="Account in collections?" value={q1} onChange={setQ1} />
|
||||
<YesNoField label="Capital contribution due?" value={q4} onChange={setQ4} />
|
||||
<YesNoField label="Special assessments pending?" value={q6} onChange={setQ6} />
|
||||
<YesNoField label="Litigation pending?" value={q8} onChange={setQ8} />
|
||||
<YesNoField label="Open violations?" value={q17} onChange={setQ17} />
|
||||
<YesNoField label="Foreclosure pending?" value={q24} onChange={setQ24} />
|
||||
<YesNoField label="Liens recorded?" value={q26} onChange={setQ26} />
|
||||
<div className="pt-3">
|
||||
<Label>Additional information</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={additionalInfo}
|
||||
onChange={(e) => setAdditionalInfo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-3">
|
||||
<Button onClick={handleExport}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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 ItlForm() {
|
||||
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 [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);
|
||||
}, []);
|
||||
|
||||
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 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}`],
|
||||
];
|
||||
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);
|
||||
|
||||
doc.save(`ITL_${ownerFullName(homeowner) || "draft"}_${letterDate}.pdf`);
|
||||
toast.success("ITL 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>Due date (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-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FileDown } from "lucide-react";
|
||||
import { ClientHomeownerPicker } from "./form-pickers";
|
||||
import {
|
||||
applyVariables,
|
||||
clientAddressLines,
|
||||
fetchFirm,
|
||||
fmtDateLong,
|
||||
ownerMailingLines,
|
||||
type ClientLite,
|
||||
type HomeownerLite,
|
||||
type FirmInfo,
|
||||
} from "@/lib/forms-shared";
|
||||
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}}`;
|
||||
|
||||
export function LetterGenerator() {
|
||||
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 [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState(DEFAULT_BODY);
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
|
||||
useEffect(() => {
|
||||
fetchFirm().then(setFirm);
|
||||
}, []);
|
||||
|
||||
const handleExport = () => {
|
||||
const ctx = { client, homeowner, firmName: firm?.company_name ?? "" };
|
||||
const renderedBody = applyVariables(body, ctx);
|
||||
const renderedSubject = applyVariables(subject, ctx);
|
||||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const margin = 60;
|
||||
const maxW = pageW - margin * 2;
|
||||
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(13);
|
||||
if (firm?.company_name) doc.text(firm.company_name, margin, 60);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
const firmLines = [
|
||||
firm?.address_line1,
|
||||
[firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", "),
|
||||
firm?.contact_phone,
|
||||
firm?.contact_email,
|
||||
].filter(Boolean) as string[];
|
||||
let y = 75;
|
||||
firmLines.forEach((l) => {
|
||||
doc.text(l, margin, y);
|
||||
y += 11;
|
||||
});
|
||||
|
||||
y += 20;
|
||||
doc.setFontSize(10);
|
||||
doc.text(fmtDateLong(date), margin, y);
|
||||
y += 28;
|
||||
|
||||
ownerMailingLines(homeowner).forEach((l) => {
|
||||
doc.text(l, margin, y);
|
||||
y += 12;
|
||||
});
|
||||
if (!homeowner && client) {
|
||||
doc.text(client.name, margin, y);
|
||||
y += 12;
|
||||
clientAddressLines(client).forEach((l) => {
|
||||
doc.text(l, margin, y);
|
||||
y += 12;
|
||||
});
|
||||
}
|
||||
|
||||
y += 20;
|
||||
if (renderedSubject) {
|
||||
doc.setFont("helvetica", "bold");
|
||||
const subjLines = doc.splitTextToSize(`Re: ${renderedSubject}`, maxW);
|
||||
doc.text(subjLines, margin, y);
|
||||
y += subjLines.length * 12 + 14;
|
||||
doc.setFont("helvetica", "normal");
|
||||
}
|
||||
|
||||
doc.setFontSize(11);
|
||||
const bodyLines = doc.splitTextToSize(renderedBody, maxW);
|
||||
for (const line of bodyLines) {
|
||||
if (y > 740) {
|
||||
doc.addPage();
|
||||
y = 60;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += 14;
|
||||
}
|
||||
|
||||
doc.save(`Letter_${date}.pdf`);
|
||||
toast.success("Letter 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-2 gap-4">
|
||||
<div>
|
||||
<Label>Date</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Subject (Re:)</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Body</Label>
|
||||
<Textarea
|
||||
rows={14}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleExport}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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 { FileDown, Plus, Trash2 } 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";
|
||||
|
||||
interface Item {
|
||||
description: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
export function NolaForm() {
|
||||
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 [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [dueDate, setDueDate] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
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" },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFirm().then(setFirm);
|
||||
}, []);
|
||||
|
||||
const total = items.reduce((s, it) => s + (parseFloat(it.amount) || 0), 0);
|
||||
|
||||
const updateItem = (i: number, field: keyof Item, v: string) => {
|
||||
setItems((prev) => prev.map((it, idx) => (idx === i ? { ...it, [field]: v } : it)));
|
||||
};
|
||||
const addItem = () => setItems((p) => [...p, { description: "", amount: "0.00" }]);
|
||||
const removeItem = (i: number) => setItems((p) => p.filter((_, idx) => idx !== i));
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
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");
|
||||
};
|
||||
|
||||
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={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Due date</Label>
|
||||
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Certified mail #</Label>
|
||||
<Input value={certifiedNo} onChange={(e) => setCertifiedNo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Itemized amounts</Label>
|
||||
<Button size="sm" variant="outline" onClick={addItem}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_140px_auto] gap-2 items-center">
|
||||
<Input
|
||||
value={it.description}
|
||||
onChange={(e) => updateItem(i, "description", e.target.value)}
|
||||
placeholder="Description"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={it.amount}
|
||||
onChange={(e) => updateItem(i, "amount", e.target.value)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => removeItem(i)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end pt-2 text-sm font-medium">
|
||||
Total: ${fmtCurrency(total)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user