CustomDashboard
surface커스텀 대시보드 Surface. WidgetGrid + ReportBuilder + ReportWidget + FilterBar 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
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 | — | 위젯 삭제 핸들러 |
editing | boolean | — | 제어 모드: 편집 상태 |
onEditingChange | (editing: boolean) => void | — | 편집 상태 변경 핸들러 |
filters | FilterGroupDef[] | — | 글로벌 필터 (날짜 범위, 세그먼트 등) |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add custom-dashboardConsumer 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.tsxcustom-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";