diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index f619313..dd6eca5 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 }, ]; diff --git a/src/components/forms/affidavit-form.tsx b/src/components/forms/affidavit-form.tsx new file mode 100644 index 0000000..efe7f54 --- /dev/null +++ b/src/components/forms/affidavit-form.tsx @@ -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(null); + const [clientId, setClientId] = useState(""); + const [, setFirm] = useState(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 ( +
+ + + { + setClientId(id); + setClient(c); + }} + onHomeownerChange={() => {}} + showHomeowner={false} + /> +
+
+ + setMailingDate(e.target.value)} + /> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + setCounty(e.target.value)} /> +
+
+
+
+ + setSignerName(e.target.value)} /> +
+
+ + setSignerTitle(e.target.value)} /> +
+
+ + setSignerCredentials(e.target.value)} + placeholder="e.g. LCAM" + /> +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx new file mode 100644 index 0000000..206b478 --- /dev/null +++ b/src/components/forms/custom-form-builder.tsx @@ -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(null); + const [title, setTitle] = useState("Official Notice"); + const [client, setClient] = useState(null); + const [homeowner, setHomeowner] = useState(null); + const [clientId, setClientId] = useState(""); + const [homeownerId, setHomeownerId] = useState(""); + const [firm, setFirm] = useState(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 ( +
+ + + { + setClientId(id); + setClient(c); + }} + onHomeownerChange={(id, h) => { + setHomeownerId(id); + setHomeowner(h); + }} + /> +
+ + setTitle(e.target.value)} /> +
+
+
+ +
+ + +
+ +

+ Click to insert at cursor position. +

+
+
+ + setSearch(e.target.value)} + className="pl-8 h-8 text-xs" + /> +
+ +
+ {filtered.map((v) => ( + + ))} +
+
+
+
+ + + +
+ + + + + +
+
+

Start typing here…

+
+
+
+
+
+ ); +} diff --git a/src/components/forms/estoppel-form.tsx b/src/components/forms/estoppel-form.tsx new file mode 100644 index 0000000..4376e6c --- /dev/null +++ b/src/components/forms/estoppel-form.tsx @@ -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 ( +
+ {label} + +
+ + +
+
+ + +
+
+
+ ); +} + +export function EstoppelForm() { + const [client, setClient] = useState(null); + const [clientId, setClientId] = useState(""); + const [firm, setFirm] = useState(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 ( +
+ + + { + setClientId(id); + setClient(c); + }} + onHomeownerChange={() => {}} + showHomeowner={false} + /> +
+
+ + setDate(e.target.value)} /> +
+
+ + setParcelId(e.target.value)} /> +
+
+ + setRecipientName(e.target.value)} /> +
+
+ + setPropertyAddress(e.target.value)} + /> +
+
+ + setSeller(e.target.value)} /> +
+
+ + setBuyerBank(e.target.value)} /> +
+
+
+ +