first commit
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user