first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:48:14 +05:30
commit a1b870fa8a
242 changed files with 39483 additions and 0 deletions
@@ -0,0 +1,686 @@
"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<SubscriptionSummary[]>(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<MemberInvoice | null>(null)
const [receiptInvoice, setReceiptInvoice] = React.useState<MemberInvoice | null>(null)
const [payOpen, setPayOpen] = React.useState(false)
const [paySummary, setPaySummary] = React.useState<SubscriptionSummary | null>(null)
const [payLoadingId, setPayLoadingId] = React.useState<string | null>(null)
const [actionsOpen, setActionsOpen] = React.useState(false)
const [actionsInvoice, setActionsInvoice] = React.useState<MemberInvoice | null>(null)
const [sendingLink, setSendingLink] = React.useState(false)
const [historyOpen, setHistoryOpen] = React.useState(false)
const [historyMemberName, setHistoryMemberName] = React.useState<string | null>(null)
const [historyRows, setHistoryRows] = React.useState<MemberInvoice[] | null>(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<MemberInvoice>[] = [
{
key: "member",
header: "Member",
cell: (row) => (
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
</div>
),
},
{
key: "date",
header: "Expire Date",
cell: (row) => (
<span className="text-muted-foreground">
{row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"}
</span>
),
},
{
key: "amount",
header: "Amount",
cell: (row) => <span className="font-medium">{formatCurrency(row.amount)}</span>,
},
{
key: "paid",
header: "Paid",
cell: (row) => <span className="text-muted-foreground">{formatCurrency(row.paidAmount ?? 0)}</span>,
},
{
key: "balance",
header: "Balance",
cell: (row) => <span className="font-medium">{formatCurrency(row.balance ?? row.amount)}</span>,
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>,
},
{
key: "overdue",
header: "Days overdue",
className: "text-right",
cell: (row) => {
const d = daysOverdue(row)
return d > 0 ? <Badge variant={d > 7 ? "destructive" : "secondary"}>{d}d</Badge> : <span className="text-muted-foreground">—</span>
},
},
{
key: "actions",
header: "Actions",
className: "text-center",
cell: (row) => {
const balance = row.balance ?? row.amount
return (
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="View previous invoices"
onClick={(e) => {
e.stopPropagation()
void handleViewHistory(row)
}}
>
<HistoryIcon />
</Button>
}
/>
<TooltipContent>View previous invoices</TooltipContent>
</Tooltip>
{balance > 0 && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Add payment"
disabled={payLoadingId === row.customerInvoiceId}
onClick={(e) => {
e.stopPropagation()
void handleOpenPay(row)
}}
>
<CreditCardIcon />
</Button>
}
/>
<TooltipContent>Add payment</TooltipContent>
</Tooltip>
)}
</div>
)
},
},
]
if (!demo && invoices.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<PageHeader title="Customer Payments" description="Member invoices and recent payments" />
<Tabs defaultValue="invoices" className="gap-4">
<TabsList>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="approvals">Approvals</TabsTrigger>
</TabsList>
<TabsContent value="invoices" className="flex flex-col gap-4">
<div className={cn("grid grid-cols-2 gap-3", showTodayPayments ? "lg:grid-cols-5" : "lg:grid-cols-4")}>
<StatCard icon={ReceiptIcon} label="Invoices" value={rows.length} hint="All member invoices" />
<StatCard icon={BanknoteIcon} tone="blue" label="Total Billed" value={formatCurrency(totalBilled)} hint="All time" />
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "done" ? "all" : "done"))}
>
<StatCard
icon={CircleCheckIcon}
tone="green"
label="Done payments"
value={paid.length}
hint={formatCurrency(paid.reduce((s, i) => s + i.amount, 0))}
className={bucket === "done" ? "ring-2 ring-emerald-500" : undefined}
/>
</button>
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "overdue" ? "all" : "overdue"))}
>
<StatCard
icon={ClockIcon}
tone="amber"
label="Overdue payments"
value={outstanding.length}
hint={`${formatCurrency(outstandingBalance)} due`}
className={bucket === "overdue" ? "ring-2 ring-amber-500" : undefined}
/>
</button>
{showTodayPayments && (
<button
type="button"
className="text-left"
onClick={() => setBucket((b) => (b === "today" ? "all" : "today"))}
>
<StatCard
icon={CalendarCheckIcon}
tone="blue"
label="Today's invoices"
value={dueToday.length}
hint={formatCurrency(dueToday.reduce((s, i) => s + i.amount, 0))}
className={bucket === "today" ? "ring-2 ring-blue-500" : undefined}
/>
</button>
)}
</div>
<FilterBar
searchLabel="Search Invoices"
searchPlaceholder="Search by member or plan..."
searchValue={search}
onSearchChange={(v) => {
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)
}}
/>
<DataTable
columns={columns}
rows={pageRows}
rowKey={(row) => row.customerInvoiceId}
onRowClick={(row) => setSelected(row)}
emptyMessage="No member invoices yet."
renderMobileCard={(row) => {
const balance = row.balance ?? row.amount
return (
<div className="w-full text-left" onClick={() => setSelected(row)}>
<div className="flex items-center justify-between gap-2">
<div className="leading-tight">
<p className="font-medium">
{row.customerName ?? row.customerSubscription?.customer?.cusName ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.renewalDate ? formatDate(row.customerSubscription.renewalDate) : "—"}
</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="font-semibold">{formatCurrency(row.amount)}</span>
<StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>
</div>
</div>
<div className="mt-1 flex items-center justify-between">
{balance > 0 ? (
<p className="text-xs text-muted-foreground">{formatCurrency(balance)} balance</p>
) : (
<span />
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="View previous invoices"
onClick={(e) => {
e.stopPropagation()
void handleViewHistory(row)
}}
>
<HistoryIcon />
</Button>
{balance > 0 && (
<Button
variant="outline"
size="icon-sm"
aria-label="Add payment"
disabled={payLoadingId === row.customerInvoiceId}
onClick={(e) => {
e.stopPropagation()
void handleOpenPay(row)
}}
>
<CreditCardIcon />
</Button>
)}
</div>
</div>
</div>
)
}}
/>
<PaginationBar
page={page}
pageCount={totalPages}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size)
setPage(1)
}}
rangeStart={filtered.length ? (page - 1) * pageSize + 1 : 0}
rangeEnd={Math.min(page * pageSize, filtered.length)}
total={filtered.length}
onPageChange={setPage}
/>
</TabsContent>
<TabsContent value="approvals">
<RenewalApprovalsView demo={demo} onActionComplete={() => invoices.reload()} />
</TabsContent>
</Tabs>
<DetailPanel
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
title="Invoice"
description={selected ? formatDate(selected.customerInvoiceDate) : undefined}
>
{selected && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 py-1 text-sm">
<dt className="text-muted-foreground">Member</dt>
<dd>{selected.customerName ?? selected.customerSubscription?.customer?.cusName ?? "—"}</dd>
<dt className="text-muted-foreground">Plan</dt>
<dd>{selected.customerSubscription?.customerPlan?.planName ?? "—"}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd className="font-semibold">{formatCurrency(selected.amount)}</dd>
<dt className="text-muted-foreground">Paid</dt>
<dd>{formatCurrency(selected.paidAmount ?? 0)}</dd>
<dt className="text-muted-foreground">Balance</dt>
<dd className="font-medium">{formatCurrency(selected.balance ?? selected.amount)}</dd>
<dt className="text-muted-foreground">Status</dt>
<dd>
<StatusBadge status={statusTone(selected.status)}>{selected.status}</StatusBadge>
</dd>
<dt className="text-muted-foreground">Invoice date</dt>
<dd>{formatDateTime(selected.customerInvoiceDate)}</dd>
<dt className="text-muted-foreground">Subscription</dt>
<dd className="truncate text-xs">{selected.fkCustomerSubscriptionId}</dd>
</dl>
)}
{selected && (
<Button
className="mt-4 w-full"
variant="outline"
onClick={() => setReceiptInvoice(selected)}
>
<ReceiptIcon />
View Receipt
</Button>
)}
</DetailPanel>
<ReceiptPreviewDialog
invoice={receiptInvoice}
open={!!receiptInvoice}
onOpenChange={(o) => !o && setReceiptInvoice(null)}
demo={demo}
demoSubscriptions={demoSubs}
/>
<PaymentQuickActionsDialog
open={actionsOpen}
onOpenChange={setActionsOpen}
memberName={
actionsInvoice?.customerName ?? actionsInvoice?.customerSubscription?.customer?.cusName ?? undefined
}
amount={
suggestedInstallmentAmount ??
paySummary?.balance ??
(actionsInvoice?.balance ?? actionsInvoice?.amount ?? 0)
}
sendingLink={sendingLink}
onSendLink={() => void handleSendLink()}
onMarkPaid={handleMarkPaid}
/>
<RecordPaymentDialog
open={payOpen}
onOpenChange={setPayOpen}
onSubmit={handleRecordPayment}
balance={paySummary?.balance ?? 0}
allowSplit={paySummary?.allowSplitPayments ?? false}
suggestedAmount={suggestedInstallmentAmount}
/>
<DetailPanel
open={historyOpen}
onOpenChange={(open) => {
setHistoryOpen(open)
if (!open) setHistoryRows(null)
}}
title="Previous Invoices"
description={historyMemberName ?? undefined}
>
{historyLoading && <p className="py-4 text-sm text-muted-foreground">Loading…</p>}
{!historyLoading && historyRows && historyRows.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">No previous invoices for this member.</p>
)}
{!historyLoading && historyRows && historyRows.length > 0 && (
<ul className="flex flex-col divide-y">
{historyRows.map((row) => (
<li key={row.customerInvoiceId}>
<button
type="button"
className="flex w-full items-start justify-between gap-3 py-3 text-left"
onClick={() => {
setHistoryOpen(false)
setSelected(row)
}}
>
<div className="flex flex-col gap-0.5 leading-tight">
<p className="text-xs text-muted-foreground">
{row.customerSubscription?.customerPlan?.planName ?? "—"}
</p>
<p className="font-medium">
Invoiced {formatDate(row.customerInvoiceDate)}
</p>
<p className="text-xs text-muted-foreground">
Renews{" "}
{row.customerSubscription?.renewalDate
? formatDate(row.customerSubscription.renewalDate)
: "—"}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-sm font-medium">{formatCurrency(row.amount)}</span>
<StatusBadge status={statusTone(row.status)}>{row.status}</StatusBadge>
</div>
</button>
</li>
))}
</ul>
)}
</DetailPanel>
</div>
)
}
export { CustomerPaymentsView }