PaymentHistory
Displays payment records in a sortable table with filtering by status and date range. Shows summary cards with total revenue, refunded amounts, and net revenue calculations.
Install
Section titled “Install”npx thebookingkit add payment-historyimport { PaymentHistory, type PaymentDisplayRecord } from "@/components/payment-history";import { useRouter } from "next/navigation";import { useEffect, useState } from "react";
export function PaymentDashboard() { const router = useRouter(); const [payments, setPayments] = useState<PaymentDisplayRecord[]>([]);
useEffect(() => { // Fetch payments from API fetch("/api/payments") .then((res) => res.json()) .then((data) => setPayments(data)); }, []);
return ( <PaymentHistory payments={payments} onPaymentClick={(payment) => console.log(payment)} onBookingClick={(bookingId) => router.push(`/bookings/${bookingId}`)} /> );}export interface PaymentDisplayRecord { id: string; bookingId: string; customerName?: string; customerEmail?: string; amountCents: number; currency: string; status: "pending" | "succeeded" | "failed" | "refunded" | "partially_refunded"; paymentType: "prepayment" | "no_show_hold" | "cancellation_fee"; refundAmountCents: number; createdAt: Date;}
export interface PaymentHistoryProps { /** Payment records to display */ payments: PaymentDisplayRecord[]; /** Called when a row is clicked */ onPaymentClick?: (payment: PaymentDisplayRecord) => void; /** Called when the booking link is clicked */ onBookingClick?: (bookingId: string) => void; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}Source
Section titled “Source”import React, { useMemo, useState } from "react";import { cn } from "../utils/cn.js";
/** A payment record for display */export interface PaymentDisplayRecord { id: string; bookingId: string; customerName?: string; customerEmail?: string; amountCents: number; currency: string; status: "pending" | "succeeded" | "failed" | "refunded" | "partially_refunded"; paymentType: "prepayment" | "no_show_hold" | "cancellation_fee"; refundAmountCents: number; createdAt: Date;}
/** Props for the PaymentHistory component */export interface PaymentHistoryProps { /** Payment records to display */ payments: PaymentDisplayRecord[]; /** Called when a row is clicked */ onPaymentClick?: (payment: PaymentDisplayRecord) => void; /** Called when the booking link is clicked */ onBookingClick?: (bookingId: string) => void; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}
/** * Payment history table for the provider dashboard. * * Displays payment records with filtering by status and date range, * and summary totals for revenue and refunds. * * @example * ```tsx * <PaymentHistory * payments={payments} * onBookingClick={(id) => router.push(`/bookings/${id}`)} * /> * ``` */export function PaymentHistory({ payments, onPaymentClick, onBookingClick, className, style,}: PaymentHistoryProps) { const [statusFilter, setStatusFilter] = useState<string>("all"); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState("");
const filteredPayments = useMemo(() => { let result = payments;
if (statusFilter !== "all") { result = result.filter((p) => p.status === statusFilter); }
if (dateFrom) { const from = new Date(dateFrom); result = result.filter((p) => p.createdAt >= from); }
if (dateTo) { const to = new Date(dateTo); to.setHours(23, 59, 59, 999); result = result.filter((p) => p.createdAt <= to); }
return result; }, [payments, statusFilter, dateFrom, dateTo]);
const summary = useMemo(() => { let totalRevenue = 0; let totalRefunded = 0;
for (const p of filteredPayments) { if (p.status === "succeeded" || p.status === "partially_refunded") { totalRevenue += p.amountCents; } totalRefunded += p.refundAmountCents; }
return { totalRevenue, totalRefunded, netRevenue: totalRevenue - totalRefunded, count: filteredPayments.length, }; }, [filteredPayments]);
const currency = payments[0]?.currency ?? "USD";
return ( <div className={cn("tbk-payment-history", className)} style={style} > {/* Summary cards */} <div className="tbk-payment-summary"> <div className="tbk-summary-card"> <span className="tbk-summary-label">Total Revenue</span> <span className="tbk-summary-value"> {formatAmount(summary.totalRevenue, currency)} </span> </div> <div className="tbk-summary-card"> <span className="tbk-summary-label">Total Refunded</span> <span className="tbk-summary-value"> {formatAmount(summary.totalRefunded, currency)} </span> </div> <div className="tbk-summary-card"> <span className="tbk-summary-label">Net Revenue</span> <span className="tbk-summary-value"> {formatAmount(summary.netRevenue, currency)} </span> </div> </div>
{/* Filters */} <div className="tbk-payment-filters"> <div className="tbk-filter-group"> <label htmlFor="payment-status-filter" className="tbk-label"> Status </label> <select id="payment-status-filter" className="tbk-select" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} > <option value="all">All</option> <option value="succeeded">Completed</option> <option value="refunded">Refunded</option> <option value="partially_refunded">Partially Refunded</option> <option value="pending">Pending</option> <option value="failed">Failed</option> </select> </div>
<div className="tbk-filter-group"> <label htmlFor="payment-date-from" className="tbk-label"> From </label> <input id="payment-date-from" type="date" className="tbk-input" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} /> </div>
<div className="tbk-filter-group"> <label htmlFor="payment-date-to" className="tbk-label"> To </label> <input id="payment-date-to" type="date" className="tbk-input" value={dateTo} onChange={(e) => setDateTo(e.target.value)} /> </div> </div>
{/* Table */} <div className="tbk-payment-table-wrapper"> <table className="tbk-payment-table"> <thead> <tr> <th>Date</th> <th>Customer</th> <th>Type</th> <th>Amount</th> <th>Status</th> <th>Booking</th> </tr> </thead> <tbody> {filteredPayments.length === 0 ? ( <tr> <td colSpan={6} className="tbk-empty-row"> No payments found. </td> </tr> ) : ( filteredPayments.map((payment) => ( <tr key={payment.id} className="tbk-payment-row" onClick={() => onPaymentClick?.(payment)} role={onPaymentClick ? "button" : undefined} tabIndex={onPaymentClick ? 0 : undefined} > <td>{payment.createdAt.toLocaleDateString()}</td> <td> {payment.customerName ?? payment.customerEmail ?? "—"} </td> <td> <span className="tbk-payment-type-badge"> {formatPaymentType(payment.paymentType)} </span> </td> <td>{formatAmount(payment.amountCents, payment.currency)}</td> <td> <span className={cn( "tbk-payment-status-badge", `tbk-payment-status-${payment.status}`, )} > {formatStatus(payment.status)} </span> </td> <td> {onBookingClick ? ( <button className="tbk-link-button" onClick={(e) => { e.stopPropagation(); onBookingClick(payment.bookingId); }} > View </button> ) : ( payment.bookingId.slice(0, 8) )} </td> </tr> )) )} </tbody> </table> </div> </div> );}
function formatAmount(amountCents: number, currency: string): string { const amount = amountCents / 100; try { return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), }).format(amount); } catch { return `${currency.toUpperCase()} ${amount.toFixed(2)}`; }}
function formatPaymentType(type: string): string { switch (type) { case "prepayment": return "Prepayment"; case "no_show_hold": return "No-Show Hold"; case "cancellation_fee": return "Cancellation Fee"; default: return type; }}
function formatStatus(status: string): string { switch (status) { case "succeeded": return "Completed"; case "partially_refunded": return "Partial Refund"; case "refunded": return "Refunded"; case "pending": return "Pending"; case "failed": return "Failed"; default: return status; }}