687 lines
26 KiB
TypeScript
687 lines
26 KiB
TypeScript
"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 }
|