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.

CustomDashboard

surface

커스텀 대시보드 Surface. WidgetGrid + ReportBuilder + ReportWidget + FilterBar 조합.

컴포넌트 의존 관계

깊이
▼ USES (8)CustomDashboardwidget-gridreport-builderreport-widgetfilter-barloading-overlaytime-range-selectorbuttondialog
100%

기본 사용

Dashboard

Filters
Dashboard

Total Revenue

$124,500↑+12%

Active Users

8,432↑+5%

Weekly Trend

Loading chart...

Top Events

Loading chart...

테스트 커버리지

2026년 2월 4일

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

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

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

CustomDashboard Props

Prop타입기본값설명
widgets*ReportWidgetConfig[]—위젯 설정 배열
onWidgetsChange*(widgets: ReportWidgetConfig[]) => void—위젯 변경 핸들러
onSave(widgets: ReportWidgetConfig[]) => void—저장 핸들러
onDeleteWidget(index: number) => void—위젯 삭제 핸들러
editingboolean—제어 모드: 편집 상태
onEditingChange(editing: boolean) => void—편집 상태 변경 핸들러
filtersFilterGroupDef[]—글로벌 필터 (날짜 범위, 세그먼트 등)

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add custom-dashboard

Consumer target

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

tsx
import { CustomDashboard } from "@/components/surfaces/custom-dashboard";

Registry metadata

설명
커스텀 대시보드 Surface. WidgetGrid + ReportBuilder + ReportWidget + FilterBar 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
filterdashboard
Install notes
없음

포함 파일

  • custom-dashboard.tsx→custom-dashboard.tsx
Surface 소스 보기
custom-dashboard.tsx
"use client";

import * as React from "react";
import {
  WidgetGrid,
  ReportBuilder,
  Button,
  FilterBar,
  DialogRoot,
  DialogPanel,
  DialogDismiss,
  ConfirmDialog,
  SurfaceLayout,
  TimeRangeSelector,
  type DateRange,
  type WidgetDef,
  type FilterGroupDef,
} from "@reopt-ai/opt-ui";
import { ReportWidget, type ReportWidgetConfig } from "@reopt-ai/opt-charts";

/** Labels for `CustomDashboard`. */
export interface CustomDashboardLabels {
  dashboard?: string;
  addWidget?: string;
  edit?: string;
  done?: string;
  save?: string;
  filtersLabel?: string;
  cancelButton?: string;
  discardButton?: string;
  saveExitButton?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  unsavedTitle?: string;
  unsavedDescription?: string;
}

const defaultLabels: Required<CustomDashboardLabels> = {
  dashboard: "Dashboard",
  addWidget: "Add Widget",
  edit: "Edit",
  done: "Done",
  save: "Save",
  filtersLabel: "Filters",
  cancelButton: "Cancel",
  discardButton: "Discard",
  saveExitButton: "Save & Exit",
  emptyTitle: "No widgets yet",
  emptyDescription: "Add widgets to build your custom dashboard.",
  unsavedTitle: "Unsaved Changes",
  unsavedDescription: "You have unsaved changes. What would you like to do?",
};

/** Labels for `DeleteConfirm`. */
export interface DeleteConfirmLabels {
  title?: string;
  description?: string;
  confirm?: string;
  cancel?: string;
}

/** Props for `CustomDashboard`. */
export interface CustomDashboardProps {
  widgets: ReportWidgetConfig[];
  onWidgetsChange: (widgets: ReportWidgetConfig[]) => void;
  onSave?: (widgets: ReportWidgetConfig[]) => void;
  /** Callback when a widget is deleted by index */
  onDeleteWidget?: (index: number) => void;
  /** Show ConfirmDialog before deleting a widget (default: false) */
  confirmDelete?: boolean;
  /** Labels for the delete confirmation dialog */
  deleteConfirmLabels?: DeleteConfirmLabels;
  /** Controlled edit mode (external toggle) */
  editing?: boolean;
  /** Callback when edit mode changes */
  onEditingChange?: (editing: boolean) => void;
  /** ReportBuilder display mode: "inline" (default) or "modal" */
  builderMode?: "inline" | "modal";
  /** Global filters (e.g., date range, segment) */
  filters?: FilterGroupDef[];
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  loading?: boolean;
  timeRange?: DateRange;
  onTimeRangeChange?: (range: DateRange) => void;
  /** Called when widget layout positions change (for debounced persistence) */
  onLayoutChange?: (widgets: ReportWidgetConfig[]) => void;
  /** Custom grid renderer (e.g., react-grid-layout). Overrides default WidgetGrid. */
  renderGrid?: (
    widgets: ReportWidgetConfig[],
    editing: boolean,
  ) => React.ReactNode;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: CustomDashboardLabels;
  className?: string;
}

/** Renders the `CustomDashboard` component. */
export function CustomDashboard({
  widgets,
  onWidgetsChange,
  onSave,
  onDeleteWidget,
  confirmDelete = false,
  deleteConfirmLabels,
  editing: controlledEditing,
  onEditingChange,
  builderMode = "inline",
  filters,
  onFilterChange,
  onLayoutChange,
  renderGrid,
  loading = false,
  timeRange,
  onTimeRangeChange,
  header,
  actions,
  labels: customLabels,
  className,
}: CustomDashboardProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const sectionId = React.useId();

  const [internalEditing, setInternalEditing] = React.useState(false);
  const editing = controlledEditing ?? internalEditing;
  const setEditing = (v: boolean) => {
    setInternalEditing(v);
    onEditingChange?.(v);
  };

  const [showUnsaved, setShowUnsaved] = React.useState(false);
  const editSnapshot = React.useRef<string>("");

  // State for confirmDelete dialog
  const [pendingDeleteIndex, setPendingDeleteIndex] = React.useState<
    number | null
  >(null);

  // State for builder modal
  const [showBuilderModal, setShowBuilderModal] = React.useState(false);

  const startEditing = () => {
    editSnapshot.current = JSON.stringify(widgets);
    if (builderMode === "modal") {
      setShowBuilderModal(true);
    } else {
      setEditing(true);
    }
  };

  const handleDone = () => {
    const changed = editSnapshot.current !== JSON.stringify(widgets);
    if (changed) {
      setShowUnsaved(true);
    } else {
      setEditing(false);
    }
  };

  const handleDeleteWidget = (index: number) => {
    if (confirmDelete) {
      setPendingDeleteIndex(index);
    } else {
      onDeleteWidget?.(index);
    }
  };

  const confirmDeleteWidget = () => {
    if (pendingDeleteIndex != null) {
      onDeleteWidget?.(pendingDeleteIndex);
      setPendingDeleteIndex(null);
    }
  };

  const cancelDeleteWidget = () => {
    setPendingDeleteIndex(null);
  };

  // Convert ReportWidgetConfigs to WidgetDefs for the grid
  const gridWidgets: WidgetDef[] = React.useMemo(() => {
    let cursorY = 0;
    let cursorX = 0;
    return widgets.map((w, i) => {
      const ww = w.layout?.w ?? 6;
      const hh = w.layout?.h ?? 3;
      const lx = w.layout?.x;
      const ly = w.layout?.y;
      if (lx != null && ly != null) {
        // Use explicit layout position if provided
        return {
          id: `widget-${i}`,
          x: lx,
          y: ly,
          w: ww,
          h: hh,
          content: (
            <div className="group relative">
              <ReportWidget config={w} height={hh * 60} />
              {onDeleteWidget && (
                <button
                  type="button"
                  className="bg-surface border-border hover:bg-bg-subtle absolute top-2 right-2 rounded border p-1 text-xs opacity-0 group-hover:opacity-100"
                  onClick={() => handleDeleteWidget(i)}
                  aria-label="Delete widget"
                >
                  ✕
                </button>
              )}
            </div>
          ),
        };
      }
      if (cursorX + ww > 12) {
        cursorX = 0;
        cursorY += hh;
      }
      const def: WidgetDef = {
        id: `widget-${i}`,
        x: cursorX,
        y: cursorY,
        w: ww,
        h: hh,
        content: (
          <div className="group relative">
            <ReportWidget config={w} height={hh * 60} />
            {onDeleteWidget && (
              <button
                type="button"
                className="bg-surface border-border hover:bg-bg-subtle absolute top-2 right-2 rounded border p-1 text-xs opacity-0 group-hover:opacity-100"
                onClick={() => handleDeleteWidget(i)}
                aria-label="Delete widget"
              >
                ✕
              </button>
            )}
          </div>
        ),
      };
      cursorX += ww;
      return def;
    });
  }, [widgets, onDeleteWidget, confirmDelete]);

  const isEmpty = widgets.length === 0;
  const hasFilters = filters && filters.length > 0;

  const builderContent = (
    <ReportBuilder
      onSave={(report) => {
        const typeMap: Record<string, ReportWidgetConfig["type"]> = {
          number: "metric",
          table: "bar",
          retention: "bar",
        };
        const newWidget: ReportWidgetConfig = {
          type:
            typeMap[report.chartType] ??
            (report.chartType as ReportWidgetConfig["type"]),
          title: report.name,
        };
        onWidgetsChange([...widgets, newWidget]);
        setEditing(false);
        setShowBuilderModal(false);
      }}
      onCancel={() => setShowBuilderModal(false)}
    />
  );

  return (
    <SurfaceLayout loading={loading} className={className}>
      <div className="flex items-center justify-between">
        {header || actions ? (
          <>
            {header && <div>{header}</div>}
            <div className="gap-element flex items-center">
              <TimeRangeSelector
                value={timeRange}
                onChange={onTimeRangeChange}
              />
              {actions}
              <Button variant="primary" size="sm" onClick={startEditing}>
                + {labels.addWidget}
              </Button>
            </div>
          </>
        ) : (
          <>
            <h2 className="text-text-primary text-lg font-semibold">
              {labels.dashboard}
            </h2>
            <div className="gap-element flex">
              <TimeRangeSelector
                value={timeRange}
                onChange={onTimeRangeChange}
              />
              {editing ? (
                <Button variant="primary" size="sm" onClick={handleDone}>
                  {labels.done}
                </Button>
              ) : (
                <>
                  <Button variant="primary" size="sm" onClick={startEditing}>
                    + {labels.addWidget}
                  </Button>
                  <Button variant="ghost" size="sm" onClick={startEditing}>
                    {labels.edit}
                  </Button>
                  {onSave && (
                    <Button
                      variant="ghost"
                      size="sm"
                      onClick={() => onSave(widgets)}
                    >
                      {labels.save}
                    </Button>
                  )}
                </>
              )}
            </div>
          </>
        )}
      </div>

      {hasFilters && (
        <section aria-labelledby={`${sectionId}-filters`}>
          <span id={`${sectionId}-filters`} className="sr-only">
            {labels.filtersLabel}
          </span>
          <FilterBar filters={filters} onFilterChange={onFilterChange} />
        </section>
      )}

      {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>
          <Button
            variant="ghost"
            size="sm"
            className="mt-4"
            onClick={startEditing}
          >
            + {labels.addWidget}
          </Button>
        </div>
      ) : (
        <section aria-labelledby={`${sectionId}-content`}>
          <span id={`${sectionId}-content`} className="sr-only">
            {labels.dashboard}
          </span>
          {renderGrid ? (
            renderGrid(widgets, editing)
          ) : editing && builderMode === "inline" ? (
            builderContent
          ) : (
            <WidgetGrid widgets={gridWidgets} />
          )}
        </section>
      )}

      {/* Unsaved changes dialog */}
      <DialogRoot open={showUnsaved} setOpen={setShowUnsaved}>
        <DialogPanel>
          <div className="p-4">
            <h3 className="text-text-primary mb-2 text-sm font-medium">
              {labels.unsavedTitle}
            </h3>
            <p className="text-text-secondary mb-4 text-sm">
              {labels.unsavedDescription}
            </p>
            <div className="gap-element flex justify-end">
              <DialogDismiss
                className="text-text-secondary hover:bg-bg-subtle rounded-md px-3 py-1.5 text-sm"
                onClick={() => setShowUnsaved(false)}
              >
                {labels.cancelButton}
              </DialogDismiss>
              <Button
                variant="ghost"
                size="sm"
                onClick={() => {
                  onWidgetsChange(JSON.parse(editSnapshot.current));
                  setShowUnsaved(false);
                  setEditing(false);
                }}
              >
                {labels.discardButton}
              </Button>
              <Button
                variant="primary"
                size="sm"
                onClick={() => {
                  onSave?.(widgets);
                  onLayoutChange?.(widgets);
                  setShowUnsaved(false);
                  setEditing(false);
                }}
              >
                {labels.saveExitButton}
              </Button>
            </div>
          </div>
        </DialogPanel>
      </DialogRoot>

      {/* Delete confirmation dialog */}
      <ConfirmDialog
        open={pendingDeleteIndex != null}
        onConfirm={confirmDeleteWidget}
        onCancel={cancelDeleteWidget}
        title={deleteConfirmLabels?.title ?? "Delete Widget"}
        description={
          deleteConfirmLabels?.description ??
          "Are you sure you want to delete this widget?"
        }
        confirmLabel={deleteConfirmLabels?.confirm ?? "Delete"}
        cancelLabel={deleteConfirmLabels?.cancel ?? "Cancel"}
        variant="danger"
      />

      {/* Builder modal (when builderMode === "modal") */}
      {builderMode === "modal" && (
        <DialogRoot open={showBuilderModal} setOpen={setShowBuilderModal}>
          <DialogPanel className="max-w-3xl p-6">{builderContent}</DialogPanel>
        </DialogRoot>
      )}
    </SurfaceLayout>
  );
}

CustomDashboard.displayName = "CustomDashboard";