reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

BillingPage

surface

빌링 페이지 Surface. StatCard + BarChart + DataTable 조합.

컴포넌트 의존 관계

깊이
▼ USES (5)BillingPagebar-chartdata-tableloading-overlaysummary-rowtime-range-selector
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[]—통계 카드 배열
usageDataChartDataPoint[]—사용량 차트 데이터
usageSeriesChartSeriesDef[]—사용량 시리즈 정의
invoicesRecord<string, string>[]—청구서 배열

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add billing-page

Consumer 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.tsx→billing-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">
                            &#10003;
                          </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";