Added Forms & Letters sidebar
X-Lovable-Edit-ID: edt-01175822-15ab-4609-9074-45f095ea5463 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,7 @@ const NAV: NavItem[] = [
|
||||
{ to: "/clients", label: "Clients", icon: Users },
|
||||
{ to: "/cases", label: "Cases", icon: Briefcase },
|
||||
{ to: "/invoices", label: "Invoices", icon: Receipt },
|
||||
{ to: "/forms", label: "Forms & Letters", icon: FileText },
|
||||
{ to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
fetchClients,
|
||||
fetchHomeowners,
|
||||
type ClientLite,
|
||||
type HomeownerLite,
|
||||
} from "@/lib/forms-shared";
|
||||
|
||||
export function ClientHomeownerPicker({
|
||||
clientId,
|
||||
homeownerId,
|
||||
onClientChange,
|
||||
onHomeownerChange,
|
||||
showHomeowner = true,
|
||||
}: {
|
||||
clientId: string;
|
||||
homeownerId: string;
|
||||
onClientChange: (id: string, client: ClientLite | null) => void;
|
||||
onHomeownerChange: (id: string, homeowner: HomeownerLite | null) => void;
|
||||
showHomeowner?: boolean;
|
||||
}) {
|
||||
const [clients, setClients] = useState<ClientLite[]>([]);
|
||||
const [homeowners, setHomeowners] = useState<HomeownerLite[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients().then(setClients);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
setHomeowners([]);
|
||||
return;
|
||||
}
|
||||
fetchHomeowners(clientId).then(setHomeowners);
|
||||
}, [clientId]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Client / Association</Label>
|
||||
<Select
|
||||
value={clientId}
|
||||
onValueChange={(id) => {
|
||||
const c = clients.find((x) => x.id === id) ?? null;
|
||||
onClientChange(id, c);
|
||||
onHomeownerChange("", null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{showHomeowner && (
|
||||
<div>
|
||||
<Label>Homeowner (optional)</Label>
|
||||
<Select
|
||||
value={homeownerId}
|
||||
onValueChange={(id) => {
|
||||
const h = homeowners.find((x) => x.id === id) ?? null;
|
||||
onHomeownerChange(id, h);
|
||||
}}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={clientId ? "Select homeowner" : "Pick client first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{homeowners.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.last_name}, {h.first_name}
|
||||
{h.unit_number ? ` — Unit ${h.unit_number}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export interface ClientLite {
|
||||
id: string;
|
||||
name: string;
|
||||
address_line1: string | null;
|
||||
address_line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postal_code: string | null;
|
||||
primary_contact_email: string | null;
|
||||
primary_contact_phone: string | null;
|
||||
annual_interest_rate: number | null;
|
||||
}
|
||||
|
||||
export interface HomeownerLite {
|
||||
id: string;
|
||||
client_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
unit_number: string | null;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
opening_balance: number;
|
||||
}
|
||||
|
||||
export async function fetchClients(): Promise<ClientLite[]> {
|
||||
const { data } = await supabase
|
||||
.from("clients")
|
||||
.select(
|
||||
"id,name,address_line1,address_line2,city,state,postal_code,primary_contact_email,primary_contact_phone,annual_interest_rate",
|
||||
)
|
||||
.order("name");
|
||||
return (data ?? []) as ClientLite[];
|
||||
}
|
||||
|
||||
export async function fetchHomeowners(clientId: string): Promise<HomeownerLite[]> {
|
||||
const { data } = await supabase
|
||||
.from("homeowners")
|
||||
.select("id,client_id,first_name,last_name,unit_number,address,email,phone,opening_balance")
|
||||
.eq("client_id", clientId)
|
||||
.order("last_name");
|
||||
return (data ?? []) as HomeownerLite[];
|
||||
}
|
||||
|
||||
export function ownerFullName(h: HomeownerLite | null | undefined): string {
|
||||
if (!h) return "";
|
||||
return `${h.first_name} ${h.last_name}`.trim();
|
||||
}
|
||||
|
||||
export function clientAddressLines(c: ClientLite | null | undefined): string[] {
|
||||
if (!c) return [];
|
||||
const lines: string[] = [];
|
||||
if (c.address_line1) lines.push(c.address_line1);
|
||||
if (c.address_line2) lines.push(c.address_line2);
|
||||
const cityLine = [c.city, c.state, c.postal_code].filter(Boolean).join(", ");
|
||||
if (cityLine) lines.push(cityLine);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function ownerMailingLines(h: HomeownerLite | null | undefined): string[] {
|
||||
if (!h) return [];
|
||||
const lines: string[] = [ownerFullName(h)];
|
||||
if (h.address) lines.push(h.address);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const SYSTEM_VARIABLES = [
|
||||
{ key: "{{clientName}}", description: "Client / Association name" },
|
||||
{ key: "{{ownerName}}", description: "Homeowner full name" },
|
||||
{ key: "{{propertyAddress}}", description: "Homeowner property address" },
|
||||
{ key: "{{unitNumber}}", description: "Homeowner unit number" },
|
||||
{ key: "{{accountNumber}}", description: "Account / unit identifier" },
|
||||
{ key: "{{balance}}", description: "Current balance" },
|
||||
{ key: "{{currentDate}}", description: "Today's date" },
|
||||
{ key: "{{firmName}}", description: "Your firm name" },
|
||||
] as const;
|
||||
|
||||
export function applyVariables(
|
||||
body: string,
|
||||
ctx: { client?: ClientLite | null; homeowner?: HomeownerLite | null; firmName?: string },
|
||||
): string {
|
||||
const today = format(new Date(), "MMMM d, yyyy");
|
||||
const replacements: Record<string, string> = {
|
||||
"{{clientName}}": ctx.client?.name ?? "",
|
||||
"{{ownerName}}": ownerFullName(ctx.homeowner),
|
||||
"{{propertyAddress}}": ctx.homeowner?.address ?? "",
|
||||
"{{unitNumber}}": ctx.homeowner?.unit_number ?? "",
|
||||
"{{accountNumber}}": ctx.homeowner?.unit_number ?? "",
|
||||
"{{balance}}": (ctx.homeowner?.opening_balance ?? 0).toFixed(2),
|
||||
"{{currentDate}}": today,
|
||||
"{{firmName}}": ctx.firmName ?? "",
|
||||
};
|
||||
let out = body;
|
||||
for (const [k, v] of Object.entries(replacements)) {
|
||||
out = out.split(k).join(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fmtCurrency(n: number | string | null | undefined): string {
|
||||
const v = typeof n === "string" ? parseFloat(n) : (n ?? 0);
|
||||
return (Number.isFinite(v) ? v : 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtDateLong(d: Date | string | null | undefined): string {
|
||||
if (!d) return "";
|
||||
const date = typeof d === "string" ? new Date(d.includes("T") ? d : d + "T12:00:00") : d;
|
||||
return format(date, "MMMM d, yyyy");
|
||||
}
|
||||
|
||||
export interface FirmInfo {
|
||||
company_name: string | null;
|
||||
address_line1: string | null;
|
||||
address_line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postal_code: string | null;
|
||||
contact_email: string | null;
|
||||
contact_phone: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export async function fetchFirm(): Promise<FirmInfo | null> {
|
||||
const { data } = await supabase.from("firm_settings").select("*").maybeSingle();
|
||||
return (data as FirmInfo) ?? null;
|
||||
}
|
||||
|
||||
export async function savePdfToDocuments(opts: {
|
||||
blob: Blob;
|
||||
caseId: string;
|
||||
name: string;
|
||||
folder?: string;
|
||||
}) {
|
||||
const { blob, caseId, name, folder = "Forms & Letters" } = opts;
|
||||
const path = `${caseId}/${Date.now()}-${name}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from("case-documents")
|
||||
.upload(path, blob, { contentType: "application/pdf", upsert: false });
|
||||
if (upErr) throw upErr;
|
||||
const { data: u } = await supabase.auth.getUser();
|
||||
const { error: insErr } = await supabase.from("documents").insert({
|
||||
case_id: caseId,
|
||||
folder,
|
||||
name,
|
||||
storage_path: path,
|
||||
mime_type: "application/pdf",
|
||||
size_bytes: blob.size,
|
||||
uploaded_by: u.user?.id ?? null,
|
||||
});
|
||||
if (insErr) throw insErr;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Route as StatusIndexRouteImport } from './routes/status.index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
|
||||
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
|
||||
import { Route as InvoicesIndexRouteImport } from './routes/invoices.index'
|
||||
import { Route as FormsIndexRouteImport } from './routes/forms.index'
|
||||
import { Route as FilesIndexRouteImport } from './routes/files.index'
|
||||
import { Route as DocumentsIndexRouteImport } from './routes/documents.index'
|
||||
import { Route as ContactsIndexRouteImport } from './routes/contacts.index'
|
||||
@@ -86,6 +87,11 @@ const InvoicesIndexRoute = InvoicesIndexRouteImport.update({
|
||||
path: '/invoices/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FormsIndexRoute = FormsIndexRouteImport.update({
|
||||
id: '/forms/',
|
||||
path: '/forms/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FilesIndexRoute = FilesIndexRouteImport.update({
|
||||
id: '/files/',
|
||||
path: '/files/',
|
||||
@@ -221,6 +227,7 @@ export interface FileRoutesByFullPath {
|
||||
'/contacts/': typeof ContactsIndexRoute
|
||||
'/documents/': typeof DocumentsIndexRoute
|
||||
'/files/': typeof FilesIndexRoute
|
||||
'/forms/': typeof FormsIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
@@ -253,6 +260,7 @@ export interface FileRoutesByTo {
|
||||
'/contacts': typeof ContactsIndexRoute
|
||||
'/documents': typeof DocumentsIndexRoute
|
||||
'/files': typeof FilesIndexRoute
|
||||
'/forms': typeof FormsIndexRoute
|
||||
'/invoices': typeof InvoicesIndexRoute
|
||||
'/messages': typeof MessagesIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
@@ -287,6 +295,7 @@ export interface FileRoutesById {
|
||||
'/contacts/': typeof ContactsIndexRoute
|
||||
'/documents/': typeof DocumentsIndexRoute
|
||||
'/files/': typeof FilesIndexRoute
|
||||
'/forms/': typeof FormsIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
@@ -322,6 +331,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/'
|
||||
| '/documents/'
|
||||
| '/files/'
|
||||
| '/forms/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/settings/'
|
||||
@@ -354,6 +364,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts'
|
||||
| '/documents'
|
||||
| '/files'
|
||||
| '/forms'
|
||||
| '/invoices'
|
||||
| '/messages'
|
||||
| '/settings'
|
||||
@@ -387,6 +398,7 @@ export interface FileRouteTypes {
|
||||
| '/contacts/'
|
||||
| '/documents/'
|
||||
| '/files/'
|
||||
| '/forms/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/settings/'
|
||||
@@ -417,6 +429,7 @@ export interface RootRouteChildren {
|
||||
ContactsIndexRoute: typeof ContactsIndexRoute
|
||||
DocumentsIndexRoute: typeof DocumentsIndexRoute
|
||||
FilesIndexRoute: typeof FilesIndexRoute
|
||||
FormsIndexRoute: typeof FormsIndexRoute
|
||||
InvoicesIndexRoute: typeof InvoicesIndexRoute
|
||||
MessagesIndexRoute: typeof MessagesIndexRoute
|
||||
StatusIndexRoute: typeof StatusIndexRoute
|
||||
@@ -492,6 +505,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InvoicesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/forms/': {
|
||||
id: '/forms/'
|
||||
path: '/forms'
|
||||
fullPath: '/forms/'
|
||||
preLoaderRoute: typeof FormsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/files/': {
|
||||
id: '/files/'
|
||||
path: '/files'
|
||||
@@ -688,6 +708,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ContactsIndexRoute: ContactsIndexRoute,
|
||||
DocumentsIndexRoute: DocumentsIndexRoute,
|
||||
FilesIndexRoute: FilesIndexRoute,
|
||||
FormsIndexRoute: FormsIndexRoute,
|
||||
InvoicesIndexRoute: InvoicesIndexRoute,
|
||||
MessagesIndexRoute: MessagesIndexRoute,
|
||||
StatusIndexRoute: StatusIndexRoute,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { ProtectedLayout } from "@/components/protected-layout";
|
||||
import { PageContainer, PageHeader } from "@/components/app-shell";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
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 { EstoppelForm } from "@/components/forms/estoppel-form";
|
||||
|
||||
export const Route = createFileRoute("/forms/")({
|
||||
component: FormsPage,
|
||||
});
|
||||
|
||||
function FormsPage() {
|
||||
const [tab, setTab] = useState("custom");
|
||||
return (
|
||||
<ProtectedLayout>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Forms & Letters"
|
||||
description="Generate notices, letters, and statutory forms wired to your clients and homeowners."
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="flex-wrap h-auto">
|
||||
<TabsTrigger value="custom">Custom Forms</TabsTrigger>
|
||||
<TabsTrigger value="letters">Letters</TabsTrigger>
|
||||
<TabsTrigger value="nola">NOLA</TabsTrigger>
|
||||
<TabsTrigger value="itl">ITL</TabsTrigger>
|
||||
<TabsTrigger value="affidavit">Affidavit</TabsTrigger>
|
||||
<TabsTrigger value="estoppel">Estoppel</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mt-6">
|
||||
<TabsContent value="custom"><CustomFormBuilder /></TabsContent>
|
||||
<TabsContent value="letters"><LetterGenerator /></TabsContent>
|
||||
<TabsContent value="nola"><NolaForm /></TabsContent>
|
||||
<TabsContent value="itl"><ItlForm /></TabsContent>
|
||||
<TabsContent value="affidavit"><AffidavitForm /></TabsContent>
|
||||
<TabsContent value="estoppel"><EstoppelForm /></TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
</ProtectedLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user