From 2d0cd224f2e2a856a222eb61bcce3cddc3b8d226 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:49:04 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 1205 ++++++++++++++++++++++ 1 file changed, 1205 insertions(+) create mode 100644 src/components/cases/collections-tab.tsx diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx new file mode 100644 index 0000000..bfa2873 --- /dev/null +++ b/src/components/cases/collections-tab.tsx @@ -0,0 +1,1205 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { formatCurrency, formatDate } from "@/lib/format"; +import { + ArrowLeft, + Calculator, + ChevronRight, + Loader2, + Plus, + Trash2, + UserPlus, + Users, +} from "lucide-react"; +import { toast } from "sonner"; + +const TXN_TYPES = [ + { value: "assessment", label: "Assessment" }, + { value: "late_fee", label: "Late Fee" }, + { value: "interest", label: "Interest" }, + { value: "legal_fee", label: "Legal Fee" }, + { value: "admin_fee", label: "Admin Fee" }, + { value: "violation", label: "Violation Fine" }, + { value: "payment", label: "Payment" }, + { value: "adjustment", label: "Adjustment" }, +]; + +const txnLabel = (t: string) => + TXN_TYPES.find((x) => x.value === t)?.label ?? t; + +const PAYMENT_TYPES = new Set(["payment", "adjustment"]); + +const STATUS_OPTIONS = [ + { value: "late_notice", label: "Late Notice" }, + { value: "second_notice", label: "Second Notice" }, + { value: "intent_to_lien", label: "Intent to Lien" }, + { value: "attorney", label: "Attorney" }, + { value: "payment_plan", label: "Payment Plan" }, + { value: "resolved", label: "Resolved" }, +]; + +const statusColor = (s: string) => { + switch (s) { + case "resolved": + return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300"; + case "attorney": + return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"; + case "intent_to_lien": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"; + case "payment_plan": + return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"; + case "second_notice": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"; + default: + return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"; + } +}; + +export function CaseCollectionsTab({ + caseRecord, +}: { + caseRecord: any; +}) { + const clientId = caseRecord.client_id; + const caseId = caseRecord.id; + const annualRate: number | null = + caseRecord.client?.annual_interest_rate ?? null; + + const [collections, setCollections] = useState([]); + const [homeowners, setHomeowners] = useState([]); + const [balances, setBalances] = useState>({}); + const [loading, setLoading] = useState(true); + const [activeId, setActiveId] = useState(null); + + // dialogs + const [addOpen, setAddOpen] = useState(false); + const [newHomeownerOpen, setNewHomeownerOpen] = useState(false); + + const load = async () => { + setLoading(true); + const [{ data: cols }, { data: hos }] = await Promise.all([ + supabase + .from("collections") + .select("*, homeowner:homeowners(*)") + .eq("case_id", caseId) + .order("created_at", { ascending: false }), + supabase + .from("homeowners") + .select("*") + .eq("client_id", clientId) + .order("last_name"), + ]); + const list = cols ?? []; + setCollections(list); + setHomeowners(hos ?? []); + + // pull balances per collection + if (list.length) { + const ids = list.map((c) => c.id); + const { data: ents } = await supabase + .from("collection_ledger_entries") + .select("collection_id, debit, credit") + .in("collection_id", ids); + const map: Record = {}; + list.forEach((c: any) => { + map[c.id] = Number(c.homeowner?.opening_balance ?? 0); + }); + (ents ?? []).forEach((e: any) => { + map[e.collection_id] = + (map[e.collection_id] ?? 0) + Number(e.debit) - Number(e.credit); + }); + setBalances(map); + } else { + setBalances({}); + } + setLoading(false); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [caseId, clientId]); + + if (activeId) { + const col = collections.find((c) => c.id === activeId); + if (!col) { + setActiveId(null); + return null; + } + return ( + setActiveId(null)} + onChange={load} + /> + ); + } + + return ( +
+
+
+

+ + Homeowner collections +

+

+ Each homeowner under this matter gets a ledger.{" "} + {annualRate != null ? ( + <> + Interest configured at{" "} + + {annualRate}% per annum + {" "} + (non-compounded, applied monthly). + + ) : ( + <> + No interest rate set on the HOA profile —{" "} + + add it on the client to enable auto-interest. + + + )} +

+
+
+ + +
+
+ + + + {loading ? ( +
+ Loading… +
+ ) : collections.length === 0 ? ( +
+ No homeowner collections yet on this matter. +
+ ) : ( + + + + Homeowner + Unit + Status + Opened + Balance + + + + + {collections.map((c) => { + const bal = balances[c.id] ?? 0; + return ( + setActiveId(c.id)} + > + + {c.homeowner?.last_name}, {c.homeowner?.first_name} + + + {c.homeowner?.unit_number || "—"} + + + + {STATUS_OPTIONS.find((s) => s.value === c.status) + ?.label ?? c.status} + + + + {formatDate(c.opened_at)} + + 0 + ? "text-destructive font-semibold" + : bal < 0 + ? "text-emerald-600" + : "" + }`} + > + {formatCurrency(Math.abs(bal))} + {bal < 0 ? " CR" : ""} + + + + + + ); + })} + +
+ )} +
+
+ + c.homeowner_id)} + onCreated={load} + /> + + +
+ ); +} + +// ─── Collection detail (ledger) ───────────────────────────────────── +function CollectionDetail({ + collection, + annualRate, + currentBalance, + onBack, + onChange, +}: { + collection: any; + annualRate: number | null; + currentBalance: number; + onBack: () => void; + onChange: () => void; +}) { + const { user } = useAuth(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [postOpen, setPostOpen] = useState(false); + const [interestOpen, setInterestOpen] = useState(false); + + const load = async () => { + setLoading(true); + const { data } = await supabase + .from("collection_ledger_entries") + .select("*") + .eq("collection_id", collection.id) + .order("entry_date", { ascending: true }) + .order("created_at", { ascending: true }); + setEntries(data ?? []); + setLoading(false); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collection.id]); + + const opening = Number(collection.homeowner?.opening_balance ?? 0); + + const withRunning = useMemo(() => { + let bal = opening; + return entries.map((e) => { + bal += Number(e.debit) - Number(e.credit); + return { ...e, runningBalance: bal }; + }); + }, [entries, opening]); + + // Unpaid assessment balance for interest calc — assessments minus payments+adjustments applied + const unpaidAssessmentBalance = useMemo(() => { + let assessments = 0; + let payments = 0; + entries.forEach((e) => { + const t = String(e.transaction_type || "").toLowerCase(); + const debit = Number(e.debit) || 0; + const credit = Number(e.credit) || 0; + if (PAYMENT_TYPES.has(t)) { + payments += credit - debit; + } else if (t === "assessment") { + assessments += debit - credit; + } + }); + return Math.max(0, assessments - payments); + }, [entries]); + + const updateStatus = async (status: string) => { + const { error } = await supabase + .from("collections") + .update({ status }) + .eq("id", collection.id); + if (error) toast.error("Could not update status", { description: error.message }); + else { + toast.success("Status updated"); + onChange(); + } + }; + + const removeEntry = async (id: string) => { + if (!confirm("Delete this ledger entry?")) return; + const { error } = await supabase + .from("collection_ledger_entries") + .delete() + .eq("id", id); + if (error) toast.error("Could not delete", { description: error.message }); + else { + toast.success("Entry deleted"); + load(); + onChange(); + } + }; + + const exportCSV = () => { + const rows = [ + ["Date", "Type", "Description", "Charge", "Payment", "Balance"].join(","), + ...withRunning.map((e) => + [ + e.entry_date, + txnLabel(e.transaction_type), + `"${(e.description || "").replace(/"/g, '""')}"`, + Number(e.debit).toFixed(2), + Number(e.credit).toFixed(2), + e.runningBalance.toFixed(2), + ].join(","), + ), + ].join("\n"); + const blob = new Blob([rows], { type: "text/csv" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `Ledger_${collection.homeowner?.last_name || "homeowner"}.csv`; + a.click(); + }; + + const ho = collection.homeowner; + + return ( +
+ + +
+
+

+ {ho?.first_name} {ho?.last_name} +

+

+ {ho?.unit_number ? `Unit ${ho.unit_number} · ` : ""} + {ho?.address || "No address"} + {ho?.email && ` · ${ho.email}`} +

+
+
+ + + +
+
+ +
+ + +
+ Current balance +
+
0 + ? "text-destructive font-semibold" + : currentBalance < 0 + ? "text-emerald-600" + : "" + }`} + > + {formatCurrency(Math.abs(currentBalance))} + {currentBalance < 0 ? " CR" : currentBalance > 0 ? " DUE" : ""} +
+
+
+ + +
+ Unpaid assessments +
+
+ {formatCurrency(unpaidAssessmentBalance)} +
+
+
+ + +
+ Ledger entries +
+
{entries.length}
+
+
+
+ + + + {loading ? ( +
+ Loading… +
+ ) : withRunning.length === 0 ? ( +
+ No ledger entries yet. Post an entry to get started. +
+ ) : ( + + + + Date + Type + Description + Charge + Payment + Balance + + + + + {opening !== 0 && ( + + + {formatDate(collection.opened_at)} + + + + Opening + + + + Opening balance + + + {opening > 0 ? formatCurrency(opening) : "—"} + + + {opening < 0 ? formatCurrency(-opening) : "—"} + + + {formatCurrency(Math.abs(opening))} + {opening < 0 ? " CR" : ""} + + + + )} + {withRunning.map((e) => ( + + + {formatDate(e.entry_date)} + + + + {txnLabel(e.transaction_type)} + + + + {e.description || "—"} + + + {Number(e.debit) > 0 ? ( + + {formatCurrency(Number(e.debit))} + + ) : ( + "—" + )} + + + {Number(e.credit) > 0 ? ( + + {formatCurrency(Number(e.credit))} + + ) : ( + "—" + )} + + 0 + ? "text-destructive" + : e.runningBalance < 0 + ? "text-emerald-600" + : "" + }`} + > + {formatCurrency(Math.abs(e.runningBalance))} + {e.runningBalance < 0 ? " CR" : ""} + + + + + + ))} + +
+ )} +
+
+ +
+ +
+ + { + load(); + onChange(); + }} + /> + + { + load(); + onChange(); + }} + /> +
+ ); +} + +// ─── Add collection dialog ────────────────────────────────────────── +function AddCollectionDialog({ + open, + onOpenChange, + caseId, + clientId, + homeowners, + existingHomeownerIds, + onCreated, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + caseId: string; + clientId: string; + homeowners: any[]; + existingHomeownerIds: string[]; + onCreated: () => void; +}) { + const { user } = useAuth(); + const [homeownerId, setHomeownerId] = useState(""); + const [status, setStatus] = useState("late_notice"); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setHomeownerId(""); + setStatus("late_notice"); + setNotes(""); + } + }, [open]); + + const available = homeowners.filter( + (h) => !existingHomeownerIds.includes(h.id), + ); + + const submit = async () => { + if (!homeownerId) { + toast.error("Pick a homeowner"); + return; + } + setSubmitting(true); + const { error } = await supabase.from("collections").insert({ + case_id: caseId, + homeowner_id: homeownerId, + status, + notes: notes || null, + created_by: user?.id, + }); + setSubmitting(false); + if (error) { + toast.error("Could not add", { description: error.message }); + return; + } + toast.success("Collection added"); + onOpenChange(false); + onCreated(); + }; + + return ( + + + + Add homeowner collection + + Attach a homeowner from this HOA to this matter. Each homeowner can + only have one collection per case. + + +
+
+ + {available.length === 0 ? ( +

+ {homeowners.length === 0 + ? "No homeowners yet on this HOA. Use 'New homeowner' first." + : "All homeowners on this HOA already have a collection on this matter."} +

+ ) : ( + + )} +
+
+ + +
+
+ +