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.

ProjectOverview

surface

프로젝트 개요 Surface. SummaryRow + NavCards + LineChart + BarChart + DataTable + CodeSnippetViewer + Dashboard CRUD 조합.

컴포넌트 의존 관계

깊이
▼ USES (9)ProjectOverviewline-chartbar-chartdata-tablecode-snippet-viewersummary-rowtime-range-selectorconfirm-dialogloading-overlaybutton
100%

기본 사용

Project Overview

Total Events1.2M↑ +12%
Active Users8,420↑ +5.3%
Sessions24.5K↓ -2.1%
Error Rate0.12%↓ -8%

Trends

Breakdown

Top Events

Data Table

EventCountChange
page_view45,200+8.2%
button_click32,100-3.1%
form_submit18,400+12.5%
api_request12,300+1.8%
error_thrown1,520-15.2%

Recent Events

Data Table

EventTimeUser
page_view2026-02-23T10:30:00Zuser_abc
button_click2026-02-23T10:29:45Zuser_def
form_submit2026-02-23T10:29:30Zuser_abc
api_request2026-02-23T10:29:15Zuser_ghi
error_thrown2026-02-23T10:29:00Zuser_jkl

Dashboards

Data Table

NameLast Updated
Marketing Overview2026-02-22T08:00:00Z
Engineering Metrics2026-02-21T16:30:00Z
Growth Dashboard2026-02-20T12:00:00Z

SDK Integration

Installation
1import posthog from 'posthog-js'
2posthog.init('phc_xxx', { api_host: 'https://app.posthog.com' })

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

ProjectOverview 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

ProjectOverview Props

Prop타입기본값설명
statsStatCardType[]—통계 카드 배열 (2-4)
navCardsProjectOverviewNavCard[]—분석 도구 내비게이션 카드
trendDataChartDataPoint[]—시계열 차트 데이터
trendSeriesChartSeriesDef[]—시계열 시리즈 정의
barDataChartDataPoint[]—바 차트 데이터
barSeriesChartSeriesDef[]—바 차트 시리즈 정의
topEventsProjectOverviewTopEvent[]—상위 이벤트 테이블
recentEventsProjectOverviewRecentEvent[]—최근 이벤트 리스트
dashboardsProjectOverviewDashboard[]—대시보드 목록
onDashboardCreate() => void—대시보드 생성 핸들러
onDashboardClick(id: string) => void—대시보드 클릭 핸들러
onDashboardDelete(id: string) => void—대시보드 삭제 핸들러
sdkSnippetstring—SDK 코드 스니펫
sdkLanguagestring"typescript"스니펫 언어
timeRangeDateRange—시간 범위 필터
onTimeRangeChange(range: DateRange) => void—시간 범위 변경 핸들러
loadingboolean—로딩 상태
headerReactNode—커스텀 헤더 슬롯
actionsReactNode—액션 버튼 슬롯
labelsProjectOverviewLabels—커스텀 레이블
classNamestring—최외곽 CSS 클래스

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add project-overview

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { ProjectOverview } from "@/components/surfaces/project-overview";

Registry metadata

설명
프로젝트 개요 Surface. SummaryRow + NavCards + LineChart + BarChart + DataTable + CodeSnippetViewer + Dashboard CRUD 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
charttabledashboard
Install notes
없음

포함 파일

  • project-overview.tsx→project-overview.tsx
Surface 소스 보기
project-overview.tsx
"use client";

import * as React from "react";
import {
  Button,
  DataTable,
  CodeSnippetViewer,
  SurfaceLayout,
  SummaryRow,
  TimeRangeSelector,
  ConfirmDialog,
  cn,
  type ColumnDef,
  type DateRange,
  type StatCardType,
} from "@reopt-ai/opt-ui";
import {
  LineChart,
  BarChart,
  type ChartDataPoint,
  type ChartSeriesDef,
} from "@reopt-ai/opt-charts";

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

/** Card data for `ProjectOverviewNav`. */
export interface ProjectOverviewNavCard {
  id: string;
  label: string;
  description?: string;
  icon?: React.ReactNode;
  href?: string;
  onClick?: () => void;
}

/** Event data for `ProjectOverviewTop`. */
export interface ProjectOverviewTopEvent {
  name: string;
  count: number;
  change?: number;
}

/** Event data for `ProjectOverviewRecent`. */
export interface ProjectOverviewRecentEvent {
  name: string;
  timestamp: string;
  userId?: string;
}

/** Dashboard data for `ProjectOverviewDashboard`. */
export interface ProjectOverviewDashboard {
  id: string;
  name: string;
  updatedAt?: string;
}

/** Labels for `ProjectOverview`. */
export interface ProjectOverviewLabels {
  overview?: string;
  analytics?: string;
  trends?: string;
  barChart?: string;
  topEvents?: string;
  recentEvents?: string;
  dashboards?: string;
  sdk?: string;
  installation?: string;
  createDashboard?: string;
  deleteDashboard?: string;
  deleteConfirmTitle?: string;
  deleteConfirmDescription?: string;
  deleteConfirmButton?: string;
  cancelButton?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  noDashboards?: string;
  eventName?: string;
  eventCount?: string;
  eventChange?: string;
  recentEventName?: string;
  recentTimestamp?: string;
  recentUserId?: string;
  dashboardName?: string;
  dashboardUpdated?: string;
  dashboardActions?: string;
}

const defaultLabels: Required<ProjectOverviewLabels> = {
  overview: "Project Overview",
  analytics: "Analysis Tools",
  trends: "Trends",
  barChart: "Breakdown",
  topEvents: "Top Events",
  recentEvents: "Recent Events",
  dashboards: "Dashboards",
  sdk: "SDK Integration",
  installation: "Installation",
  createDashboard: "Create Dashboard",
  deleteDashboard: "Delete",
  deleteConfirmTitle: "Delete Dashboard",
  deleteConfirmDescription:
    "Are you sure you want to delete this dashboard? This action cannot be undone.",
  deleteConfirmButton: "Delete",
  cancelButton: "Cancel",
  emptyTitle: "No data yet",
  emptyDescription: "Project overview data will appear here once available.",
  noDashboards: "No dashboards created yet.",
  eventName: "Event",
  eventCount: "Count",
  eventChange: "Change",
  recentEventName: "Event",
  recentTimestamp: "Time",
  recentUserId: "User",
  dashboardName: "Name",
  dashboardUpdated: "Last Updated",
  dashboardActions: "Actions",
};

/* ------------------------------------------------------------------ */
/*  Props                                                              */
/* ------------------------------------------------------------------ */

/** Props for `ProjectOverview`. */
export interface ProjectOverviewProps {
  /** Overview stat cards (2-4) */
  stats?: StatCardType[];
  /** Analysis tool navigation cards */
  navCards?: ProjectOverviewNavCard[];
  /** Time series chart data */
  trendData?: ChartDataPoint[];
  /** Series definitions for trend chart */
  trendSeries?: ChartSeriesDef[];
  /** Bar chart data */
  barData?: ChartDataPoint[];
  /** Bar chart series definitions */
  barSeries?: ChartSeriesDef[];
  /** Top events table data */
  topEvents?: ProjectOverviewTopEvent[];
  /** Recent events list */
  recentEvents?: ProjectOverviewRecentEvent[];
  /** Saved dashboards */
  dashboards?: ProjectOverviewDashboard[];
  /** Create dashboard callback */
  onDashboardCreate?: () => void;
  /** Dashboard click callback */
  onDashboardClick?: (id: string) => void;
  /** Dashboard delete callback */
  onDashboardDelete?: (id: string) => void;
  /** SDK integration code snippet */
  sdkSnippet?: string;
  /** Snippet language */
  sdkLanguage?: string;
  /** Date range value */
  timeRange?: DateRange;
  /** Date range change handler */
  onTimeRangeChange?: (range: DateRange) => void;
  /** Loading state */
  loading?: boolean;
  /** Custom header slot */
  header?: React.ReactNode;
  /** Custom actions slot */
  actions?: React.ReactNode;
  /** Custom labels */
  labels?: ProjectOverviewLabels;
  /** Root className override */
  className?: string;
}

/* ------------------------------------------------------------------ */
/*  Default columns                                                    */
/* ------------------------------------------------------------------ */

function buildTopEventColumns(
  labels: Required<ProjectOverviewLabels>,
): ColumnDef<ProjectOverviewTopEvent>[] {
  return [
    { id: "name", header: labels.eventName, accessor: "name" },
    {
      id: "count",
      header: labels.eventCount,
      accessor: (row) => row.count.toLocaleString(),
    },
    {
      id: "change",
      header: labels.eventChange,
      accessor: (row) =>
        row.change != null
          ? `${row.change > 0 ? "+" : ""}${row.change}%`
          : "\u2014",
    },
  ];
}

function buildRecentEventColumns(
  labels: Required<ProjectOverviewLabels>,
): ColumnDef<ProjectOverviewRecentEvent>[] {
  const cols: ColumnDef<ProjectOverviewRecentEvent>[] = [
    { id: "name", header: labels.recentEventName, accessor: "name" },
    { id: "timestamp", header: labels.recentTimestamp, accessor: "timestamp" },
  ];
  cols.push({
    id: "userId",
    header: labels.recentUserId,
    accessor: (row) => row.userId ?? "\u2014",
  });
  return cols;
}

/* ------------------------------------------------------------------ */
/*  Component                                                          */
/* ------------------------------------------------------------------ */

/** Renders the `ProjectOverview` component. */
export function ProjectOverview({
  stats = [],
  navCards = [],
  trendData = [],
  trendSeries = [],
  barData = [],
  barSeries = [],
  topEvents = [],
  recentEvents = [],
  dashboards = [],
  onDashboardCreate,
  onDashboardClick,
  onDashboardDelete,
  sdkSnippet,
  sdkLanguage = "typescript",
  timeRange,
  onTimeRangeChange,
  loading = false,
  header,
  actions,
  labels: customLabels,
  className,
}: ProjectOverviewProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const titleId = React.useId();
  const analyticsId = React.useId();
  const trendsId = React.useId();
  const barId = React.useId();
  const topEventsId = React.useId();
  const recentEventsId = React.useId();
  const dashboardsId = React.useId();
  const sdkId = React.useId();

  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(
    null,
  );

  const topEventColumns = React.useMemo(
    () => buildTopEventColumns(labels),
    [labels.eventName, labels.eventCount, labels.eventChange],
  );

  const recentEventColumns = React.useMemo(
    () => buildRecentEventColumns(labels),
    [labels.recentEventName, labels.recentTimestamp, labels.recentUserId],
  );

  const dashboardColumns = React.useMemo<ColumnDef<ProjectOverviewDashboard>[]>(
    () => [
      { id: "name", header: labels.dashboardName, accessor: "name" },
      {
        id: "updatedAt",
        header: labels.dashboardUpdated,
        accessor: (row) => row.updatedAt ?? "\u2014",
      },
      ...(onDashboardDelete
        ? [
            {
              id: "actions" as const,
              header: labels.dashboardActions,
              accessor: () => "",
              cell: (row: ProjectOverviewDashboard) => (
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={(e: React.MouseEvent) => {
                    e.stopPropagation();
                    setPendingDeleteId(row.id);
                  }}
                >
                  {labels.deleteDashboard}
                </Button>
              ),
            },
          ]
        : []),
    ],
    [
      labels.dashboardName,
      labels.dashboardUpdated,
      labels.dashboardActions,
      labels.deleteDashboard,
      onDashboardDelete,
    ],
  );

  const hasStats = stats.length > 0;
  const hasNavCards = navCards.length > 0;
  const hasTrend = trendData.length > 0 && trendSeries.length > 0;
  const hasBar = barData.length > 0 && barSeries.length > 0;
  const hasTopEvents = topEvents.length > 0;
  const hasRecentEvents = recentEvents.length > 0;
  const hasDashboards = dashboards.length > 0;
  const isEmpty = !hasStats && !hasNavCards && !hasTrend && !hasBar;

  const handleConfirmDelete = () => {
    if (pendingDeleteId != null) {
      onDashboardDelete?.(pendingDeleteId);
      setPendingDeleteId(null);
    }
  };

  return (
    <SurfaceLayout loading={loading} className={className}>
      {/* ---- Header ---- */}
      {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.overview}
          </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>
      ) : (
        <>
          {/* ---- Stats Cards ---- */}
          {hasStats && <SummaryRow stats={stats} columns={4} />}

          {/* ---- Navigation Cards ---- */}
          {hasNavCards && (
            <section aria-labelledby={analyticsId}>
              <h3
                id={analyticsId}
                className="text-text-tertiary mb-3 text-sm font-medium"
              >
                {labels.analytics}
              </h3>
              <nav
                aria-label={labels.analytics}
                className="gap-group grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
              >
                {navCards.map((card) => {
                  const content = (
                    <>
                      {card.icon && (
                        <span className="text-lg" aria-hidden="true">
                          {card.icon}
                        </span>
                      )}
                      <span className="text-text-primary text-sm font-medium">
                        {card.label}
                      </span>
                      {card.description && (
                        <span className="text-text-tertiary text-xs">
                          {card.description}
                        </span>
                      )}
                    </>
                  );

                  const cls =
                    "gap-element flex flex-col rounded-lg border border-border-subtle p-3 transition-colors hover:bg-bg-subtle";

                  if (card.href) {
                    return (
                      <a key={card.id} href={card.href} className={cls}>
                        {content}
                      </a>
                    );
                  }
                  return (
                    <button
                      key={card.id}
                      type="button"
                      onClick={card.onClick}
                      className={cn(cls, "text-left")}
                    >
                      {content}
                    </button>
                  );
                })}
              </nav>
            </section>
          )}

          {/* ---- Charts Section ---- */}
          {(hasTrend || hasBar) && (
            <div className="gap-section grid grid-cols-1 lg:grid-cols-2">
              {hasTrend && (
                <section aria-labelledby={trendsId}>
                  <h3
                    id={trendsId}
                    className="text-text-tertiary mb-2 text-sm font-medium"
                  >
                    {labels.trends}
                  </h3>
                  <LineChart
                    data={trendData}
                    series={trendSeries}
                    aria-label={labels.trends}
                  />
                </section>
              )}
              {hasBar && (
                <section aria-labelledby={barId}>
                  <h3
                    id={barId}
                    className="text-text-tertiary mb-2 text-sm font-medium"
                  >
                    {labels.barChart}
                  </h3>
                  <BarChart
                    data={barData}
                    series={barSeries}
                    aria-label={labels.barChart}
                  />
                </section>
              )}
            </div>
          )}

          {/* ---- Events Section ---- */}
          {(hasTopEvents || hasRecentEvents) && (
            <div className="gap-section grid grid-cols-1 lg:grid-cols-2">
              {hasTopEvents && (
                <section aria-labelledby={topEventsId}>
                  <h3
                    id={topEventsId}
                    className="text-text-tertiary mb-2 text-sm font-medium"
                  >
                    {labels.topEvents}
                  </h3>
                  <DataTable
                    columns={topEventColumns}
                    data={topEvents}
                    keyExtractor={(r) => r.name}
                  />
                </section>
              )}
              {hasRecentEvents && (
                <section aria-labelledby={recentEventsId}>
                  <h3
                    id={recentEventsId}
                    className="text-text-tertiary mb-2 text-sm font-medium"
                  >
                    {labels.recentEvents}
                  </h3>
                  <DataTable
                    columns={recentEventColumns}
                    data={recentEvents}
                    keyExtractor={(r) => `${r.name}-${r.timestamp}`}
                  />
                </section>
              )}
            </div>
          )}

          {/* ---- Dashboards Section ---- */}
          {(hasDashboards || onDashboardCreate) && (
            <section aria-labelledby={dashboardsId}>
              <div className="mb-2 flex items-center justify-between">
                <h3
                  id={dashboardsId}
                  className="text-text-tertiary text-sm font-medium"
                >
                  {labels.dashboards}
                </h3>
                {onDashboardCreate && (
                  <Button
                    variant="primary"
                    size="sm"
                    onClick={onDashboardCreate}
                  >
                    + {labels.createDashboard}
                  </Button>
                )}
              </div>
              {hasDashboards ? (
                <DataTable
                  columns={dashboardColumns}
                  data={dashboards}
                  keyExtractor={(r) => r.id}
                  onRowClick={
                    onDashboardClick
                      ? (row) => onDashboardClick(row.id)
                      : undefined
                  }
                />
              ) : (
                <p className="text-text-tertiary py-4 text-center text-sm">
                  {labels.noDashboards}
                </p>
              )}
            </section>
          )}

          {/* ---- SDK Snippet Section ---- */}
          {sdkSnippet && (
            <section aria-labelledby={sdkId}>
              <h3
                id={sdkId}
                className="text-text-tertiary mb-2 text-sm font-medium"
              >
                {labels.sdk}
              </h3>
              <CodeSnippetViewer
                code={sdkSnippet}
                language={sdkLanguage}
                title={labels.installation}
              />
            </section>
          )}
        </>
      )}

      {/* ---- Delete Dashboard Confirmation ---- */}
      <ConfirmDialog
        open={pendingDeleteId != null}
        onConfirm={handleConfirmDelete}
        onCancel={() => setPendingDeleteId(null)}
        title={labels.deleteConfirmTitle}
        description={labels.deleteConfirmDescription}
        confirmLabel={labels.deleteConfirmButton}
        cancelLabel={labels.cancelButton}
        variant="danger"
      />
    </SurfaceLayout>
  );
}

ProjectOverview.displayName = "ProjectOverview";