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] 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))} + + + + + + +
+ ))} +
+
+ )} +
+
+
+
+ ); +}