From d14b0cff4261505323a06b811bb60e43ae4e5601 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:14:55 +0000 Subject: [PATCH 1/2] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/payment-plans.index.tsx | 291 +++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/routes/payment-plans.index.tsx diff --git a/src/routes/payment-plans.index.tsx b/src/routes/payment-plans.index.tsx new file mode 100644 index 0000000..7564581 --- /dev/null +++ b/src/routes/payment-plans.index.tsx @@ -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([]); + const [installments, setInstallments] = useState>({}); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("active"); + const [freqFilter, setFreqFilter] = useState("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 = {}; + (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 ( + + + + ); + }; + + return ( + + + +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + +
+ + + + {loading ? ( +
+ Loading… +
+ ) : sorted.length === 0 ? ( +
+ + No payment plans found. +
+ ) : ( + + + + + + + + + + + + + + + {sorted.map((r) => ( + + + + {r.displayName} + {r.homeowner?.unit_number && ( + + · Unit {r.homeowner.unit_number} + + )} + + {r.plan.status !== "active" && ( + + {r.plan.status} + + )} + + +
{r.client ?? "—"}
+ {r.caseRow && ( +
+ {r.caseRow.case_number} · {r.caseRow.title} +
+ )} +
+ + + {r.plan.frequency} + + + + {r.paidCount} / {r.plan.installment_count} + + + {r.next ? formatDate(r.next.due_date) : "—"} + + + {formatCurrency(r.total)} + + 0 ? "text-destructive font-semibold" : "text-emerald-600" + }`} + > + {formatCurrency(Math.max(0, r.remaining))} + + + + + + +
+ ))} +
+
+ )} +
+
+
+
+ ); +} From f0ec108548afa315f37a6076014d738065f97714 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:15:01 +0000 Subject: [PATCH 2/2] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/app-shell.tsx | 2 ++ src/routeTree.gen.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 2b5fe75..49495e4 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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 }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 59ca129..bcbba1d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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,