Files
mylegal-stage-law/src/routes/collections.index.tsx
T
2026-04-20 00:24:39 +00:00

332 lines
13 KiB
TypeScript

import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { formatCurrency, formatDate } from "@/lib/format";
import { ChevronRight, Wallet, Search, ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { StatusFlagBadges } from "@/components/collections/status-flag-badges";
export const Route = createFileRoute("/collections/")({
component: CollectionsIndexPage,
});
interface Stage {
key: string;
label: string;
sort_order: number;
}
function CollectionsIndexPage() {
const [rows, setRows] = useState<any[]>([]);
const [balances, setBalances] = useState<Record<string, number>>({});
const [openTaskCounts, setOpenTaskCounts] = useState<Record<string, number>>({});
const [stages, setStages] = useState<Stage[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [stageFilter, setStageFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("open");
const [sortKey, setSortKey] = useState<"homeowner" | "case" | "stage" | "tasks" | "opened" | "balance">("opened");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const toggleSort = (key: typeof sortKey) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
};
const load = async () => {
setLoading(true);
const [{ data: cols }, { data: stgs }] = await Promise.all([
supabase
.from("collections")
.select(
"id, status, current_stage, opened_at, name, in_bankruptcy, on_payment_plan, homeowner:homeowners(id, first_name, last_name, unit_number, opening_balance), case:cases(id, case_number, title, client:clients(id, name))",
)
.order("created_at", { ascending: false }),
supabase
.from("collection_workflow_stages")
.select("*")
.order("sort_order"),
]);
const list = cols ?? [];
setRows(list);
setStages((stgs ?? []) as Stage[]);
if (list.length) {
const ids = list.map((c: any) => c.id);
const [{ data: ents }, { data: tasks }] = await Promise.all([
supabase
.from("collection_ledger_entries")
.select("collection_id, assess, late, admin, legal, viol, interest, bank, payment, debit, credit")
.in("collection_id", ids),
supabase
.from("collection_tasks")
.select("collection_id")
.in("collection_id", ids)
.eq("done", false),
]);
const balMap: Record<string, number> = {};
list.forEach((c: any) => {
balMap[c.id] = Number(c.homeowner?.opening_balance ?? 0);
});
const num = (v: any) => Number(v) || 0;
(ents ?? []).forEach((e: any) => {
const charges =
num(e.assess) + num(e.late) + num(e.admin) + num(e.legal) +
num(e.viol) + num(e.interest) + num(e.bank) + num(e.debit);
const credits = num(e.payment) + num(e.credit);
balMap[e.collection_id] = (balMap[e.collection_id] ?? 0) + charges - credits;
});
setBalances(balMap);
const taskMap: Record<string, number> = {};
(tasks ?? []).forEach((t: any) => {
taskMap[t.collection_id] = (taskMap[t.collection_id] ?? 0) + 1;
});
setOpenTaskCounts(taskMap);
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const stageLabel = (key: string | null) =>
stages.find((s) => s.key === key)?.label ?? key ?? "—";
const filtered = rows.filter((r) => {
if (statusFilter === "open" && r.status === "closed") return false;
if (statusFilter === "closed" && r.status !== "closed") return false;
if (stageFilter !== "all" && r.current_stage !== stageFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
`${r.homeowner?.first_name ?? ""} ${r.homeowner?.last_name ?? ""}`
.toLowerCase()
.includes(q) ||
(r.case?.case_number ?? "").toLowerCase().includes(q) ||
(r.case?.client?.name ?? "").toLowerCase().includes(q) ||
(r.homeowner?.unit_number ?? "").toLowerCase().includes(q)
);
});
const sorted = useMemo(() => {
const getVal = (r: any): string | number => {
switch (sortKey) {
case "homeowner":
return `${r.homeowner?.last_name ?? ""} ${r.homeowner?.first_name ?? ""}`.toLowerCase();
case "case":
return (r.case?.client?.name ?? "").toLowerCase();
case "stage":
return (stages.find((s) => s.key === r.current_stage)?.label ?? "").toLowerCase();
case "tasks":
return openTaskCounts[r.id] ?? 0;
case "opened":
return r.opened_at ? new Date(r.opened_at).getTime() : 0;
case "balance":
return balances[r.id] ?? 0;
}
};
return [...filtered].sort((a, b) => {
const av = getVal(a);
const bv = getVal(b);
if (av < bv) return sortDir === "asc" ? -1 : 1;
if (av > bv) return sortDir === "asc" ? 1 : -1;
return 0;
});
}, [filtered, sortKey, sortDir, balances, openTaskCounts, stages]);
const SortHeader = ({ k, label, className }: { k: typeof sortKey; label: string; className?: string }) => {
const Icon = sortKey !== k ? ArrowUpDown : sortDir === "asc" ? ArrowUp : ArrowDown;
return (
<TableHead className={className}>
<button
onClick={() => toggleSort(k)}
className="inline-flex items-center gap-1 hover:text-foreground"
>
{label}
<Icon className={`h-3 w-3 ${sortKey === k ? "" : "opacity-50"}`} />
</button>
</TableHead>
);
};
return (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Collections"
description="All homeowner collections across every matter."
/>
<div className="flex gap-3 mb-4 flex-wrap">
<div className="relative flex-1 min-w-[260px] max-w-md">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search homeowner, HOA, case…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open only</SelectItem>
<SelectItem value="closed">Closed only</SelectItem>
<SelectItem value="all">All statuses</SelectItem>
</SelectContent>
</Select>
<Select value={stageFilter} onValueChange={setStageFilter}>
<SelectTrigger className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{stages.map((s) => (
<SelectItem key={s.key} value={s.key}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
<div className="p-12 text-center text-muted-foreground text-sm">
Loading…
</div>
) : filtered.length === 0 ? (
<div className="p-16 text-center text-muted-foreground text-sm">
<Wallet className="h-8 w-8 mx-auto mb-3 opacity-40" />
No collections found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader k="homeowner" label="Homeowner" />
<SortHeader k="case" label="HOA / Case" />
<SortHeader k="stage" label="Stage" />
<SortHeader k="tasks" label="Open tasks" className="text-center" />
<SortHeader k="opened" label="Opened" />
<SortHeader k="balance" label="Balance" className="text-right" />
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((r) => {
const bal = balances[r.id] ?? 0;
const tcount = openTaskCounts[r.id] ?? 0;
return (
<TableRow
key={r.id}
className="cursor-pointer"
onClick={() => {}}
>
<TableCell className="font-medium">
<Link
to="/collections/$collectionId"
params={{ collectionId: r.id }}
className="hover:underline"
>
{r.homeowner ? (
<>
{r.homeowner.last_name}, {r.homeowner.first_name}
{r.homeowner.unit_number && (
<span className="text-muted-foreground font-normal ml-1">
· Unit {r.homeowner.unit_number}
</span>
)}
</>
) : (
<span className="italic text-muted-foreground">
{(r as any).name || "Unassigned"}
</span>
)}
</Link>
<StatusFlagBadges bk={r.in_bankruptcy} pp={r.on_payment_plan} className="ml-2" />
{r.status === "closed" && (
<Badge variant="outline" className="ml-2 text-[10px] border-emerald-600 text-emerald-700">
Closed
</Badge>
)}
</TableCell>
<TableCell className="text-sm">
<div>{r.case?.client?.name}</div>
<div className="text-xs text-muted-foreground">
{r.case?.case_number} · {r.case?.title}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{stageLabel(r.current_stage)}
</Badge>
</TableCell>
<TableCell className="text-center">
{tcount > 0 ? (
<Badge className="text-[10px]">{tcount}</Badge>
) : (
<span className="text-xs text-muted-foreground">
—
</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(r.opened_at)}
</TableCell>
<TableCell
className={`text-right font-mono ${
bal > 0
? "text-destructive font-semibold"
: bal < 0
? "text-emerald-600"
: ""
}`}
>
{formatCurrency(Math.abs(bal))}
{bal < 0 ? " CR" : ""}
</TableCell>
<TableCell className="text-right">
<Link
to="/collections/$collectionId"
params={{ collectionId: r.id }}
>
<ChevronRight className="h-4 w-4 text-muted-foreground inline" />
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PageContainer>
</ProtectedLayout>
);
}