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.

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