reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 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";