From dc523d86c3f2e484e4f6a21cb6fbc625e5371179 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 14:22:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-dashboard):=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조 변경: - 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 --- .../designer/panels/ComponentEditorPanel.tsx | 12 +- .../pop-dashboard/PopDashboardComponent.tsx | 121 +- .../pop-dashboard/PopDashboardConfig.tsx | 1088 ++++++++++++++--- .../pop-dashboard/PopDashboardPreview.tsx | 37 +- .../pop-components/pop-dashboard/index.tsx | 1 + .../pop-dashboard/modes/GridMode.tsx | 118 +- .../pop-dashboard/utils/dataFetcher.ts | 24 + frontend/lib/registry/pop-components/types.ts | 21 +- 8 files changed, 1197 insertions(+), 225 deletions(-) diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ddb7ac79..d58eff84 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -97,8 +97,8 @@ export default function ComponentEditorPanel({ {/* 탭 */} - - + + 위치 @@ -118,7 +118,7 @@ export default function ComponentEditorPanel({ {/* 위치 탭 */} - + {/* 설정 탭 */} - + {/* 표시 탭 */} - + {/* 데이터 탭 */} - + diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 7c6e7c50..0f1aba1a 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -14,6 +14,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import type { PopDashboardConfig, DashboardItem, + DashboardPage, } from "../types"; import { fetchAggregatedData } from "./utils/dataFetcher"; import { @@ -33,6 +34,62 @@ import { AutoSlideModeComponent } from "./modes/AutoSlideMode"; import { GridModeComponent } from "./modes/GridMode"; 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 +): PopDashboardConfig { + const config = { ...raw } as PopDashboardConfig & Record; + + // 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).useGridLayout === true; + + if (wasGrid) { + const cols = + ((config as Record).gridColumns as number) ?? 2; + const rows = + ((config as Record).gridRows as number) ?? 2; + const cells = + ((config as Record).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).displayMode = "arrows"; + } + } + + return config as PopDashboardConfig; +} + // ===== 내부 타입 ===== interface ItemData { @@ -259,9 +316,43 @@ export function PopDashboardComponent({ ); } - // 표시 모드별 렌더링 - const displayMode = config.displayMode; + // 마이그레이션: 기존 config를 페이지 기반으로 변환 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const displayMode = migrated.displayMode; + // 페이지 하나를 GridModeComponent로 렌더링 + const renderPageContent = (page: DashboardPage) => ( + { + 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 (
{displayMode === "arrows" && ( renderSingleItem(visibleItems[index])} + renderItem={renderSlide} /> )} {displayMode === "auto-slide" && ( renderSingleItem(visibleItems[index])} - /> - )} - - {displayMode === "grid" && ( - { - const item = visibleItems.find((i) => i.id === itemId); - if (!item) return null; - return renderSingleItem(item); - }} + renderItem={renderSlide} /> )} {displayMode === "scroll" && ( renderSingleItem(visibleItems[index])} + renderItem={renderSlide} /> )}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 26da10f5..1b0ec03c 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -6,7 +6,7 @@ * 3개 탭: * [기본 설정] - 표시 모드, 간격, 인디케이터 * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정 - * [레이아웃] - grid 모드 셀 분할/병합 + * [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃 */ import React, { useState, useEffect, useCallback } from "react"; @@ -16,6 +16,8 @@ import { ChevronDown, ChevronUp, GripVertical, + Check, + ChevronsUpDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,17 +30,42 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import type { PopDashboardConfig, DashboardItem, DashboardSubType, DashboardDisplayMode, DataSourceConfig, + DataSourceFilter, + FilterOperator, FormulaConfig, ItemVisibility, DashboardCell, + DashboardPage, + JoinConfig, + JoinType, } from "../types"; -import { fetchTableColumns, type ColumnInfo } from "./utils/dataFetcher"; +import { migrateConfig } from "./PopDashboardComponent"; +import { + fetchTableColumns, + fetchTableList, + type ColumnInfo, + type TableInfo, +} from "./utils/dataFetcher"; import { validateExpression } from "./utils/formula"; // ===== Props ===== @@ -52,14 +79,12 @@ interface ConfigPanelProps { const DEFAULT_CONFIG: PopDashboardConfig = { items: [], + pages: [], displayMode: "arrows", autoSlideInterval: 5, autoSlideResumeDelay: 3, showIndicator: true, gap: 8, - gridColumns: 2, - gridRows: 2, - gridCells: [], }; const DEFAULT_VISIBILITY: ItemVisibility = { @@ -82,7 +107,6 @@ const DEFAULT_DATASOURCE: DataSourceConfig = { const DISPLAY_MODE_LABELS: Record = { arrows: "좌우 버튼", "auto-slide": "자동 슬라이드", - grid: "그리드", scroll: "스크롤", }; @@ -93,6 +117,24 @@ const SUBTYPE_LABELS: Record = { "stat-card": "통계 카드", }; +const JOIN_TYPE_LABELS: Record = { + inner: "INNER JOIN", + left: "LEFT JOIN", + right: "RIGHT JOIN", +}; + +const FILTER_OPERATOR_LABELS: Record = { + "=": "같음 (=)", + "!=": "다름 (!=)", + ">": "초과 (>)", + ">=": "이상 (>=)", + "<": "미만 (<)", + "<=": "이하 (<=)", + like: "포함 (LIKE)", + in: "목록 (IN)", + between: "범위 (BETWEEN)", +}; + // ===== 데이터 소스 편집기 ===== function DataSourceEditor({ @@ -102,9 +144,23 @@ function DataSourceEditor({ dataSource: DataSourceConfig; onChange: (ds: DataSourceConfig) => void; }) { + // 테이블 목록 (Combobox용) + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableOpen, setTableOpen] = useState(false); + + // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); + // 마운트 시 테이블 목록 로드 + useEffect(() => { + setLoadingTables(true); + fetchTableList() + .then(setTables) + .finally(() => setLoadingTables(false)); + }, []); + // 테이블 변경 시 컬럼 목록 조회 useEffect(() => { if (!dataSource.tableName) { @@ -119,20 +175,81 @@ function DataSourceEditor({ return (
- {/* 테이블명 입력 */} + {/* 테이블 선택 (검색 가능한 Combobox) */}
- - - onChange({ ...dataSource, tableName: e.target.value }) - } - placeholder="예: production" - className="h-8 text-xs" - /> + + + + + + + + + + + 테이블을 찾을 수 없습니다 + + + {tables.map((table) => ( + { + const newVal = + table.tableName === dataSource.tableName + ? "" + : table.tableName; + onChange({ ...dataSource, tableName: newVal }); + setTableOpen(false); + }} + className="text-xs" + > + +
+ + {table.displayName || table.tableName} + + {table.displayName && + table.displayName !== table.tableName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
- {/* 집계 함수 */} + {/* 집계 함수 + 대상 컬럼 */}
@@ -143,7 +260,9 @@ function DataSourceEditor({ ...dataSource, aggregation: val ? { - type: val as DataSourceConfig["aggregation"] extends undefined ? never : NonNullable["type"], + type: val as NonNullable< + DataSourceConfig["aggregation"] + >["type"], column: dataSource.aggregation?.column ?? "", } : undefined, @@ -163,7 +282,6 @@ function DataSourceEditor({
- {/* 집계 대상 컬럼 */} {dataSource.aggregation && (
@@ -172,15 +290,14 @@ function DataSourceEditor({ onValueChange={(val) => onChange({ ...dataSource, - aggregation: { - ...dataSource.aggregation!, - column: val, - }, + aggregation: { ...dataSource.aggregation!, column: val }, }) } > - + {columns.map((col) => ( @@ -194,22 +311,469 @@ function DataSourceEditor({ )}
- {/* 새로고침 주기 */} -
- - - onChange({ - ...dataSource, - refreshInterval: parseInt(e.target.value) || 0, - }) - } - className="h-8 text-xs" - min={0} - /> + {/* 자동 새로고침 (Switch + 주기 입력) */} +
+
+ + 0} + onCheckedChange={(checked) => + onChange({ + ...dataSource, + refreshInterval: checked ? 30 : 0, + }) + } + /> +
+ {(dataSource.refreshInterval ?? 0) > 0 && ( +
+ + + onChange({ + ...dataSource, + refreshInterval: Math.max( + 5, + parseInt(e.target.value) || 30 + ), + }) + } + className="h-7 text-xs" + min={5} + /> +
+ )}
+ + {/* 조인 설정 */} + onChange({ ...dataSource, joins })} + /> + + {/* 필터 조건 */} + onChange({ ...dataSource, filters })} + /> +
+ ); +} + +// ===== 조인 편집기 ===== + +function JoinEditor({ + joins, + mainTable, + onChange, +}: { + joins: JoinConfig[]; + mainTable: string; + onChange: (joins: JoinConfig[]) => void; +}) { + const [tables, setTables] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const addJoin = () => { + onChange([ + ...joins, + { + targetTable: "", + joinType: "left", + on: { sourceColumn: "", targetColumn: "" }, + }, + ]); + }; + + const updateJoin = (index: number, partial: Partial) => { + const newJoins = [...joins]; + newJoins[index] = { ...newJoins[index], ...partial }; + onChange(newJoins); + }; + + const removeJoin = (index: number) => { + onChange(joins.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {!mainTable && joins.length === 0 && ( +

+ 먼저 메인 테이블을 선택하세요 +

+ )} + + {joins.map((join, index) => ( + updateJoin(index, partial)} + onRemove={() => removeJoin(index)} + /> + ))} +
+ ); +} + +function JoinRow({ + join, + mainTable, + tables, + onUpdate, + onRemove, +}: { + join: JoinConfig; + mainTable: string; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [targetTableOpen, setTargetTableOpen] = useState(false); + + // 메인 테이블 컬럼 로드 + useEffect(() => { + if (!mainTable) return; + fetchTableColumns(mainTable).then(setSourceColumns); + }, [mainTable]); + + // 조인 대상 테이블 컬럼 로드 + useEffect(() => { + if (!join.targetTable) return; + fetchTableColumns(join.targetTable).then(setTargetColumns); + }, [join.targetTable]); + + return ( +
+
+ {/* 조인 타입 */} + + + {/* 조인 대상 테이블 (Combobox) */} + + + + + + + + + + 없음 + + + {tables + .filter((t) => t.tableName !== mainTable) + .map((t) => ( + { + onUpdate({ targetTable: t.tableName }); + setTargetTableOpen(false); + }} + className="text-xs" + > + {t.displayName || t.tableName} + + ))} + + + + + + + {/* 삭제 */} + +
+ + {/* 조인 조건 (ON 절) */} + {join.targetTable && ( +
+ ON + + = + +
+ )} +
+ ); +} + +// ===== 필터 편집기 ===== + +function FilterEditor({ + filters, + tableName, + onChange, +}: { + filters: DataSourceFilter[]; + tableName: string; + onChange: (filters: DataSourceFilter[]) => void; +}) { + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (!tableName) return; + fetchTableColumns(tableName).then(setColumns); + }, [tableName]); + + const addFilter = () => { + onChange([...filters, { column: "", operator: "=", value: "" }]); + }; + + const updateFilter = ( + index: number, + partial: Partial + ) => { + const newFilters = [...filters]; + newFilters[index] = { ...newFilters[index], ...partial }; + + // operator 변경 시 value 초기화 + if (partial.operator) { + if (partial.operator === "between") { + newFilters[index].value = ["", ""]; + } else if (partial.operator === "in") { + newFilters[index].value = []; + } else if ( + typeof newFilters[index].value !== "string" && + typeof newFilters[index].value !== "number" + ) { + newFilters[index].value = ""; + } + } + + onChange(newFilters); + }; + + const removeFilter = (index: number) => { + onChange(filters.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {filters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 */} + + + {/* 값 입력 (연산자에 따라 다른 UI) */} +
+ {filter.operator === "between" ? ( +
+ { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[0] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="시작" + className="h-7 text-[10px]" + /> + { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[1] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="끝" + className="h-7 text-[10px]" + /> +
+ ) : filter.operator === "in" ? ( + { + const vals = e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + updateFilter(index, { value: vals }); + }} + placeholder="값1, 값2, 값3" + className="h-7 text-[10px]" + /> + ) : ( + + updateFilter(index, { value: e.target.value }) + } + placeholder="값" + className="h-7 text-[10px]" + /> + )} +
+ + {/* 삭제 */} + +
+ ))}
); } @@ -255,7 +819,9 @@ function FormulaEditor({ size="icon" className="h-7 w-7" onClick={() => { - const newValues = formula.values.filter((_, i) => i !== index); + const newValues = formula.values.filter( + (_, i) => i !== index + ); onChange({ ...formula, values: newValues }); }} > @@ -280,7 +846,7 @@ function FormulaEditor({ size="sm" className="h-7 w-full text-xs" onClick={() => { - const nextId = String.fromCharCode(65 + formula.values.length); // A, B, C... + const nextId = String.fromCharCode(65 + formula.values.length); onChange({ ...formula, values: [ @@ -373,15 +939,17 @@ function ItemEditor({
{/* 헤더 */}
- - - {item.label || `아이템 ${index + 1}`} - - + + onUpdate({ ...item, label: e.target.value })} + placeholder={`아이템 ${index + 1}`} + className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1" + /> + {SUBTYPE_LABELS[item.subType]} - {/* 이동 버튼 */} - {/* 보이기/숨기기 */} @@ -410,7 +977,6 @@ function ItemEditor({ className="scale-75" /> - {/* 접기/펼치기 */}
- {/* 상세 설정 (접힘) */} + {/* 상세 설정 */} {expanded && (
- {/* 라벨 */} -
- - onUpdate({ ...item, label: e.target.value })} - className="h-8 text-xs" - placeholder="아이템 이름" - /> -
- - {/* 서브타입 */}
- {/* 데이터 소스 / 수식 편집 */} {dataMode === "formula" && item.formula ? ( void; + onChange: (cells: DashboardCell[], cols: number, rows: number) => void; }) { - // 셀이 없으면 기본 그리드 생성 const ensuredCells = - cells.length > 0 - ? cells - : Array.from({ length: gridColumns * gridRows }, (_, i) => ({ - id: `cell-${i}`, - gridColumn: `${(i % gridColumns) + 1} / ${(i % gridColumns) + 2}`, - gridRow: `${Math.floor(i / gridColumns) + 1} / ${Math.floor(i / gridColumns) + 2}`, - itemId: null as string | null, - })); + cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); return (
- {/* 열/행 수 */} -
-
- - { - const newCols = Math.max(1, parseInt(e.target.value) || 1); - onChange(ensuredCells, newCols, gridRows); + {/* 행/열 조절 버튼 */} +
+
+ +
-
- - { - const newRows = Math.max(1, parseInt(e.target.value) || 1); - onChange(ensuredCells, gridColumns, newRows); + disabled={gridColumns <= 1} + > + - + + + {gridColumns} + +
+ +
+ + + + {gridRows} + + +
+ +
- {/* 셀 미리보기 + 아이템 배정 */} + {/* 시각적 그리드 프리뷰 + 아이템 배정 */}
{ensuredCells.map((cell) => (
- + @@ -776,28 +1425,96 @@ function GridLayoutEditor({ ))}
- {/* 셀 재생성 */} -
+ ); +} + +// ===== 페이지 편집기 ===== + +function PageEditor({ + page, + pageIndex, + items, + onChange, + onDelete, +}: { + page: DashboardPage; + pageIndex: number; + items: DashboardItem[]; + onChange: (updatedPage: DashboardPage) => void; + onDelete: () => void; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ {/* 헤더 */} +
+ + {page.label || `페이지 ${pageIndex + 1}`} + + + {page.gridColumns}x{page.gridRows} + + + +
+ + {/* 상세 */} + {expanded && ( +
+ {/* 라벨 */} +
+ + + onChange({ ...page, label: e.target.value }) + } + placeholder={`페이지 ${pageIndex + 1}`} + className="h-7 text-xs" + /> +
+ + {/* GridLayoutEditor 재사용 */} + + onChange({ + ...page, + gridCells: cells, + gridColumns: cols, + gridRows: rows, + }) } - } - onChange(newCells, gridColumns, gridRows); - }} - > - 셀 초기화 - + /> +
+ )}
); } @@ -809,8 +1526,14 @@ export function PopDashboardConfigPanel({ onUpdate: onChange, }: ConfigPanelProps) { // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 - const cfg: PopDashboardConfig = { ...DEFAULT_CONFIG, ...(config || {}) }; - const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( + const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; + + // 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환 + const cfg = migrateConfig( + merged as unknown as Record + ) as PopDashboardConfig; + + const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">( "basic" ); @@ -848,20 +1571,22 @@ export function PopDashboardConfigPanel({ [cfg.items, updateConfig] ); - // 아이템 삭제 (grid 셀 배정도 해제) + // 아이템 삭제 (모든 페이지의 셀 배정도 해제) const deleteItem = useCallback( (index: number) => { const deletedId = cfg.items[index].id; const newItems = cfg.items.filter((_, i) => i !== index); - // grid 셀에서 해당 아이템 배정 해제 - const newCells = cfg.gridCells?.map((cell) => - cell.itemId === deletedId ? { ...cell, itemId: null } : cell - ); + const newPages = cfg.pages?.map((page) => ({ + ...page, + gridCells: page.gridCells.map((cell) => + cell.itemId === deletedId ? { ...cell, itemId: null } : cell + ), + })); - updateConfig({ items: newItems, gridCells: newCells }); + updateConfig({ items: newItems, pages: newPages }); }, - [cfg.items, cfg.gridCells, updateConfig] + [cfg.items, cfg.pages, updateConfig] ); // 아이템 순서 변경 @@ -884,7 +1609,7 @@ export function PopDashboardConfigPanel({ [ ["basic", "기본 설정"], ["items", "아이템"], - ["layout", "레이아웃"], + ["pages", "페이지"], ] as const ).map(([key, label]) => ( + + {(cfg.pages?.length ?? 0) === 0 && ( +

+ 페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을 + 설정할 수 있습니다. +
+ 페이지가 없으면 아이템이 하나씩 슬라이드됩니다. +

)}
)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx index 8d530b96..2c8b7643 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -10,6 +10,7 @@ import React from "react"; import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; import type { PopDashboardConfig, DashboardSubType } from "../types"; +import { migrateConfig } from "./PopDashboardComponent"; // ===== 서브타입별 아이콘 매핑 ===== @@ -32,7 +33,6 @@ const SUBTYPE_LABELS: Record = { const MODE_LABELS: Record = { arrows: "좌우 버튼", "auto-slide": "자동 슬라이드", - grid: "그리드", scroll: "스크롤", }; @@ -75,34 +75,43 @@ export function PopDashboardPreviewComponent({ } const visibleItems = config.items.filter((i) => i.visible); - const mode = config.displayMode; + + // 마이그레이션 적용 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const hasPages = pages.length > 0; return (
- {/* 모드 표시 */} + {/* 모드 + 페이지 뱃지 */}
- {MODE_LABELS[mode] ?? mode} + {MODE_LABELS[migrated.displayMode] ?? migrated.displayMode} + {hasPages && ( + + {pages.length}페이지 + + )} {visibleItems.length}개
- {/* 모드별 미리보기 */} + {/* 미리보기 */}
- {mode === "grid" ? ( - // 그리드: 셀 구조 시각화 + {hasPages ? ( + // 첫 번째 페이지 그리드 미리보기
- {config.gridCells?.length - ? config.gridCells.map((cell) => { + {pages[0].gridCells.length > 0 + ? pages[0].gridCells.map((cell) => { const item = visibleItems.find( (i) => i.id === cell.itemId ); @@ -125,8 +134,7 @@ export function PopDashboardPreviewComponent({
); }) - : // 셀 미설정: 아이템만 나열 - visibleItems.slice(0, 4).map((item) => ( + : visibleItems.slice(0, 4).map((item) => ( ) : ( - // 다른 모드: 첫 번째 아이템만 크게 표시 + // 페이지 미설정: 첫 번째 아이템만 크게 표시
{visibleItems[0] && ( )} - {/* 추가 아이템 수 뱃지 */} {visibleItems.length > 1 && (
+{visibleItems.length - 1} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx index 01a653b6..58cdf6e2 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx @@ -23,6 +23,7 @@ PopComponentRegistry.registerComponent({ preview: PopDashboardPreviewComponent, defaultProps: { items: [], + pages: [], displayMode: "arrows", autoSlideInterval: 5, autoSlideResumeDelay: 3, diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx index 36a75934..66c4f5e9 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -5,26 +5,110 @@ * * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) * 각 셀에 @container 적용하여 내부 아이템 반응형 + * + * 반응형 자동 조정: + * - containerWidth에 따라 열 수를 자동 축소 + * - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦 + * - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단 */ -import React from "react"; +import React, { useMemo } from "react"; import type { DashboardCell } from "../../types"; +// ===== 상수 ===== + +/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ +const MIN_CELL_WIDTH = 160; + // ===== Props ===== export interface GridModeProps { /** 셀 배치 정보 */ cells: DashboardCell[]; - /** 열 수 */ + /** 설정된 열 수 (최대값) */ columns: number; - /** 행 수 */ + /** 설정된 행 수 */ rows: number; /** 아이템 간 간격 (px) */ gap?: number; + /** 컨테이너 너비 (px, 반응형 자동 조정용) */ + containerWidth?: number; /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ 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({ @@ -32,9 +116,25 @@ export function GridModeComponent({ columns, rows, gap = 8, + containerWidth, renderItem, }: 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 (
셀 없음 @@ -47,12 +147,12 @@ export function GridModeComponent({ className="h-full w-full" style={{ display: "grid", - gridTemplateColumns: `repeat(${columns}, 1fr)`, - gridTemplateRows: `repeat(${rows}, 1fr)`, + gridTemplateColumns: `repeat(${actualColumns}, 1fr)`, + gridTemplateRows: `repeat(${actualRows}, 1fr)`, gap: `${gap}px`, }} > - {cells.map((cell) => ( + {remappedCells.map((cell) => (
- 빈 셀 + + 빈 셀 +
)}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 74dfcd4c..64860699 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -12,8 +12,14 @@ import { dashboardApi } from "@/lib/api/dashboard"; 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"; +// ===== 타입 re-export ===== + +export type { TableInfo }; + // ===== 반환 타입 ===== export interface AggregatedResult { @@ -233,3 +239,21 @@ export async function fetchTableColumns( return []; } } + +/** + * 테이블 목록 조회 (설정 패널 Combobox용) + * tableManagementApi.getTableList() 래핑 + * + * @INFRA-EXTRACT: useDataSource 완성 후 교체 예정 + */ +export async function fetchTableList(): Promise { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + return response.data; + } + return []; + } catch { + return []; + } +} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 4e8ae079..e5927787 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -194,7 +194,6 @@ export interface PopActionConfig { export type DashboardDisplayMode = | "arrows" | "auto-slide" - | "grid" | "scroll"; export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card"; export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio"; @@ -280,6 +279,17 @@ export interface DashboardCell { itemId: string | null; // null이면 빈 셀 } +// ----- 대시보드 페이지(슬라이드) ----- + +/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */ +export interface DashboardPage { + id: string; + label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1") + gridColumns: number; // 이 페이지의 열 수 + gridRows: number; // 이 페이지의 행 수 + gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정) +} + // ----- 대시보드 아이템 ----- export interface DashboardItem { @@ -306,17 +316,18 @@ export interface DashboardItem { export interface PopDashboardConfig { items: DashboardItem[]; - displayMode: DashboardDisplayMode; + pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃) + displayMode: DashboardDisplayMode; // 페이지 간 전환 방식 // 모드별 설정 autoSlideInterval?: number; // 초 (기본 5) autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3) - gridCells?: DashboardCell[]; // grid 모드 셀 배치 - gridColumns?: number; // grid 모드 열 수 (기본 2) - gridRows?: number; // grid 모드 행 수 (기본 2) // 공통 스타일 showIndicator?: boolean; // 페이지 인디케이터 gap?: number; // 아이템 간 간격 px backgroundColor?: string; + + // 데이터 소스 (아이템 공통) + dataSource?: DataSourceConfig; }