BillingPage
surface빌링 페이지 Surface. StatCard + BarChart + DataTable 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
Billing
MRR$12,400↑ +8.2%
ARR$148,800↑ +8.2%
Customers234↑ +12
Churn Rate2.1%↓ -0.3%
Usage
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
BillingPage 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
BillingPage Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
stats* | BillingStat[] | — | 통계 카드 배열 |
usageData | ChartDataPoint[] | — | 사용량 차트 데이터 |
usageSeries | ChartSeriesDef[] | — | 사용량 시리즈 정의 |
invoices | Record<string, string>[] | — | 청구서 배열 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add billing-pageConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { BillingPage } from "@/components/surfaces/billing-page";Registry metadata
- 설명
- 빌링 페이지 Surface. StatCard + BarChart + DataTable 조합.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- charttable
- Install notes
- 없음
포함 파일
billing-page.tsxbilling-page.tsx
Surface 소스 보기
billing-page.tsx
"use client";
import * as React from "react";
import {
Badge,
DataTable,
SurfaceLayout,
SummaryRow,
TimeRangeSelector,
cn,
type ColumnDef,
type DateRange,
} from "@reopt-ai/opt-ui";
import {
BarChart,
type ChartDataPoint,
type ChartSeriesDef,
} from "@reopt-ai/opt-charts";
/** Labels for `BillingPage`. */
export interface BillingPageLabels {
billing?: string;
usage?: string;
invoices?: string;
plans?: string;
creditBreakdown?: string;
projectBreakdown?: string;
costEstimate?: string;
currentPlanLabel?: string;
recommendedPlanLabel?: string;
selectPlanLabel?: string;
emptyTitle?: string;
emptyDescription?: string;
}
const defaultLabels: Required<BillingPageLabels> = {
billing: "Billing",
usage: "Usage",
invoices: "Invoices",
plans: "Plans",
creditBreakdown: "Credit Breakdown",
projectBreakdown: "Usage by Project",
costEstimate: "Cost Estimate",
currentPlanLabel: "Current",
recommendedPlanLabel: "Recommended",
selectPlanLabel: "Select",
emptyTitle: "No billing data",
emptyDescription: "Billing information will appear here once available.",
};
/** Definition shape for `BillingPlan`. */
export interface BillingPlanDef {
id: string;
name: string;
price: string;
features: string[];
current?: boolean;
recommended?: boolean;
}
/** Props for `BillingPage`. */
export interface BillingPageProps {
stats: Array<{
id: string;
title: string;
value: string;
change: string;
trend: "up" | "down" | "neutral";
}>;
usageData?: ChartDataPoint[];
usageSeries?: ChartSeriesDef[];
invoices?: Array<Record<string, string>>;
invoiceColumns?: ColumnDef<Record<string, string>>[];
/** Credit breakdown table data */
creditBreakdown?: Array<Record<string, string>>;
creditBreakdownColumns?: ColumnDef<Record<string, string>>[];
/** Usage by project table data */
projectBreakdown?: Array<Record<string, string>>;
projectBreakdownColumns?: ColumnDef<Record<string, string>>[];
/** Cost estimate stats (second row of summary cards) */
costEstimate?: Array<{
id: string;
title: string;
value: string;
change: string;
trend: "up" | "down" | "neutral";
}>;
/** Custom gauge or usage meter content */
gaugeContent?: React.ReactNode;
/** Plan comparison cards (rendered after invoices) */
planCards?: React.ReactNode;
/** Structured plan comparison data */
plans?: BillingPlanDef[];
/** Called when a plan is selected */
onPlanSelect?: (planId: string) => void;
header?: React.ReactNode;
actions?: React.ReactNode;
loading?: boolean;
timeRange?: DateRange;
onTimeRangeChange?: (range: DateRange) => void;
labels?: BillingPageLabels;
className?: string;
}
const defaultInvoiceColumns: ColumnDef<Record<string, string>>[] = [
{ id: "date", header: "Date", accessor: "date" },
{ id: "amount", header: "Amount", accessor: "amount" },
{ id: "status", header: "Status", accessor: "status" },
];
function fingerprintBillingRow(row: Record<string, string>): string {
return Object.entries(row)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join("|");
}
function createBillingRowKeyExtractor(primaryKey: string) {
const fallbackKeys = new WeakMap<Record<string, string>, string>();
let nextFallbackId = 1;
return (row: Record<string, string>) => {
const primaryValue = row[primaryKey];
if (primaryValue) return primaryValue;
const cached = fallbackKeys.get(row);
if (cached) return cached;
const fingerprint = fingerprintBillingRow(row) || "empty";
const key = `${primaryKey}:${fingerprint}:${nextFallbackId++}`;
fallbackKeys.set(row, key);
return key;
};
}
/** Renders the `BillingPage` component. */
export function BillingPage({
stats,
usageData = [],
usageSeries = [{ dataKey: "value", name: "Usage" }],
invoices = [],
invoiceColumns = defaultInvoiceColumns,
creditBreakdown = [],
creditBreakdownColumns = [
{ id: "type", header: "Type", accessor: "type" },
{ id: "credits", header: "Credits", accessor: "credits" },
{ id: "cost", header: "Cost", accessor: "cost" },
],
projectBreakdown = [],
projectBreakdownColumns = [
{ id: "project", header: "Project", accessor: "project" },
{ id: "usage", header: "Usage", accessor: "usage" },
{ id: "percentage", header: "%", accessor: "percentage" },
],
costEstimate = [],
gaugeContent,
planCards,
plans,
onPlanSelect,
loading = false,
timeRange,
onTimeRangeChange,
header,
actions,
labels: customLabels,
className,
}: BillingPageProps) {
const labels = { ...defaultLabels, ...customLabels };
const titleId = React.useId();
const usageId = React.useId();
const costId = React.useId();
const creditId = React.useId();
const projectId = React.useId();
const invoicesId = React.useId();
const plansId = React.useId();
const creditBreakdownKeyExtractor = React.useMemo(
() => createBillingRowKeyExtractor("type"),
[],
);
const projectBreakdownKeyExtractor = React.useMemo(
() => createBillingRowKeyExtractor("project"),
[],
);
const invoiceKeyExtractor = React.useMemo(
() => createBillingRowKeyExtractor("date"),
[],
);
const isEmpty = stats.length === 0;
return (
<SurfaceLayout loading={loading} className={className}>
{header || actions ? (
<div className="flex items-center justify-between">
{header && <div>{header}</div>}
<div className="gap-element flex items-center">
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
{actions}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<h2 id={titleId} className="text-text-primary text-lg font-semibold">
{labels.billing}
</h2>
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
</div>
)}
{isEmpty ? (
<div
role="status"
className="flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
) : (
<>
<SummaryRow stats={stats} />
{gaugeContent && <div>{gaugeContent}</div>}
{costEstimate.length > 0 && (
<section aria-labelledby={costId}>
<h3
id={costId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.costEstimate}
</h3>
<SummaryRow stats={costEstimate} />
</section>
)}
{usageData.length > 0 && (
<section aria-labelledby={usageId}>
<h3
id={usageId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.usage}
</h3>
<BarChart
data={usageData}
series={usageSeries}
aria-label={labels.usage}
/>
</section>
)}
{creditBreakdown.length > 0 && (
<section aria-labelledby={creditId}>
<h3
id={creditId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.creditBreakdown}
</h3>
<DataTable
columns={creditBreakdownColumns}
data={creditBreakdown}
keyExtractor={creditBreakdownKeyExtractor}
/>
</section>
)}
{projectBreakdown.length > 0 && (
<section aria-labelledby={projectId}>
<h3
id={projectId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.projectBreakdown}
</h3>
<DataTable
columns={projectBreakdownColumns}
data={projectBreakdown}
keyExtractor={projectBreakdownKeyExtractor}
/>
</section>
)}
{invoices.length > 0 && (
<section aria-labelledby={invoicesId}>
<h3
id={invoicesId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.invoices}
</h3>
<DataTable
columns={invoiceColumns}
data={invoices}
keyExtractor={invoiceKeyExtractor}
/>
</section>
)}
{planCards && (
<section aria-labelledby={plansId}>
<h3
id={plansId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.plans}
</h3>
{planCards}
</section>
)}
{!planCards && plans && plans.length > 0 && (
<section aria-labelledby={plansId}>
<h3
id={plansId}
className="text-text-tertiary mb-2 text-sm font-medium"
>
{labels.plans}
</h3>
<div className="gap-group grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.id}
className={cn(
"border-border bg-bg-primary flex flex-col rounded-lg border p-4",
plan.current && "ring-accent ring-2",
plan.recommended &&
!plan.current &&
"border-accent-emphasis border-2",
)}
>
<div className="gap-element mb-2 flex items-center">
<h4 className="text-text-primary text-sm font-semibold">
{plan.name}
</h4>
{plan.current && (
<Badge variant="info" size="sm">
{labels.currentPlanLabel}
</Badge>
)}
{plan.recommended && !plan.current && (
<Badge variant="success" size="sm">
{labels.recommendedPlanLabel}
</Badge>
)}
</div>
<p className="text-text-primary mb-3 text-lg font-bold">
{plan.price}
</p>
<ul className="text-text-secondary mb-4 flex flex-1 flex-col gap-1 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-1.5">
<span className="text-success mt-0.5 text-xs">
✓
</span>
{feature}
</li>
))}
</ul>
{onPlanSelect && !plan.current && (
<button
type="button"
onClick={() => onPlanSelect(plan.id)}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
plan.recommended
? "bg-accent text-accent-fg hover:bg-accent-emphasis"
: "bg-bg-subtle text-text-secondary hover:bg-bg-muted",
)}
>
{labels.selectPlanLabel}
</button>
)}
</div>
))}
</div>
</section>
)}
</>
)}
</SurfaceLayout>
);
}
BillingPage.displayName = "BillingPage";