Files
mylegal-stage-law/src/routes/payment-plans.index.tsx
T
2026-04-26 08:14:55 +00:00

292 lines
12 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, CalendarClock, Search, ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
export const Route = createFileRoute("/payment-plans/")({
component: PaymentPlansIndexPage,
});
function PaymentPlansIndexPage() {
const [rows, setRows] = useState<any[]>([]);
const [installments, setInstallments] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("active");
const [freqFilter, setFreqFilter] = useState<string>("all");
const [sortKey, setSortKey] = useState<
"name" | "case" | "frequency" | "next" | "progress" | "total" | "remaining" | "started"
>("started");
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: plans } = await supabase
.from("payment_plans")
.select(
"id, name, status, total_amount, down_payment, installment_count, installment_amount, frequency, start_date, created_at, case_id, collection_id, case:cases(id, case_number, title, client:clients(id, name)), collection:collections(id, case_id, homeowner:homeowners(id, first_name, last_name, unit_number), case:cases(id, case_number, title, client:clients(id, name)))",
)
.order("created_at", { ascending: false });
const list = plans ?? [];
setRows(list);
if (list.length) {
const ids = list.map((p: any) => p.id);
const { data: inst } = await supabase
.from("payment_plan_installments")
.select("plan_id, amount, paid, paid_amount, due_date")
.in("plan_id", ids)
.order("due_date", { ascending: true });
const map: Record<string, any[]> = {};
(inst ?? []).forEach((i: any) => {
(map[i.plan_id] ||= []).push(i);
});
setInstallments(map);
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const computed = useMemo(() => {
return rows.map((p: any) => {
const ins = installments[p.id] ?? [];
const total = Number(p.total_amount) || 0;
const paidAmt = ins.reduce((s, i) => s + (i.paid ? Number(i.paid_amount ?? i.amount) || 0 : 0), 0);
const remaining = total - paidAmt;
const paidCount = ins.filter((i) => i.paid).length;
const next = ins.find((i) => !i.paid);
const homeowner = p.collection?.homeowner;
const client =
p.collection?.case?.client?.name ?? p.case?.client?.name ?? null;
const caseRow = p.collection?.case ?? p.case ?? null;
const displayName =
p.name ||
(homeowner ? `${homeowner.last_name}, ${homeowner.first_name}` : null) ||
(caseRow ? `${caseRow.case_number} · ${caseRow.title}` : "Plan");
const linkTo: { to: any; params: any } = p.collection_id
? { to: "/collections/$collectionId", params: { collectionId: p.collection_id } }
: { to: "/cases/$caseId", params: { caseId: p.case_id } };
return { plan: p, ins, total, paidAmt, remaining, paidCount, next, homeowner, client, caseRow, displayName, linkTo };
});
}, [rows, installments]);
const filtered = computed.filter((r) => {
if (statusFilter !== "all" && r.plan.status !== statusFilter) return false;
if (freqFilter !== "all" && r.plan.frequency !== freqFilter) return false;
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
(r.displayName ?? "").toLowerCase().includes(q) ||
(r.client ?? "").toLowerCase().includes(q) ||
(r.caseRow?.case_number ?? "").toLowerCase().includes(q) ||
(r.caseRow?.title ?? "").toLowerCase().includes(q)
);
});
const sorted = useMemo(() => {
const getVal = (r: typeof filtered[number]): string | number => {
switch (sortKey) {
case "name":
return (r.displayName ?? "").toLowerCase();
case "case":
return (r.client ?? "").toLowerCase();
case "frequency":
return r.plan.frequency ?? "";
case "next":
return r.next?.due_date ? new Date(r.next.due_date).getTime() : Infinity;
case "progress":
return r.plan.installment_count ? r.paidCount / r.plan.installment_count : 0;
case "total":
return r.total;
case "remaining":
return r.remaining;
case "started":
return r.plan.start_date ? new Date(r.plan.start_date).getTime() : 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]);
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="Payment plans"
description="All installment plans 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 plan, 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="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="all">All statuses</SelectItem>
</SelectContent>
</Select>
<Select value={freqFilter} onValueChange={setFreqFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All frequencies</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem>
<SelectItem value="monthly">Monthly</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>
) : sorted.length === 0 ? (
<div className="p-16 text-center text-muted-foreground text-sm">
<CalendarClock className="h-8 w-8 mx-auto mb-3 opacity-40" />
No payment plans found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader k="name" label="Plan / Homeowner" />
<SortHeader k="case" label="HOA / Case" />
<SortHeader k="frequency" label="Frequency" />
<SortHeader k="progress" label="Progress" className="text-center" />
<SortHeader k="next" label="Next due" />
<SortHeader k="total" label="Total" className="text-right" />
<SortHeader k="remaining" label="Remaining" className="text-right" />
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((r) => (
<TableRow key={r.plan.id} className="cursor-pointer">
<TableCell className="font-medium">
<Link
{...r.linkTo}
className="hover:underline"
>
{r.displayName}
{r.homeowner?.unit_number && (
<span className="text-muted-foreground font-normal ml-1">
· Unit {r.homeowner.unit_number}
</span>
)}
</Link>
{r.plan.status !== "active" && (
<Badge variant="outline" className="ml-2 text-[10px] capitalize">
{r.plan.status}
</Badge>
)}
</TableCell>
<TableCell className="text-sm">
<div>{r.client ?? "—"}</div>
{r.caseRow && (
<div className="text-xs text-muted-foreground">
{r.caseRow.case_number} · {r.caseRow.title}
</div>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] capitalize">
{r.plan.frequency}
</Badge>
</TableCell>
<TableCell className="text-center text-sm">
{r.paidCount} / {r.plan.installment_count}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.next ? formatDate(r.next.due_date) : "—"}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(r.total)}
</TableCell>
<TableCell
className={`text-right font-mono ${
r.remaining > 0 ? "text-destructive font-semibold" : "text-emerald-600"
}`}
>
{formatCurrency(Math.max(0, r.remaining))}
</TableCell>
<TableCell className="text-right">
<Link {...r.linkTo}>
<ChevronRight className="h-4 w-4 text-muted-foreground inline" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PageContainer>
</ProtectedLayout>
);
}