feat(pop-dashboard): 페이지 기반 구조 전환 및 설정 패널 고도화

구조 변경:
- grid 모드를 독립 displayMode에서 페이지 내부 그리드 레이아웃으로 전환
- DashboardPage 타입 추가 (각 페이지가 독립 그리드 보유)
- migrateConfig()로 기존 grid/useGridLayout 설정 자동 마이그레이션

설정 패널 (PopDashboardConfig):
- 드롭다운 기반 집계 설정 UI 전면 재작성 (+917줄)
- 테이블/컬럼 선택 Combobox, 페이지 관리, 셀 배치 편집기
- fetchTableList() 추가 (테이블 목록 조회)

컴포넌트/모드 개선:
- GridMode: 반응형 자동 열 축소 (MIN_CELL_WIDTH 기준)
- PopDashboardComponent: 페이지 기반 렌더링 로직
- PopDashboardPreview: 페이지 뱃지 표시

기타:
- ComponentEditorPanel: 탭 콘텐츠 스크롤 수정 (min-h-0 추가)
- types.ts: grid를 displayMode에서 제거, DashboardPage 타입 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-10 14:22:30 +09:00
parent 73e3d56381
commit dc523d86c3
8 changed files with 1197 additions and 225 deletions

View File

@ -97,8 +97,8 @@ export default function ComponentEditorPanel({
</div> </div>
{/* 탭 */} {/* 탭 */}
<Tabs defaultValue="position" className="flex flex-1 flex-col"> <Tabs defaultValue="position" className="flex min-h-0 flex-1 flex-col">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2"> <TabsList className="w-full shrink-0 justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="position" className="gap-1 text-xs"> <TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" /> <Grid3x3 className="h-3 w-3" />
@ -118,7 +118,7 @@ export default function ComponentEditorPanel({
</TabsList> </TabsList>
{/* 위치 탭 */} {/* 위치 탭 */}
<TabsContent value="position" className="flex-1 overflow-auto p-4"> <TabsContent value="position" className="min-h-0 flex-1 overflow-auto p-4">
<PositionForm <PositionForm
component={component} component={component}
currentMode={currentMode} currentMode={currentMode}
@ -129,7 +129,7 @@ export default function ComponentEditorPanel({
</TabsContent> </TabsContent>
{/* 설정 탭 */} {/* 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4"> <TabsContent value="settings" className="min-h-0 flex-1 overflow-auto p-4">
<ComponentSettingsForm <ComponentSettingsForm
component={component} component={component}
onUpdate={onUpdateComponent} onUpdate={onUpdateComponent}
@ -137,7 +137,7 @@ export default function ComponentEditorPanel({
</TabsContent> </TabsContent>
{/* 표시 탭 */} {/* 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4"> <TabsContent value="visibility" className="min-h-0 flex-1 overflow-auto p-4">
<VisibilityForm <VisibilityForm
component={component} component={component}
onUpdate={onUpdateComponent} onUpdate={onUpdateComponent}
@ -145,7 +145,7 @@ export default function ComponentEditorPanel({
</TabsContent> </TabsContent>
{/* 데이터 탭 */} {/* 데이터 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4"> <TabsContent value="data" className="min-h-0 flex-1 overflow-auto p-4">
<DataBindingPlaceholder /> <DataBindingPlaceholder />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@ -14,6 +14,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
import type { import type {
PopDashboardConfig, PopDashboardConfig,
DashboardItem, DashboardItem,
DashboardPage,
} from "../types"; } from "../types";
import { fetchAggregatedData } from "./utils/dataFetcher"; import { fetchAggregatedData } from "./utils/dataFetcher";
import { import {
@ -33,6 +34,62 @@ import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
import { GridModeComponent } from "./modes/GridMode"; import { GridModeComponent } from "./modes/GridMode";
import { ScrollModeComponent } from "./modes/ScrollMode"; import { ScrollModeComponent } from "./modes/ScrollMode";
// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
/**
* config를 .
* ( config ).
*
* 시나리오1: displayMode="grid" ( )
* 시나리오2: useGridLayout=true ( )
* 시나리오3: pages ( ) ->
* 시나리오4: pages + grid -> pages ( )
*/
export function migrateConfig(
raw: Record<string, unknown>
): PopDashboardConfig {
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
// pages가 이미 있으면 마이그레이션 불필요
if (
Array.isArray(config.pages) &&
config.pages.length > 0
) {
return config;
}
// 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
const wasGrid =
config.displayMode === ("grid" as string) ||
(config as Record<string, unknown>).useGridLayout === true;
if (wasGrid) {
const cols =
((config as Record<string, unknown>).gridColumns as number) ?? 2;
const rows =
((config as Record<string, unknown>).gridRows as number) ?? 2;
const cells =
((config as Record<string, unknown>).gridCells as DashboardPage["gridCells"]) ?? [];
const page: DashboardPage = {
id: "migrated-page-1",
label: "페이지 1",
gridColumns: cols,
gridRows: rows,
gridCells: cells,
};
config.pages = [page];
// displayMode="grid" 보정
if (config.displayMode === ("grid" as string)) {
(config as Record<string, unknown>).displayMode = "arrows";
}
}
return config as PopDashboardConfig;
}
// ===== 내부 타입 ===== // ===== 내부 타입 =====
interface ItemData { interface ItemData {
@ -259,9 +316,43 @@ export function PopDashboardComponent({
); );
} }
// 표시 모드별 렌더링 // 마이그레이션: 기존 config를 페이지 기반으로 변환
const displayMode = config.displayMode; const migrated = migrateConfig(config as unknown as Record<string, unknown>);
const pages = migrated.pages ?? [];
const displayMode = migrated.displayMode;
// 페이지 하나를 GridModeComponent로 렌더링
const renderPageContent = (page: DashboardPage) => (
<GridModeComponent
cells={page.gridCells}
columns={page.gridColumns}
rows={page.gridRows}
gap={config.gap}
containerWidth={containerWidth}
renderItem={(itemId) => {
const item = visibleItems.find((i) => i.id === itemId);
if (!item) return null;
return renderSingleItem(item);
}}
/>
);
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
// 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
const renderSlide = (index: number) => {
if (pages.length > 0 && pages[index]) {
return renderPageContent(pages[index]);
}
// fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
if (visibleItems[index]) {
return renderSingleItem(visibleItems[index]);
}
return null;
};
// 표시 모드별 렌더링
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -274,41 +365,27 @@ export function PopDashboardComponent({
> >
{displayMode === "arrows" && ( {displayMode === "arrows" && (
<ArrowsModeComponent <ArrowsModeComponent
itemCount={visibleItems.length} itemCount={slideCount}
showIndicator={config.showIndicator} showIndicator={config.showIndicator}
renderItem={(index) => renderSingleItem(visibleItems[index])} renderItem={renderSlide}
/> />
)} )}
{displayMode === "auto-slide" && ( {displayMode === "auto-slide" && (
<AutoSlideModeComponent <AutoSlideModeComponent
itemCount={visibleItems.length} itemCount={slideCount}
interval={config.autoSlideInterval} interval={config.autoSlideInterval}
resumeDelay={config.autoSlideResumeDelay} resumeDelay={config.autoSlideResumeDelay}
showIndicator={config.showIndicator} showIndicator={config.showIndicator}
renderItem={(index) => renderSingleItem(visibleItems[index])} renderItem={renderSlide}
/>
)}
{displayMode === "grid" && (
<GridModeComponent
cells={config.gridCells ?? []}
columns={config.gridColumns ?? 2}
rows={config.gridRows ?? 2}
gap={config.gap}
renderItem={(itemId) => {
const item = visibleItems.find((i) => i.id === itemId);
if (!item) return null;
return renderSingleItem(item);
}}
/> />
)} )}
{displayMode === "scroll" && ( {displayMode === "scroll" && (
<ScrollModeComponent <ScrollModeComponent
itemCount={visibleItems.length} itemCount={slideCount}
showIndicator={config.showIndicator} showIndicator={config.showIndicator}
renderItem={(index) => renderSingleItem(visibleItems[index])} renderItem={renderSlide}
/> />
)} )}
</div> </div>

View File

@ -10,6 +10,7 @@
import React from "react"; import React from "react";
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
import type { PopDashboardConfig, DashboardSubType } from "../types"; import type { PopDashboardConfig, DashboardSubType } from "../types";
import { migrateConfig } from "./PopDashboardComponent";
// ===== 서브타입별 아이콘 매핑 ===== // ===== 서브타입별 아이콘 매핑 =====
@ -32,7 +33,6 @@ const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
const MODE_LABELS: Record<string, string> = { const MODE_LABELS: Record<string, string> = {
arrows: "좌우 버튼", arrows: "좌우 버튼",
"auto-slide": "자동 슬라이드", "auto-slide": "자동 슬라이드",
grid: "그리드",
scroll: "스크롤", scroll: "스크롤",
}; };
@ -75,34 +75,43 @@ export function PopDashboardPreviewComponent({
} }
const visibleItems = config.items.filter((i) => i.visible); const visibleItems = config.items.filter((i) => i.visible);
const mode = config.displayMode;
// 마이그레이션 적용
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
const pages = migrated.pages ?? [];
const hasPages = pages.length > 0;
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden p-1"> <div className="flex h-full w-full flex-col overflow-hidden p-1">
{/* 모드 표시 */} {/* 모드 + 페이지 뱃지 */}
<div className="mb-1 flex items-center gap-1"> <div className="mb-1 flex items-center gap-1">
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary"> <span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
{MODE_LABELS[mode] ?? mode} {MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
</span> </span>
{hasPages && (
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-medium text-muted-foreground">
{pages.length}
</span>
)}
<span className="text-[8px] text-muted-foreground"> <span className="text-[8px] text-muted-foreground">
{visibleItems.length} {visibleItems.length}
</span> </span>
</div> </div>
{/* 모드별 미리보기 */} {/* 미리보기 */}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
{mode === "grid" ? ( {hasPages ? (
// 그리드: 셀 구조 시각화 // 첫 번째 페이지 그리드 미리보기
<div <div
className="h-full w-full gap-1" className="h-full w-full gap-1"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${config.gridColumns ?? 2}, 1fr)`, gridTemplateColumns: `repeat(${pages[0].gridColumns}, 1fr)`,
gridTemplateRows: `repeat(${config.gridRows ?? 2}, 1fr)`, gridTemplateRows: `repeat(${pages[0].gridRows}, 1fr)`,
}} }}
> >
{config.gridCells?.length {pages[0].gridCells.length > 0
? config.gridCells.map((cell) => { ? pages[0].gridCells.map((cell) => {
const item = visibleItems.find( const item = visibleItems.find(
(i) => i.id === cell.itemId (i) => i.id === cell.itemId
); );
@ -125,8 +134,7 @@ export function PopDashboardPreviewComponent({
</div> </div>
); );
}) })
: // 셀 미설정: 아이템만 나열 : visibleItems.slice(0, 4).map((item) => (
visibleItems.slice(0, 4).map((item) => (
<DummyItemPreview <DummyItemPreview
key={item.id} key={item.id}
subType={item.subType} subType={item.subType}
@ -135,7 +143,7 @@ export function PopDashboardPreviewComponent({
))} ))}
</div> </div>
) : ( ) : (
// 다른 모드: 첫 번째 아이템만 크게 표시 // 페이지 미설정: 첫 번째 아이템만 크게 표시
<div className="relative h-full"> <div className="relative h-full">
{visibleItems[0] && ( {visibleItems[0] && (
<DummyItemPreview <DummyItemPreview
@ -143,7 +151,6 @@ export function PopDashboardPreviewComponent({
label={visibleItems[0].label} label={visibleItems[0].label}
/> />
)} )}
{/* 추가 아이템 수 뱃지 */}
{visibleItems.length > 1 && ( {visibleItems.length > 1 && (
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground"> <div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
+{visibleItems.length - 1} +{visibleItems.length - 1}

View File

@ -23,6 +23,7 @@ PopComponentRegistry.registerComponent({
preview: PopDashboardPreviewComponent, preview: PopDashboardPreviewComponent,
defaultProps: { defaultProps: {
items: [], items: [],
pages: [],
displayMode: "arrows", displayMode: "arrows",
autoSlideInterval: 5, autoSlideInterval: 5,
autoSlideResumeDelay: 3, autoSlideResumeDelay: 3,

View File

@ -5,26 +5,110 @@
* *
* CSS Grid로 ( / ) * CSS Grid로 ( / )
* @container * @container
*
* :
* - containerWidth에
* - ,
* - (MIN_CELL_WIDTH)
*/ */
import React from "react"; import React, { useMemo } from "react";
import type { DashboardCell } from "../../types"; import type { DashboardCell } from "../../types";
// ===== 상수 =====
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
const MIN_CELL_WIDTH = 160;
// ===== Props ===== // ===== Props =====
export interface GridModeProps { export interface GridModeProps {
/** 셀 배치 정보 */ /** 셀 배치 정보 */
cells: DashboardCell[]; cells: DashboardCell[];
/** 열 수 */ /** 설정된 열 수 (최대값) */
columns: number; columns: number;
/** 행 수 */ /** 설정된 행 수 */
rows: number; rows: number;
/** 아이템 간 간격 (px) */ /** 아이템 간 간격 (px) */
gap?: number; gap?: number;
/** 컨테이너 너비 (px, 반응형 자동 조정용) */
containerWidth?: number;
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
renderItem: (itemId: string) => React.ReactNode; renderItem: (itemId: string) => React.ReactNode;
} }
// ===== 반응형 열 수 계산 =====
/**
*
*
* columns가 , .
* gap도 .
*
* : columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160
* = 400 - (3-1)*8 = 384
* = 384/3 = 128 < 160 ->
* columns=2: 사용 = 400 - (2-1)*8 = 392, = 196 >= 160 -> OK
*/
function computeResponsiveColumns(
configColumns: number,
containerWidth: number,
gap: number
): number {
if (containerWidth <= 0) return configColumns;
for (let cols = configColumns; cols >= 1; cols--) {
const totalGap = (cols - 1) * gap;
const cellWidth = (containerWidth - totalGap) / cols;
if (cellWidth >= MIN_CELL_WIDTH) return cols;
}
return 1;
}
/**
*
*
* gridColumn/gridRow를 actualColumns에
* ,
*/
function remapCells(
cells: DashboardCell[],
configColumns: number,
actualColumns: number,
configRows: number
): { remappedCells: DashboardCell[]; actualRows: number } {
// 열 수가 같으면 원본 그대로
if (actualColumns >= configColumns) {
return { remappedCells: cells, actualRows: configRows };
}
// 셀을 원래 위치 순서대로 정렬 (행 우선)
const sorted = [...cells].sort((a, b) => {
const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0;
const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0;
if (aRow !== bRow) return aRow - bRow;
const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0;
const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0;
return aCol - bCol;
});
// 순서대로 새 위치에 배치
let maxRow = 0;
const remapped = sorted.map((cell, index) => {
const newCol = (index % actualColumns) + 1;
const newRow = Math.floor(index / actualColumns) + 1;
maxRow = Math.max(maxRow, newRow);
return {
...cell,
gridColumn: `${newCol} / ${newCol + 1}`,
gridRow: `${newRow} / ${newRow + 1}`,
};
});
return { remappedCells: remapped, actualRows: maxRow };
}
// ===== 메인 컴포넌트 ===== // ===== 메인 컴포넌트 =====
export function GridModeComponent({ export function GridModeComponent({
@ -32,9 +116,25 @@ export function GridModeComponent({
columns, columns,
rows, rows,
gap = 8, gap = 8,
containerWidth,
renderItem, renderItem,
}: GridModeProps) { }: GridModeProps) {
if (!cells.length) { // 반응형 열 수 계산
const actualColumns = useMemo(
() =>
containerWidth
? computeResponsiveColumns(columns, containerWidth, gap)
: columns,
[columns, containerWidth, gap]
);
// 열 수가 줄었으면 셀 재배열
const { remappedCells, actualRows } = useMemo(
() => remapCells(cells, columns, actualColumns, rows),
[cells, columns, actualColumns, rows]
);
if (!remappedCells.length) {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span> <span className="text-xs text-muted-foreground"> </span>
@ -47,12 +147,12 @@ export function GridModeComponent({
className="h-full w-full" className="h-full w-full"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`, gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`, gridTemplateRows: `repeat(${actualRows}, 1fr)`,
gap: `${gap}px`, gap: `${gap}px`,
}} }}
> >
{cells.map((cell) => ( {remappedCells.map((cell) => (
<div <div
key={cell.id} key={cell.id}
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card" className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
@ -65,7 +165,9 @@ export function GridModeComponent({
renderItem(cell.itemId) renderItem(cell.itemId)
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<span className="text-[10px] text-muted-foreground/50"> </span> <span className="text-[10px] text-muted-foreground/50">
</span>
</div> </div>
)} )}
</div> </div>

View File

@ -12,8 +12,14 @@
import { dashboardApi } from "@/lib/api/dashboard"; import { dashboardApi } from "@/lib/api/dashboard";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { tableManagementApi } from "@/lib/api/tableManagement";
import type { TableInfo } from "@/lib/api/tableManagement";
import type { DataSourceConfig, DataSourceFilter } from "../../types"; import type { DataSourceConfig, DataSourceFilter } from "../../types";
// ===== 타입 re-export =====
export type { TableInfo };
// ===== 반환 타입 ===== // ===== 반환 타입 =====
export interface AggregatedResult { export interface AggregatedResult {
@ -233,3 +239,21 @@ export async function fetchTableColumns(
return []; return [];
} }
} }
/**
* ( Combobox용)
* tableManagementApi.getTableList()
*
* @INFRA-EXTRACT: useDataSource
*/
export async function fetchTableList(): Promise<TableInfo[]> {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
return response.data;
}
return [];
} catch {
return [];
}
}

View File

@ -194,7 +194,6 @@ export interface PopActionConfig {
export type DashboardDisplayMode = export type DashboardDisplayMode =
| "arrows" | "arrows"
| "auto-slide" | "auto-slide"
| "grid"
| "scroll"; | "scroll";
export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card"; export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio"; export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
@ -280,6 +279,17 @@ export interface DashboardCell {
itemId: string | null; // null이면 빈 셀 itemId: string | null; // null이면 빈 셀
} }
// ----- 대시보드 페이지(슬라이드) -----
/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */
export interface DashboardPage {
id: string;
label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1")
gridColumns: number; // 이 페이지의 열 수
gridRows: number; // 이 페이지의 행 수
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
}
// ----- 대시보드 아이템 ----- // ----- 대시보드 아이템 -----
export interface DashboardItem { export interface DashboardItem {
@ -306,17 +316,18 @@ export interface DashboardItem {
export interface PopDashboardConfig { export interface PopDashboardConfig {
items: DashboardItem[]; items: DashboardItem[];
displayMode: DashboardDisplayMode; pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃)
displayMode: DashboardDisplayMode; // 페이지 간 전환 방식
// 모드별 설정 // 모드별 설정
autoSlideInterval?: number; // 초 (기본 5) autoSlideInterval?: number; // 초 (기본 5)
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3) autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
gridCells?: DashboardCell[]; // grid 모드 셀 배치
gridColumns?: number; // grid 모드 열 수 (기본 2)
gridRows?: number; // grid 모드 행 수 (기본 2)
// 공통 스타일 // 공통 스타일
showIndicator?: boolean; // 페이지 인디케이터 showIndicator?: boolean; // 페이지 인디케이터
gap?: number; // 아이템 간 간격 px gap?: number; // 아이템 간 간격 px
backgroundColor?: string; backgroundColor?: string;
// 데이터 소스 (아이템 공통)
dataSource?: DataSourceConfig;
} }