"use client" import * as React from "react" import { BanknoteIcon, CalendarCheckIcon, CircleCheckIcon, ClockIcon, CreditCardIcon, HistoryIcon, ReceiptIcon } from "lucide-react" import { toast } from "sonner" import { getCustomerInvoiceHistory, getMemberInvoices, sendPaymentLink } from "@/lib/api/invoices" import { getSubscription, recordSubscriptionPayment } from "@/lib/api/subscriptions" import { getStatSummary } from "@/lib/api/stats" import { formatCurrency, formatDate, formatDateTime } from "@/lib/format" import { cn } from "@/lib/utils" import { sortByNewest } from "@/lib/validation" import { DEMO_GYM_PLANS, DEMO_MEMBER_PROFILES, DEMO_SUBSCRIPTIONS } from "@/lib/demo/mock-data" import type { MemberProfile } from "@/lib/models/customer" import type { GymPlan } from "@/lib/models/gym-plan" import type { MemberInvoice } from "@/lib/models/invoice" import type { SubscriptionSummary } from "@/lib/models/subscription" import { useFetch } from "@/lib/use-fetch" import { runMutation } from "@/lib/use-mutation" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { DataTable, type DataTableColumn } from "@/components/hexa/data-table" import { DetailPanel } from "@/components/hexa/detail-panel" import { PaymentQuickActionsDialog } from "@/components/hexa/views/payment-quick-actions-dialog" import { ReceiptPreviewDialog } from "@/components/hexa/views/receipt-preview-dialog" import { RecordPaymentDialog } from "@/components/hexa/views/record-payment-dialog" import { RenewalApprovalsView } from "@/components/hexa/views/renewal-approvals-view" import { FilterBar } from "@/components/hexa/filter-bar" import { PageHeader } from "@/components/hexa/page-header" import { PageSkeleton } from "@/components/hexa/page-skeleton" import { PaginationBar } from "@/components/hexa/pagination-bar" import { StatCard } from "@/components/hexa/stat-card" import { StatusBadge } from "@/components/hexa/status-badge" function statusTone(status: string): "success" | "warning" | "danger" | "neutral" { const s = status.toLowerCase() if (s === "paid" || s === "success" || s === "approved") return "success" if (s === "partial") return "warning" if (s === "pending") return "warning" if (s === "failed" || s === "overdue") return "danger" return "neutral" } /** Same calendar day as "now", in the viewer's local timezone. */ function isToday(iso: string): boolean { const d = new Date(iso) const now = new Date() return d.toDateString() === now.toDateString() } function daysOverdue(invoice: MemberInvoice): number { const balance = invoice.balance ?? invoice.amount if (balance <= 0) return 0 const ms = Date.now() - new Date(invoice.customerInvoiceDate).getTime() return Math.max(0, Math.floor(ms / (24 * 3600 * 1000))) } /** Derives invoice rows straight from the demo subscription table, mirroring GET client/member-invoices. */ function buildDemoInvoiceRows(subscriptions: SubscriptionSummary[], profiles: MemberProfile[], gymPlans: GymPlan[]): MemberInvoice[] { return subscriptions.map((sub) => { const primary = sub.members.find((m) => m.isPrimary) ?? sub.members[0] const customer = profiles.find((c) => c.cusId === primary?.customerId) ?? null const plan = gymPlans.find((p) => p.customerPlanId === sub.customerPlanId) ?? null return { customerInvoiceId: sub.customerInvoiceId ?? `inv-${sub.subscriptionId}`, fkCustomerSubscriptionId: sub.subscriptionId, fkClientId: "demo-gym-pulse-fitness", amount: sub.invoiceAmount, status: sub.paymentStatus, isActive: true, paidAmount: sub.paidAmount, balance: sub.balance, customerInvoiceDate: `${sub.onboardDate}T09:00:00+05:30`, customerSubscription: { subscriptionId: sub.subscriptionId, onboardDate: sub.onboardDate, expiryDate: sub.expiryDate, renewalDate: sub.renewalDate, customer, customerPlan: plan, }, } }) } interface CustomerPaymentsViewProps { /** Sandbox demo mode: local in-memory data, no API calls. */ demo?: boolean } function CustomerPaymentsView({ demo = false }: CustomerPaymentsViewProps) { // scope=latest: one row per customer (their latest approved invoice) -- older cycles // (Paid/RolledOver/Cancelled) stay reachable via "view previous invoices" per row. const invoices = useFetch(demo ? async () => [] as MemberInvoice[] : () => getMemberInvoices("latest")) const [demoSubs, setDemoSubs] = React.useState(DEMO_SUBSCRIPTIONS) // Muscle Beast-only pilot ("Today's payments") -- gated server-side, same summary endpoint // the dashboard already uses to decide whether to show it. const summary = useFetch(demo ? async () => null : () => getStatSummary()) const showTodayPayments = summary.data?.showTodayPayments ?? false const [search, setSearch] = React.useState("") const [status, setStatus] = React.useState("all") const [bucket, setBucket] = React.useState<"all" | "overdue" | "done" | "today">("all") const [page, setPage] = React.useState(1) const [pageSize, setPageSize] = React.useState(10) const [selected, setSelected] = React.useState(null) const [receiptInvoice, setReceiptInvoice] = React.useState(null) const [payOpen, setPayOpen] = React.useState(false) const [paySummary, setPaySummary] = React.useState(null) const [payLoadingId, setPayLoadingId] = React.useState(null) const [actionsOpen, setActionsOpen] = React.useState(false) const [actionsInvoice, setActionsInvoice] = React.useState(null) const [sendingLink, setSendingLink] = React.useState(false) const [historyOpen, setHistoryOpen] = React.useState(false) const [historyMemberName, setHistoryMemberName] = React.useState(null) const [historyRows, setHistoryRows] = React.useState(null) const [historyLoading, setHistoryLoading] = React.useState(false) // The next installment still genuinely owing -- not just the first one not yet marked "Paid" // (which can be "Partial" with some of it already paid off) and not its original `amount` // (bug: that over-large value passed the overall-balance check on submit but got rejected by // the backend's per-installment balance check). `> 0` also guards against suggesting Rs. 0 for // an installment that's fully paid but not yet flagged "Paid". const nextDueInstallment = paySummary?.installments.find( (i) => i.status !== "Paid" && i.amount - i.paidAmount > 0 ) const suggestedInstallmentAmount = nextDueInstallment ? Math.max(0, nextDueInstallment.amount - nextDueInstallment.paidAmount) : undefined const rows = React.useMemo( () => demo ? sortByNewest(buildDemoInvoiceRows(demoSubs, DEMO_MEMBER_PROFILES, DEMO_GYM_PLANS), (r) => r.customerInvoiceDate) : sortByNewest(invoices.data ?? [], (r) => r.customerInvoiceDate), [demo, demoSubs, invoices.data] ) const filtered = rows.filter((inv) => { const memberName = inv.customerName ?? inv.customerSubscription?.customer?.cusName ?? "" const planName = inv.customerSubscription?.customerPlan?.planName ?? "" const matchesSearch = !search || memberName.toLowerCase().includes(search.toLowerCase()) || planName.toLowerCase().includes(search.toLowerCase()) const matchesStatus = status === "all" || inv.status.toLowerCase() === status const matchesBucket = bucket === "all" ? true : bucket === "overdue" ? (inv.balance ?? inv.amount) > 0 : bucket === "today" ? isToday(inv.customerInvoiceDate) : inv.status.toLowerCase() === "paid" return matchesSearch && matchesStatus && matchesBucket }) const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1) const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize) const totalBilled = rows.reduce((sum, i) => sum + i.amount, 0) const paid = rows.filter((i) => i.status.toLowerCase() === "paid") // "Outstanding" = anything not fully paid (pending + partial). const outstanding = rows.filter((i) => { const s = i.status.toLowerCase() return s === "pending" || s === "partial" }) const dueToday = rows.filter((i) => isToday(i.customerInvoiceDate)) const outstandingBalance = outstanding.reduce((s, i) => s + (i.balance ?? i.amount), 0) async function handleOpenPay(row: MemberInvoice) { setPayLoadingId(row.customerInvoiceId) if (demo) { const summary = demoSubs.find((s) => s.subscriptionId === row.fkCustomerSubscriptionId) ?? null if (summary) { setPaySummary(summary) setActionsInvoice(row) setActionsOpen(true) } setPayLoadingId(null) return } try { const summary = await getSubscription(row.fkCustomerSubscriptionId) setPaySummary(summary) setActionsInvoice(row) setActionsOpen(true) } catch (err) { toast.error(err instanceof Error ? err.message : "Couldn't load this subscription.") } finally { setPayLoadingId(null) } } async function handleViewHistory(row: MemberInvoice) { const customerId = row.fkCustomerId const name = row.customerName ?? row.customerSubscription?.customer?.cusName ?? "this member" setHistoryMemberName(name) setHistoryOpen(true) setHistoryRows(null) if (demo || !customerId) { setHistoryRows([]) return } setHistoryLoading(true) try { setHistoryRows(await getCustomerInvoiceHistory(customerId)) } catch (err) { toast.error(err instanceof Error ? err.message : "Couldn't load previous invoices.") setHistoryRows([]) } finally { setHistoryLoading(false) } } async function handleSendLink() { if (!actionsInvoice || sendingLink) return setSendingLink(true) if (demo) { toast.success("Payment link sent to the member.") setActionsOpen(false) setSendingLink(false) return } try { await runMutation(() => sendPaymentLink(actionsInvoice.customerInvoiceId)) toast.success("Payment link sent to the member.") setActionsOpen(false) } catch { // runMutation() already toasted the real error. } finally { setSendingLink(false) } } function handleMarkPaid() { setActionsOpen(false) setPayOpen(true) } async function handleRecordPayment( amount: number, note: string, paymentMethod: "Cash" | "Bank", bankRefNo: string ) { if (!paySummary) return if (demo) { setDemoSubs((prev) => prev.map((s) => { if (s.subscriptionId !== paySummary.subscriptionId) return s const paidAmount = s.paidAmount + amount const balance = Math.max(s.invoiceAmount - paidAmount, 0) return { ...s, paidAmount, balance, paymentStatus: balance <= 0 ? "Paid" : "Partial" } }) ) toast.success("Payment recorded") return } await recordSubscriptionPayment(paySummary.subscriptionId, { amount, note, paymentMethod, bankRefNo }) toast.success("Payment recorded") invoices.reload() } const columns: DataTableColumn[] = [ { key: "member", header: "Member", cell: (row) => (

{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}

{row.customerSubscription?.customerPlan?.planName ?? "—"}

), }, { key: "date", header: "Expire Date", cell: (row) => ( {row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"} ), }, { key: "amount", header: "Amount", cell: (row) => {formatCurrency(row.amount)}, }, { key: "paid", header: "Paid", cell: (row) => {formatCurrency(row.paidAmount ?? 0)}, }, { key: "balance", header: "Balance", cell: (row) => {formatCurrency(row.balance ?? row.amount)}, }, { key: "status", header: "Status", cell: (row) => {row.status}, }, { key: "overdue", header: "Days overdue", className: "text-right", cell: (row) => { const d = daysOverdue(row) return d > 0 ? 7 ? "destructive" : "secondary"}>{d}d : — }, }, { key: "actions", header: "Actions", className: "text-center", cell: (row) => { const balance = row.balance ?? row.amount return (
{ e.stopPropagation() void handleViewHistory(row) }} > } /> View previous invoices {balance > 0 && ( { e.stopPropagation() void handleOpenPay(row) }} > } /> Add payment )}
) }, }, ] if (!demo && invoices.loading) { return } return (
Invoices Approvals
{showTodayPayments && ( )}
{ setSearch(v) setPage(1) }} filters={[ { key: "status", label: "Status", value: status, placeholder: "All Statuses", options: [ { label: "All Statuses", value: "all" }, { label: "Paid", value: "paid" }, { label: "Partial", value: "partial" }, { label: "Pending", value: "pending" }, { label: "Failed", value: "failed" }, ], onChange: (v) => { setStatus(v) setPage(1) }, }, ]} resultsShowing={pageRows.length} resultsTotal={filtered.length} onClear={() => { setSearch("") setStatus("all") setBucket("all") setPage(1) }} /> row.customerInvoiceId} onRowClick={(row) => setSelected(row)} emptyMessage="No member invoices yet." renderMobileCard={(row) => { const balance = row.balance ?? row.amount return (
setSelected(row)}>

{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}

{row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"}

{formatCurrency(row.amount)} {row.status}
{balance > 0 ? (

{formatCurrency(balance)} balance

) : ( )}
{balance > 0 && ( )}
) }} /> { setPageSize(size) setPage(1) }} rangeStart={filtered.length ? (page - 1) * pageSize + 1 : 0} rangeEnd={Math.min(page * pageSize, filtered.length)} total={filtered.length} onPageChange={setPage} />
invoices.reload()} />
!open && setSelected(null)} title="Invoice" description={selected ? formatDate(selected.customerInvoiceDate) : undefined} > {selected && (
Member
{selected.customerName ?? selected.customerSubscription?.customer?.cusName ?? "—"}
Plan
{selected.customerSubscription?.customerPlan?.planName ?? "—"}
Amount
{formatCurrency(selected.amount)}
Paid
{formatCurrency(selected.paidAmount ?? 0)}
Balance
{formatCurrency(selected.balance ?? selected.amount)}
Status
{selected.status}
Invoice date
{formatDateTime(selected.customerInvoiceDate)}
Subscription
{selected.fkCustomerSubscriptionId}
)} {selected && ( )}
!o && setReceiptInvoice(null)} demo={demo} demoSubscriptions={demoSubs} /> void handleSendLink()} onMarkPaid={handleMarkPaid} /> { setHistoryOpen(open) if (!open) setHistoryRows(null) }} title="Previous Invoices" description={historyMemberName ?? undefined} > {historyLoading &&

Loading…

} {!historyLoading && historyRows && historyRows.length === 0 && (

No previous invoices for this member.

)} {!historyLoading && historyRows && historyRows.length > 0 && (
    {historyRows.map((row) => (
  • ))}
)}
) } export { CustomerPaymentsView }