Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
2c434a10ca
commit
d14b0cff42
@@ -0,0 +1,291 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user