Added payment plans to sidebar
X-Lovable-Edit-ID: edt-57271c31-ce3b-4a14-9421-5d0793d8791f Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
CreditCard,
|
||||
Archive as ArchiveIcon,
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -56,6 +57,7 @@ const NAV: NavItem[] = (() => {
|
||||
{ to: "/invoices", label: "Invoices", icon: Receipt },
|
||||
{ to: "/messages", label: "Messages", icon: MessageSquare },
|
||||
{ to: "/payments", label: "Payments", icon: CreditCard },
|
||||
{ to: "/payment-plans", label: "Payment plans", icon: CalendarClock },
|
||||
{ to: "/documents", label: "Pleadings", icon: FolderOpen },
|
||||
{ to: "/reports", label: "Reports", icon: BarChart3 },
|
||||
{ to: "/status", label: "Status Updates", icon: Activity },
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Route as StatusIndexRouteImport } from './routes/status.index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
|
||||
import { Route as ReportsIndexRouteImport } from './routes/reports.index'
|
||||
import { Route as PaymentsIndexRouteImport } from './routes/payments.index'
|
||||
import { Route as PaymentPlansIndexRouteImport } from './routes/payment-plans.index'
|
||||
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
|
||||
import { Route as InvoicesIndexRouteImport } from './routes/invoices.index'
|
||||
import { Route as InboxIndexRouteImport } from './routes/inbox.index'
|
||||
@@ -113,6 +114,11 @@ const PaymentsIndexRoute = PaymentsIndexRouteImport.update({
|
||||
path: '/payments/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PaymentPlansIndexRoute = PaymentPlansIndexRouteImport.update({
|
||||
id: '/payment-plans/',
|
||||
path: '/payment-plans/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MessagesIndexRoute = MessagesIndexRouteImport.update({
|
||||
id: '/messages/',
|
||||
path: '/messages/',
|
||||
@@ -407,6 +413,7 @@ export interface FileRoutesByFullPath {
|
||||
'/inbox/': typeof InboxIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/payment-plans/': typeof PaymentPlansIndexRoute
|
||||
'/payments/': typeof PaymentsIndexRoute
|
||||
'/reports/': typeof ReportsIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
@@ -466,6 +473,7 @@ export interface FileRoutesByTo {
|
||||
'/inbox': typeof InboxIndexRoute
|
||||
'/invoices': typeof InvoicesIndexRoute
|
||||
'/messages': typeof MessagesIndexRoute
|
||||
'/payment-plans': typeof PaymentPlansIndexRoute
|
||||
'/payments': typeof PaymentsIndexRoute
|
||||
'/reports': typeof ReportsIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
@@ -527,6 +535,7 @@ export interface FileRoutesById {
|
||||
'/inbox/': typeof InboxIndexRoute
|
||||
'/invoices/': typeof InvoicesIndexRoute
|
||||
'/messages/': typeof MessagesIndexRoute
|
||||
'/payment-plans/': typeof PaymentPlansIndexRoute
|
||||
'/payments/': typeof PaymentsIndexRoute
|
||||
'/reports/': typeof ReportsIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
@@ -589,6 +598,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/payment-plans/'
|
||||
| '/payments/'
|
||||
| '/reports/'
|
||||
| '/settings/'
|
||||
@@ -648,6 +658,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox'
|
||||
| '/invoices'
|
||||
| '/messages'
|
||||
| '/payment-plans'
|
||||
| '/payments'
|
||||
| '/reports'
|
||||
| '/settings'
|
||||
@@ -708,6 +719,7 @@ export interface FileRouteTypes {
|
||||
| '/inbox/'
|
||||
| '/invoices/'
|
||||
| '/messages/'
|
||||
| '/payment-plans/'
|
||||
| '/payments/'
|
||||
| '/reports/'
|
||||
| '/settings/'
|
||||
@@ -759,6 +771,7 @@ export interface RootRouteChildren {
|
||||
InboxIndexRoute: typeof InboxIndexRoute
|
||||
InvoicesIndexRoute: typeof InvoicesIndexRoute
|
||||
MessagesIndexRoute: typeof MessagesIndexRoute
|
||||
PaymentPlansIndexRoute: typeof PaymentPlansIndexRoute
|
||||
PaymentsIndexRoute: typeof PaymentsIndexRoute
|
||||
ReportsIndexRoute: typeof ReportsIndexRoute
|
||||
StatusIndexRoute: typeof StatusIndexRoute
|
||||
@@ -839,6 +852,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PaymentsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/payment-plans/': {
|
||||
id: '/payment-plans/'
|
||||
path: '/payment-plans'
|
||||
fullPath: '/payment-plans/'
|
||||
preLoaderRoute: typeof PaymentPlansIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/messages/': {
|
||||
id: '/messages/'
|
||||
path: '/messages'
|
||||
@@ -1276,6 +1296,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
InboxIndexRoute: InboxIndexRoute,
|
||||
InvoicesIndexRoute: InvoicesIndexRoute,
|
||||
MessagesIndexRoute: MessagesIndexRoute,
|
||||
PaymentPlansIndexRoute: PaymentPlansIndexRoute,
|
||||
PaymentsIndexRoute: PaymentsIndexRoute,
|
||||
ReportsIndexRoute: ReportsIndexRoute,
|
||||
StatusIndexRoute: StatusIndexRoute,
|
||||
|
||||
@@ -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