From 4f3e9ec19e1ae11ed23e7f9fd4b97908b338102c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 11:04:18 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(pop-dashboard):=20Phase=200=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=ED=83=80=EC=9E=85=20+=20Phase=201=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: 공통 인프라 타입 정의 - ColumnBinding, JoinConfig, DataSourceConfig, PopActionConfig 등 - FilterOperator, AggregationType, SortConfig 타입 Phase 1: pop-dashboard 컴포넌트 - 4개 서브타입: KpiCard, ChartItem, GaugeItem, StatCard - 4개 표시모드: ArrowsMode, AutoSlideMode, GridMode, ScrollMode - 설정패널(PopDashboardConfig), 미리보기(PopDashboardPreview) - 계산식 엔진(formula.ts), 데이터 조회(dataFetcher.ts) - 팔레트/렌더러/타입 시스템 연동 fix(pop-text): DateTimeDisplay isRealtime 기본값 true로 수정 EOF --- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 2 + .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 1 + .../pop-dashboard/PopDashboardComponent.tsx | 314 +++++ .../pop-dashboard/PopDashboardConfig.tsx | 1073 +++++++++++++++++ .../pop-dashboard/PopDashboardPreview.tsx | 156 +++ .../pop-components/pop-dashboard/index.tsx | 34 + .../pop-dashboard/items/ChartItem.tsx | 152 +++ .../pop-dashboard/items/GaugeItem.tsx | 137 +++ .../pop-dashboard/items/KpiCard.tsx | 111 ++ .../pop-dashboard/items/StatCard.tsx | 91 ++ .../pop-dashboard/modes/ArrowsMode.tsx | 103 ++ .../pop-dashboard/modes/AutoSlideMode.tsx | 141 +++ .../pop-dashboard/modes/GridMode.tsx | 75 ++ .../pop-dashboard/modes/ScrollMode.tsx | 90 ++ .../pop-dashboard/utils/dataFetcher.ts | 235 ++++ .../pop-dashboard/utils/formula.ts | 259 ++++ .../lib/registry/pop-components/pop-text.tsx | 7 +- frontend/lib/registry/pop-components/types.ts | 234 ++++ 20 files changed, 3222 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts create mode 100644 frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 05db0aab..bfb9800b 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText } from "lucide-react"; +import { Square, FileText, BarChart3 } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -27,6 +27,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: FileText, description: "텍스트, 시간, 이미지 표시", }, + { + type: "pop-dashboard", + label: "대시보드", + icon: BarChart3, + description: "KPI, 차트, 게이지, 통계 집계", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 4ead2adf..c01c5155 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -62,6 +62,8 @@ interface PopRendererProps { const COMPONENT_TYPE_LABELS: Record = { "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-dashboard": "대시보드", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 1a8335ec..97145572 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 +export type PopComponentType = "pop-sample" | "pop-text" | "pop-dashboard"; /** * 데이터 흐름 정의 @@ -342,6 +342,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { export const DEFAULT_COMPONENT_GRID_SIZE: Record = { "pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 }, + "pop-dashboard": { colSpan: 6, rowSpan: 3 }, }; /** diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index b604f9e8..95ec93f1 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -13,6 +13,7 @@ export * from "./types"; // POP 컴포넌트 등록 import "./pop-text"; +import "./pop-dashboard"; // 향후 추가될 컴포넌트들: // import "./pop-field"; diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx new file mode 100644 index 00000000..53940b0a --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -0,0 +1,314 @@ +"use client"; + +/** + * pop-dashboard 메인 컴포넌트 (뷰어용) + * + * 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링 + * + * @INFRA-EXTRACT 대상: + * - fetchAggregatedData 호출부 -> useDataSource로 교체 예정 + * - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정 + */ + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import type { + PopDashboardConfig, + DashboardItem, +} from "../types"; +import { fetchAggregatedData } from "./utils/dataFetcher"; +import { + evaluateFormula, + formatFormulaResult, +} from "./utils/formula"; + +// 서브타입 아이템 컴포넌트 +import { KpiCardComponent } from "./items/KpiCard"; +import { ChartItemComponent } from "./items/ChartItem"; +import { GaugeItemComponent } from "./items/GaugeItem"; +import { StatCardComponent } from "./items/StatCard"; + +// 표시 모드 컴포넌트 +import { ArrowsModeComponent } from "./modes/ArrowsMode"; +import { AutoSlideModeComponent } from "./modes/AutoSlideMode"; +import { GridModeComponent } from "./modes/GridMode"; +import { ScrollModeComponent } from "./modes/ScrollMode"; + +// ===== 내부 타입 ===== + +interface ItemData { + /** 단일 집계 값 */ + value: number; + /** 데이터 행 (차트용) */ + rows: Record[]; + /** 수식 결과 표시 문자열 */ + formulaDisplay: string | null; + /** 에러 메시지 */ + error: string | null; +} + +// ===== 데이터 로딩 함수 ===== + +/** 단일 아이템의 데이터를 조회 */ +async function loadItemData(item: DashboardItem): Promise { + try { + // 수식 모드 + if (item.formula?.enabled && item.formula.values.length > 0) { + // 각 값(A, B, ...)을 병렬 조회 + const results = await Promise.allSettled( + item.formula.values.map((fv) => fetchAggregatedData(fv.dataSource)) + ); + + const valueMap: Record = {}; + for (let i = 0; i < item.formula.values.length; i++) { + const result = results[i]; + const fv = item.formula.values[i]; + valueMap[fv.id] = + result.status === "fulfilled" ? result.value.value : 0; + } + + const calculatedValue = evaluateFormula( + item.formula.expression, + valueMap + ); + const formulaDisplay = formatFormulaResult(item.formula, valueMap); + + return { + value: calculatedValue, + rows: [], + formulaDisplay, + error: null, + }; + } + + // 단일 집계 모드 + const result = await fetchAggregatedData(item.dataSource); + if (result.error) { + return { value: 0, rows: [], formulaDisplay: null, error: result.error }; + } + + return { + value: result.value, + rows: result.rows ?? [], + formulaDisplay: null, + error: null, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 로딩 실패"; + return { value: 0, rows: [], formulaDisplay: null, error: message }; + } +} + +// ===== 메인 컴포넌트 ===== + +export function PopDashboardComponent({ + config, +}: { + config?: PopDashboardConfig; +}) { + const [dataMap, setDataMap] = useState>({}); + const [loading, setLoading] = useState(true); + const refreshTimerRef = useRef | null>(null); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(300); + + // 빈 설정 + if (!config || !config.items.length) { + return ( +
+ + 대시보드 아이템을 추가하세요 + +
+ ); + } + + // 보이는 아이템만 필터링 + const visibleItems = config.items.filter((item) => item.visible); + + // 컨테이너 크기 감지 + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // 데이터 로딩 함수 + const fetchAllData = useCallback(async () => { + if (!visibleItems.length) { + setLoading(false); + return; + } + + setLoading(true); + + // 모든 아이템 병렬 로딩 (하나 실패해도 나머지 표시) + // @INFRA-EXTRACT: useDataSource로 교체 예정 + const results = await Promise.allSettled( + visibleItems.map((item) => loadItemData(item)) + ); + + const newDataMap: Record = {}; + for (let i = 0; i < visibleItems.length; i++) { + const result = results[i]; + newDataMap[visibleItems[i].id] = + result.status === "fulfilled" + ? result.value + : { value: 0, rows: [], formulaDisplay: null, error: "로딩 실패" }; + } + + setDataMap(newDataMap); + setLoading(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(visibleItems.map((i) => i.id))]); + + // 초기 로딩 + 주기적 새로고침 + useEffect(() => { + fetchAllData(); + + // refreshInterval 적용 (첫 번째 아이템 기준) + const refreshSec = visibleItems[0]?.dataSource.refreshInterval; + if (refreshSec && refreshSec > 0) { + refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000); + } + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [fetchAllData, visibleItems]); + + // 단일 아이템 렌더링 + const renderSingleItem = (item: DashboardItem) => { + const itemData = dataMap[item.id]; + if (!itemData) { + return ( +
+ 로딩 중... +
+ ); + } + + if (itemData.error) { + return ( +
+ {itemData.error} +
+ ); + } + + switch (item.subType) { + case "kpi-card": + return ( + + ); + case "chart": + return ( + + ); + case "gauge": + return ; + case "stat-card": { + // StatCard: 카테고리별 건수 맵 구성 + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + // 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준) + categoryData[cat.label] = itemData.rows.length; + } + } + return ( + + ); + } + default: + return ( +
+ + 미지원 타입: {item.subType} + +
+ ); + } + }; + + // 로딩 상태 + if (loading && !Object.keys(dataMap).length) { + return ( +
+
+
+ ); + } + + // 표시 모드별 렌더링 + const displayMode = config.displayMode; + + return ( +
+ {displayMode === "arrows" && ( + renderSingleItem(visibleItems[index])} + /> + )} + + {displayMode === "auto-slide" && ( + renderSingleItem(visibleItems[index])} + /> + )} + + {displayMode === "grid" && ( + { + const item = visibleItems.find((i) => i.id === itemId); + if (!item) return null; + return renderSingleItem(item); + }} + /> + )} + + {displayMode === "scroll" && ( + renderSingleItem(visibleItems[index])} + /> + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx new file mode 100644 index 00000000..74126c22 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -0,0 +1,1073 @@ +"use client"; + +/** + * pop-dashboard 설정 패널 (디자이너용) + * + * 3개 탭: + * [기본 설정] - 표시 모드, 간격, 인디케이터 + * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정 + * [레이아웃] - grid 모드 셀 분할/병합 + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { + Plus, + Trash2, + ChevronDown, + ChevronUp, + GripVertical, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import type { + PopDashboardConfig, + DashboardItem, + DashboardSubType, + DashboardDisplayMode, + DataSourceConfig, + FormulaConfig, + ItemVisibility, + DashboardCell, +} from "../types"; +import { fetchTableColumns, type ColumnInfo } from "./utils/dataFetcher"; +import { validateExpression } from "./utils/formula"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopDashboardConfig | undefined; + onChange: (config: PopDashboardConfig) => void; +} + +// ===== 기본값 ===== + +const DEFAULT_CONFIG: PopDashboardConfig = { + items: [], + displayMode: "arrows", + autoSlideInterval: 5, + autoSlideResumeDelay: 3, + showIndicator: true, + gap: 8, + gridColumns: 2, + gridRows: 2, + gridCells: [], +}; + +const DEFAULT_VISIBILITY: ItemVisibility = { + showLabel: true, + showValue: true, + showUnit: true, + showTrend: true, + showSubLabel: false, + showTarget: true, +}; + +const DEFAULT_DATASOURCE: DataSourceConfig = { + tableName: "", + filters: [], + sort: [], +}; + +// ===== 라벨 상수 ===== + +const DISPLAY_MODE_LABELS: Record = { + arrows: "좌우 버튼", + "auto-slide": "자동 슬라이드", + grid: "그리드", + scroll: "스크롤", +}; + +const SUBTYPE_LABELS: Record = { + "kpi-card": "KPI 카드", + chart: "차트", + gauge: "게이지", + "stat-card": "통계 카드", +}; + +// ===== 데이터 소스 편집기 ===== + +function DataSourceEditor({ + dataSource, + onChange, +}: { + dataSource: DataSourceConfig; + onChange: (ds: DataSourceConfig) => void; +}) { + const [columns, setColumns] = useState([]); + const [loadingCols, setLoadingCols] = useState(false); + + // 테이블 변경 시 컬럼 목록 조회 + useEffect(() => { + if (!dataSource.tableName) { + setColumns([]); + return; + } + setLoadingCols(true); + fetchTableColumns(dataSource.tableName) + .then(setColumns) + .finally(() => setLoadingCols(false)); + }, [dataSource.tableName]); + + return ( +
+ {/* 테이블명 입력 */} +
+ + + onChange({ ...dataSource, tableName: e.target.value }) + } + placeholder="예: production" + className="h-8 text-xs" + /> +
+ + {/* 집계 함수 */} +
+
+ + +
+ + {/* 집계 대상 컬럼 */} + {dataSource.aggregation && ( +
+ + +
+ )} +
+ + {/* 새로고침 주기 */} +
+ + + onChange({ + ...dataSource, + refreshInterval: parseInt(e.target.value) || 0, + }) + } + className="h-8 text-xs" + min={0} + /> +
+
+ ); +} + +// ===== 수식 편집기 ===== + +function FormulaEditor({ + formula, + onChange, +}: { + formula: FormulaConfig; + onChange: (f: FormulaConfig) => void; +}) { + const availableIds = formula.values.map((v) => v.id); + const isValid = formula.expression + ? validateExpression(formula.expression, availableIds) + : true; + + return ( +
+

계산식 설정

+ + {/* 값 목록 */} + {formula.values.map((fv, index) => ( +
+
+ + {fv.id} + + { + const newValues = [...formula.values]; + newValues[index] = { ...fv, label: e.target.value }; + onChange({ ...formula, values: newValues }); + }} + placeholder="라벨 (예: 생산량)" + className="h-7 flex-1 text-xs" + /> + {formula.values.length > 2 && ( + + )} +
+ { + const newValues = [...formula.values]; + newValues[index] = { ...fv, dataSource: ds }; + onChange({ ...formula, values: newValues }); + }} + /> +
+ ))} + + {/* 값 추가 */} + + + {/* 수식 입력 */} +
+ + + onChange({ ...formula, expression: e.target.value }) + } + placeholder="예: A / B * 100" + className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`} + /> + {!isValid && ( +

+ 수식에 정의되지 않은 변수가 있습니다 +

+ )} +
+ + {/* 표시 형태 */} +
+ + +
+
+ ); +} + +// ===== 아이템 편집기 ===== + +function ItemEditor({ + item, + index, + onUpdate, + onDelete, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}: { + item: DashboardItem; + index: number; + onUpdate: (item: DashboardItem) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const [dataMode, setDataMode] = useState<"single" | "formula">( + item.formula?.enabled ? "formula" : "single" + ); + + return ( +
+ {/* 헤더 */} +
+ + + {item.label || `아이템 ${index + 1}`} + + + {SUBTYPE_LABELS[item.subType]} + + + {/* 이동 버튼 */} + + + + {/* 보이기/숨기기 */} + + onUpdate({ ...item, visible: checked }) + } + className="scale-75" + /> + + {/* 접기/펼치기 */} + + + {/* 삭제 */} + +
+ + {/* 상세 설정 (접힘) */} + {expanded && ( +
+ {/* 라벨 */} +
+ + onUpdate({ ...item, label: e.target.value })} + className="h-8 text-xs" + placeholder="아이템 이름" + /> +
+ + {/* 서브타입 */} +
+ + +
+ + {/* 데이터 모드 선택 */} +
+ + +
+ + {/* 데이터 소스 / 수식 편집 */} + {dataMode === "formula" && item.formula ? ( + onUpdate({ ...item, formula: f })} + /> + ) : ( + onUpdate({ ...item, dataSource: ds })} + /> + )} + + {/* 요소별 보이기/숨기기 */} +
+ +
+ {( + [ + ["showLabel", "라벨"], + ["showValue", "값"], + ["showUnit", "단위"], + ["showTrend", "증감율"], + ["showSubLabel", "보조라벨"], + ["showTarget", "목표값"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ + {/* 서브타입별 추가 설정 */} + {item.subType === "kpi-card" && ( +
+ + + onUpdate({ + ...item, + kpiConfig: { ...item.kpiConfig, unit: e.target.value }, + }) + } + placeholder="EA, 톤, 원" + className="h-8 text-xs" + /> +
+ )} + + {item.subType === "chart" && ( +
+ + +
+ )} + + {item.subType === "gauge" && ( +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: parseInt(e.target.value) || 0, + max: item.gaugeConfig?.max ?? 100, + ...item.gaugeConfig, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: item.gaugeConfig?.min ?? 0, + max: parseInt(e.target.value) || 100, + ...item.gaugeConfig, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: item.gaugeConfig?.min ?? 0, + max: item.gaugeConfig?.max ?? 100, + ...item.gaugeConfig, + target: parseInt(e.target.value) || undefined, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ )} +
+ )} +
+ ); +} + +// ===== 그리드 레이아웃 편집기 ===== + +function GridLayoutEditor({ + cells, + gridColumns, + gridRows, + items, + onChange, +}: { + cells: DashboardCell[]; + gridColumns: number; + gridRows: number; + items: DashboardItem[]; + 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, + })); + + return ( +
+ {/* 열/행 수 */} +
+
+ + { + const newCols = Math.max(1, parseInt(e.target.value) || 1); + onChange(ensuredCells, newCols, gridRows); + }} + className="h-8 text-xs" + min={1} + max={6} + /> +
+
+ + { + const newRows = Math.max(1, parseInt(e.target.value) || 1); + onChange(ensuredCells, gridColumns, newRows); + }} + className="h-8 text-xs" + min={1} + max={6} + /> +
+
+ + {/* 셀 미리보기 + 아이템 배정 */} +
+ {ensuredCells.map((cell) => ( +
+ +
+ ))} +
+ + {/* 셀 재생성 */} + +
+ ); +} + +// ===== 메인 설정 패널 ===== + +export function PopDashboardConfigPanel({ + config, + onChange, +}: ConfigPanelProps) { + const cfg = config ?? DEFAULT_CONFIG; + const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( + "basic" + ); + + // 설정 변경 헬퍼 + const updateConfig = useCallback( + (partial: Partial) => { + onChange({ ...cfg, ...partial }); + }, + [cfg, onChange] + ); + + // 아이템 추가 + const addItem = useCallback( + (subType: DashboardSubType) => { + const newItem: DashboardItem = { + id: `item-${Date.now()}`, + label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`, + visible: true, + subType, + dataSource: { ...DEFAULT_DATASOURCE }, + visibility: { ...DEFAULT_VISIBILITY }, + }; + updateConfig({ items: [...cfg.items, newItem] }); + }, + [cfg.items, updateConfig] + ); + + // 아이템 업데이트 + const updateItem = useCallback( + (index: number, item: DashboardItem) => { + const newItems = [...cfg.items]; + newItems[index] = item; + updateConfig({ items: newItems }); + }, + [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 + ); + + updateConfig({ items: newItems, gridCells: newCells }); + }, + [cfg.items, cfg.gridCells, updateConfig] + ); + + // 아이템 순서 변경 + const moveItem = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= cfg.items.length) return; + const newItems = [...cfg.items]; + const [moved] = newItems.splice(from, 1); + newItems.splice(to, 0, moved); + updateConfig({ items: newItems }); + }, + [cfg.items, updateConfig] + ); + + return ( +
+ {/* 탭 헤더 */} +
+ {( + [ + ["basic", "기본 설정"], + ["items", "아이템"], + ["layout", "레이아웃"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ + {/* ===== 기본 설정 탭 ===== */} + {activeTab === "basic" && ( +
+ {/* 표시 모드 */} +
+ + +
+ + {/* 자동 슬라이드 설정 */} + {cfg.displayMode === "auto-slide" && ( +
+
+ + + updateConfig({ + autoSlideInterval: parseInt(e.target.value) || 5, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ + + updateConfig({ + autoSlideResumeDelay: parseInt(e.target.value) || 3, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ )} + + {/* 인디케이터 */} +
+ + + updateConfig({ showIndicator: checked }) + } + /> +
+ + {/* 간격 */} +
+ + + updateConfig({ gap: parseInt(e.target.value) || 8 }) + } + className="h-8 text-xs" + min={0} + /> +
+ + {/* 배경색 */} +
+ + + updateConfig({ backgroundColor: e.target.value || undefined }) + } + placeholder="예: #f0f0f0" + className="h-8 text-xs" + /> +
+
+ )} + + {/* ===== 아이템 관리 탭 ===== */} + {activeTab === "items" && ( +
+ {/* 아이템 목록 */} + {cfg.items.map((item, index) => ( + updateItem(index, updated)} + onDelete={() => deleteItem(index)} + onMoveUp={() => moveItem(index, index - 1)} + onMoveDown={() => moveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === cfg.items.length - 1} + /> + ))} + + {/* 아이템 추가 버튼 */} +
+ {(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map( + (subType) => ( + + ) + )} +
+
+ )} + + {/* ===== 레이아웃 탭 (grid 모드 전용) ===== */} + {activeTab === "layout" && ( +
+ {cfg.displayMode === "grid" ? ( + + updateConfig({ + gridCells: cells, + gridColumns: cols, + gridRows: rows, + }) + } + /> + ) : ( +
+

+ 그리드 모드에서만 레이아웃을 편집할 수 있습니다. +
+ 기본 설정에서 표시 모드를 "그리드"로 변경하세요. +

+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx new file mode 100644 index 00000000..db64f04c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * pop-dashboard 디자이너 미리보기 컴포넌트 + * + * 실제 데이터 없이 더미 레이아웃으로 미리보기 표시 + * 디자이너가 설정 변경 시 즉시 미리보기 확인 가능 + */ + +import React from "react"; +import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; +import type { PopDashboardConfig, DashboardSubType } from "../types"; + +// ===== 서브타입별 아이콘 매핑 ===== + +const SUBTYPE_ICONS: Record = { + "kpi-card": , + chart: , + gauge: , + "stat-card": , +}; + +const SUBTYPE_LABELS: Record = { + "kpi-card": "KPI", + chart: "차트", + gauge: "게이지", + "stat-card": "통계", +}; + +// ===== 모드 라벨 ===== + +const MODE_LABELS: Record = { + arrows: "좌우 버튼", + "auto-slide": "자동 슬라이드", + grid: "그리드", + scroll: "스크롤", +}; + +// ===== 더미 아이템 프리뷰 ===== + +function DummyItemPreview({ + subType, + label, +}: { + subType: DashboardSubType; + label: string; +}) { + return ( +
+ + {SUBTYPE_ICONS[subType]} + + + {label || SUBTYPE_LABELS[subType]} + +
+ ); +} + +// ===== 메인 미리보기 ===== + +export function PopDashboardPreviewComponent({ + config, +}: { + config?: PopDashboardConfig; +}) { + if (!config || !config.items.length) { + return ( +
+ + 대시보드 +
+ ); + } + + const visibleItems = config.items.filter((i) => i.visible); + const mode = config.displayMode; + + return ( +
+ {/* 모드 표시 */} +
+ + {MODE_LABELS[mode] ?? mode} + + + {visibleItems.length}개 + +
+ + {/* 모드별 미리보기 */} +
+ {mode === "grid" ? ( + // 그리드: 셀 구조 시각화 +
+ {config.gridCells?.length + ? config.gridCells.map((cell) => { + const item = visibleItems.find( + (i) => i.id === cell.itemId + ); + return ( +
+ {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 new file mode 100644 index 00000000..01a653b6 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx @@ -0,0 +1,34 @@ +"use client"; + +/** + * pop-dashboard 컴포넌트 레지스트리 등록 진입점 + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopDashboardComponent } from "./PopDashboardComponent"; +import { PopDashboardConfigPanel } from "./PopDashboardConfig"; +import { PopDashboardPreviewComponent } from "./PopDashboardPreview"; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-dashboard", + name: "대시보드", + description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌", + category: "display", + icon: "BarChart3", + component: PopDashboardComponent, + configPanel: PopDashboardConfigPanel, + preview: PopDashboardPreviewComponent, + defaultProps: { + items: [], + displayMode: "arrows", + autoSlideInterval: 5, + autoSlideResumeDelay: 3, + showIndicator: true, + gap: 8, + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx new file mode 100644 index 00000000..66694a58 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * 차트 서브타입 컴포넌트 + * + * Recharts 기반 막대/원형/라인 차트 + * 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지 + */ + +import React from "react"; +import { + BarChart, + Bar, + PieChart, + Pie, + Cell, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import type { DashboardItem } from "../../types"; + +// ===== Props ===== + +export interface ChartItemProps { + item: DashboardItem; + /** 차트에 표시할 데이터 행 */ + rows: Record[]; + /** 컨테이너 너비 (px) - 최소 크기 판단용 */ + containerWidth: number; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#06b6d4", // cyan + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // rose + "#ec4899", // pink + "#14b8a6", // teal +]; + +// ===== 최소 표시 크기 ===== + +const MIN_CHART_WIDTH = 120; + +// ===== 메인 컴포넌트 ===== + +export function ChartItemComponent({ + item, + rows, + containerWidth, +}: ChartItemProps) { + const { chartConfig, visibility } = item; + const chartType = chartConfig?.chartType ?? "bar"; + const colors = chartConfig?.colors?.length + ? chartConfig.colors + : DEFAULT_COLORS; + const xKey = chartConfig?.xAxisColumn ?? "name"; + const yKey = chartConfig?.yAxisColumn ?? "value"; + + // 컨테이너가 너무 작으면 메시지 표시 + if (containerWidth < MIN_CHART_WIDTH) { + return ( +
+ 차트 +
+ ); + } + + // 데이터 없음 + if (!rows.length) { + return ( +
+ 데이터 없음 +
+ ); + } + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 차트 영역 */} +
+ + {chartType === "bar" ? ( + []}> + + + + + + ) : chartType === "line" ? ( + []}> + + + + 250} + /> + + ) : ( + /* pie */ + + []} + dataKey={yKey} + nameKey={xKey} + cx="50%" + cy="50%" + outerRadius="80%" + label={containerWidth > 250} + > + {rows.map((_, index) => ( + + ))} + + + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx new file mode 100644 index 00000000..e2b5dd30 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -0,0 +1,137 @@ +"use client"; + +/** + * 게이지 서브타입 컴포넌트 + * + * SVG 기반 반원형 게이지 (외부 라이브러리 불필요) + * min/max/target/current 표시, 달성률 구간별 색상 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface GaugeItemProps { + item: DashboardItem; + data: number | null; + /** 동적 목표값 (targetDataSource로 조회된 값) */ + targetValue?: number | null; +} + +// ===== 게이지 색상 판정 ===== + +function getGaugeColor( + percentage: number, + ranges?: { min: number; max: number; color: string }[] +): string { + if (ranges?.length) { + const match = ranges.find((r) => percentage >= r.min && percentage <= r.max); + if (match) return match.color; + } + // 기본 색상 (달성률 기준) + if (percentage >= 80) return "#10b981"; // emerald + if (percentage >= 50) return "#f59e0b"; // amber + return "#ef4444"; // rose +} + +// ===== 메인 컴포넌트 ===== + +export function GaugeItemComponent({ + item, + data, + targetValue, +}: GaugeItemProps) { + const { visibility, gaugeConfig } = item; + const current = data ?? 0; + const min = gaugeConfig?.min ?? 0; + const max = gaugeConfig?.max ?? 100; + const target = targetValue ?? gaugeConfig?.target ?? max; + + // 달성률 계산 (0~100) + const range = max - min; + const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0; + const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges); + + // SVG 반원 게이지 수치 + const cx = 100; + const cy = 90; + const radius = 70; + // 반원: 180도 -> percentage에 비례한 각도 + const startAngle = Math.PI; // 180도 (왼쪽) + const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향 + + const startX = cx + radius * Math.cos(startAngle); + const startY = cy - radius * Math.sin(startAngle); + const endX = cx + radius * Math.cos(endAngle); + const endY = cy - radius * Math.sin(endAngle); + const largeArcFlag = percentage > 50 ? 1 : 0; + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 게이지 SVG */} +
+ + {/* 배경 반원 (회색) */} + + + {/* 값 반원 (색상) */} + {percentage > 0 && ( + + )} + + {/* 중앙 텍스트 */} + {visibility.showValue && ( + + {abbreviateNumber(current)} + + )} + + {/* 퍼센트 */} + + {percentage.toFixed(1)}% + + +
+ + {/* 목표값 */} + {visibility.showTarget && ( +

+ 목표: {abbreviateNumber(target)} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx new file mode 100644 index 00000000..1cb09e74 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -0,0 +1,111 @@ +"use client"; + +/** + * KPI 카드 서브타입 컴포넌트 + * + * 큰 숫자 + 단위 + 증감 표시 + * CSS Container Query로 반응형 내부 콘텐츠 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface KpiCardProps { + item: DashboardItem; + data: number | null; + /** 이전 기간 대비 증감 퍼센트 (선택) */ + trendValue?: number | null; + /** 수식 결과 표시 문자열 (formula가 있을 때) */ + formulaDisplay?: string | null; +} + +// ===== 증감 표시 ===== + +function TrendIndicator({ value }: { value: number }) { + const isPositive = value > 0; + const isZero = value === 0; + const color = isPositive + ? "text-emerald-600" + : isZero + ? "text-muted-foreground" + : "text-rose-600"; + const arrow = isPositive ? "↑" : isZero ? "→" : "↓"; + + return ( + + {arrow} + {Math.abs(value).toFixed(1)}% + + ); +} + +// ===== 색상 구간 판정 ===== + +function getColorForValue( + value: number, + ranges?: { min: number; max: number; color: string }[] +): string | undefined { + if (!ranges?.length) return undefined; + const match = ranges.find((r) => value >= r.min && value <= r.max); + return match?.color; +} + +// ===== 메인 컴포넌트 ===== + +export function KpiCardComponent({ + item, + data, + trendValue, + formulaDisplay, +}: KpiCardProps) { + const { visibility, kpiConfig } = item; + const displayValue = data ?? 0; + const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 메인 값 */} + {visibility.showValue && ( +
+ + {formulaDisplay ?? abbreviateNumber(displayValue)} + + + {/* 단위 */} + {visibility.showUnit && kpiConfig?.unit && ( + + {kpiConfig.unit} + + )} +
+ )} + + {/* 증감율 */} + {visibility.showTrend && trendValue != null && ( +
+ +
+ )} + + {/* 보조 라벨 (수식 표시 등) */} + {visibility.showSubLabel && formulaDisplay && ( +

+ {item.formula?.values.map((v) => v.label).join(" / ")} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx new file mode 100644 index 00000000..f12e4e05 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -0,0 +1,91 @@ +"use client"; + +/** + * 통계 카드 서브타입 컴포넌트 + * + * 상태별 건수 표시 (대기/진행/완료 등) + * 각 카테고리별 색상 및 링크 지원 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface StatCardProps { + item: DashboardItem; + /** 카테고리별 건수 맵 (카테고리 label -> 건수) */ + categoryData: Record; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_STAT_COLORS = [ + "#6366f1", // indigo + "#f59e0b", // amber + "#10b981", // emerald + "#ef4444", // rose + "#8b5cf6", // violet +]; + +// ===== 메인 컴포넌트 ===== + +export function StatCardComponent({ item, categoryData }: StatCardProps) { + const { visibility, statConfig } = item; + const categories = statConfig?.categories ?? []; + const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 총합 */} + {visibility.showValue && ( +

+ {abbreviateNumber(total)} +

+ )} + + {/* 카테고리별 건수 */} +
+ {categories.map((cat, index) => { + const count = categoryData[cat.label] ?? 0; + const color = + cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length]; + + return ( +
+ {/* 색상 점 */} + + {/* 라벨 + 건수 */} + + {cat.label} + + + {abbreviateNumber(count)} + +
+ ); + })} +
+ + {/* 보조 라벨 (단위 등) */} + {visibility.showSubLabel && ( +

+ {visibility.showUnit && item.kpiConfig?.unit + ? `단위: ${item.kpiConfig.unit}` + : ""} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx new file mode 100644 index 00000000..51a05814 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * 좌우 버튼 표시 모드 + * + * 화살표 버튼으로 아이템을 한 장씩 넘기는 모드 + * 터치 최적화: 최소 44x44px 터치 영역 + */ + +import React, { useState, useCallback } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +// ===== Props ===== + +export interface ArrowsModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ArrowsModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ArrowsModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrev = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + }, [itemCount]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + }, [itemCount]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 콘텐츠 + 화살표 */} +
+ {/* 왼쪽 화살표 */} + {itemCount > 1 && ( + + )} + + {/* 아이템 */} +
+ {renderItem(currentIndex)} +
+ + {/* 오른쪽 화살표 */} + {itemCount > 1 && ( + + )} +
+ + {/* 페이지 인디케이터 */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx new file mode 100644 index 00000000..a984bbf6 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx @@ -0,0 +1,141 @@ +"use client"; + +/** + * 자동 슬라이드 표시 모드 + * + * 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개 + * 컴포넌트 unmount 시 타이머 정리 필수 + */ + +import React, { useState, useEffect, useRef, useCallback } from "react"; + +// ===== Props ===== + +export interface AutoSlideModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 자동 전환 간격 (초, 기본 5) */ + interval?: number; + /** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */ + resumeDelay?: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function AutoSlideModeComponent({ + itemCount, + interval = 5, + resumeDelay = 3, + showIndicator = true, + renderItem, +}: AutoSlideModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const intervalRef = useRef | null>(null); + const resumeTimerRef = useRef | null>(null); + + // 타이머 정리 함수 + const clearTimers = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (resumeTimerRef.current) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + }, []); + + // 자동 슬라이드 시작 + const startAutoSlide = useCallback(() => { + clearTimers(); + if (itemCount <= 1) return; + + intervalRef.current = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % itemCount); + }, interval * 1000); + }, [itemCount, interval, clearTimers]); + + // 터치/클릭으로 일시 정지 + const handlePause = useCallback(() => { + setIsPaused(true); + clearTimers(); + + // resumeDelay 후 자동 재개 + resumeTimerRef.current = setTimeout(() => { + setIsPaused(false); + startAutoSlide(); + }, resumeDelay * 1000); + }, [resumeDelay, clearTimers, startAutoSlide]); + + // 마운트 시 자동 슬라이드 시작, unmount 시 정리 + useEffect(() => { + if (!isPaused) { + startAutoSlide(); + } + return clearTimers; + }, [isPaused, startAutoSlide, clearTimers]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 콘텐츠 (슬라이드 애니메이션) */} +
+
+
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+
+
+ + {/* 인디케이터 + 일시정지 표시 */} + {showIndicator && itemCount > 1 && ( +
+ {isPaused && ( + 일시정지 + )} + {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx new file mode 100644 index 00000000..36a75934 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * 그리드 표시 모드 + * + * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) + * 각 셀에 @container 적용하여 내부 아이템 반응형 + */ + +import React from "react"; +import type { DashboardCell } from "../../types"; + +// ===== Props ===== + +export interface GridModeProps { + /** 셀 배치 정보 */ + cells: DashboardCell[]; + /** 열 수 */ + columns: number; + /** 행 수 */ + rows: number; + /** 아이템 간 간격 (px) */ + gap?: number; + /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ + renderItem: (itemId: string) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function GridModeComponent({ + cells, + columns, + rows, + gap = 8, + renderItem, +}: GridModeProps) { + if (!cells.length) { + return ( +
+ 셀 없음 +
+ ); + } + + return ( +
+ {cells.map((cell) => ( +
+ {cell.itemId ? ( + renderItem(cell.itemId) + ) : ( +
+ 빈 셀 +
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx new file mode 100644 index 00000000..300b637d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx @@ -0,0 +1,90 @@ +"use client"; + +/** + * 스크롤 표시 모드 + * + * 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅 + * 터치 스와이프 네이티브 지원 + */ + +import React, { useRef, useState, useEffect, useCallback } from "react"; + +// ===== Props ===== + +export interface ScrollModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ScrollModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ScrollModeProps) { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + // 스크롤 위치로 현재 인덱스 계산 + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || !el.clientWidth) return; + const index = Math.round(el.scrollLeft / el.clientWidth); + setActiveIndex(Math.min(index, itemCount - 1)); + }, [itemCount]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 스크롤 영역 */} +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+ + {/* 페이지 인디케이터 */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts new file mode 100644 index 00000000..74dfcd4c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -0,0 +1,235 @@ +/** + * pop-dashboard 데이터 페처 + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정 + * 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체. + * + * 보안: + * - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리 + * - 멀티테넌시: autoFilter 자동 전달 + * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 + */ + +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import type { DataSourceConfig, DataSourceFilter } from "../../types"; + +// ===== 반환 타입 ===== + +export interface AggregatedResult { + value: number; + rows?: Record[]; + error?: string; +} + +export interface ColumnInfo { + name: string; + type: string; + udtName: string; +} + +// ===== SQL 값 이스케이프 ===== + +/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ +function escapeSQL(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + // 문자열: 작은따옴표 이스케이프 + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +// ===== 필터 조건 SQL 생성 ===== + +/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ +function buildWhereClause(filters: DataSourceFilter[]): string { + if (!filters.length) return ""; + + const conditions = filters.map((f) => { + const col = sanitizeIdentifier(f.column); + + switch (f.operator) { + case "between": { + const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; + return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; + } + case "in": { + const arr = Array.isArray(f.value) ? f.value : [f.value]; + const vals = arr.map(escapeSQL).join(", "); + return `${col} IN (${vals})`; + } + case "like": + return `${col} LIKE ${escapeSQL(f.value)}`; + default: + return `${col} ${f.operator} ${escapeSQL(f.value)}`; + } + }); + + return `WHERE ${conditions.join(" AND ")}`; +} + +// ===== 식별자 검증 (테이블명, 컬럼명) ===== + +/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 집계 SQL 빌더 ===== + +/** + * DataSourceConfig를 SELECT SQL로 변환 + * + * @param config - 데이터 소스 설정 + * @returns SQL 문자열 + */ +export function buildAggregationSQL(config: DataSourceConfig): string { + const tableName = sanitizeIdentifier(config.tableName); + + // SELECT 절 + let selectClause: string; + if (config.aggregation) { + const aggType = config.aggregation.type.toUpperCase(); + const aggCol = sanitizeIdentifier(config.aggregation.column); + selectClause = `${aggType}(${aggCol}) as value`; + + // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 + if (config.aggregation.groupBy?.length) { + const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); + selectClause = `${groupCols}, ${selectClause}`; + } + } else { + selectClause = "*"; + } + + // FROM 절 (조인 포함) + let fromClause = tableName; + if (config.joins?.length) { + for (const join of config.joins) { + const joinTable = sanitizeIdentifier(join.targetTable); + const joinType = join.joinType.toUpperCase(); + const srcCol = sanitizeIdentifier(join.on.sourceColumn); + const tgtCol = sanitizeIdentifier(join.on.targetColumn); + fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; + } + } + + // WHERE 절 + const whereClause = config.filters?.length + ? buildWhereClause(config.filters) + : ""; + + // GROUP BY 절 + let groupByClause = ""; + if (config.aggregation?.groupBy?.length) { + groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; + } + + // ORDER BY 절 + let orderByClause = ""; + if (config.sort?.length) { + const sortCols = config.sort + .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) + .join(", "); + orderByClause = `ORDER BY ${sortCols}`; + } + + // LIMIT 절 + const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; + + return [ + `SELECT ${selectClause}`, + `FROM ${fromClause}`, + whereClause, + groupByClause, + orderByClause, + limitClause, + ] + .filter(Boolean) + .join(" "); +} + +// ===== 메인 데이터 페처 ===== + +/** + * DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환 + * + * API 선택 전략: + * 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery() + * 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원) + * 3. 단순 조회 -> dataApi.getTableData() + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체 + */ +export async function fetchAggregatedData( + config: DataSourceConfig +): Promise { + try { + // 집계 또는 조인이 있으면 SQL 직접 실행 + if (config.aggregation || (config.joins && config.joins.length > 0)) { + const sql = buildAggregationSQL(config); + const result = await dashboardApi.executeQuery(sql); + + if (result.rows.length === 0) { + return { value: 0, rows: [] }; + } + + // 첫 번째 행의 value 컬럼 추출 + const firstRow = result.rows[0]; + const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0); + + return { + value: Number.isFinite(numericValue) ? numericValue : 0, + rows: result.rows, + }; + } + + // 단순 조회 + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: config.filters?.reduce( + (acc, f) => { + acc[f.column] = f.value; + return acc; + }, + {} as Record + ), + }); + + // 단순 조회 시에는 행 수를 value로 사용 + return { + value: tableResult.total ?? tableResult.data.length, + rows: tableResult.data, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + return { value: 0, error: message }; + } +} + +// ===== 설정 패널용 헬퍼 ===== + +/** + * 테이블 목록 조회 (설정 패널 드롭다운용) + * dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되, + * 테이블 목록은 별도로 필요하므로 간단히 반환 + */ +export async function fetchTableColumns( + tableName: string +): Promise { + try { + const schema = await dashboardApi.getTableSchema(tableName); + return schema.columns.map((col) => ({ + name: col.name, + type: col.type, + udtName: col.udtName, + })); + } catch { + return []; + } +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts new file mode 100644 index 00000000..2ed27a98 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts @@ -0,0 +1,259 @@ +/** + * pop-dashboard 수식 파싱 및 평가 유틸리티 + * + * 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현. + */ + +import type { FormulaConfig, FormulaDisplayFormat } from "../../types"; + +// ===== 토큰 타입 ===== + +type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen"; + +interface Token { + type: TokenType; + value: string; +} + +// ===== 토크나이저 ===== + +/** 수식 문자열을 토큰 배열로 분리 */ +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; + let i = 0; + const expr = expression.replace(/\s+/g, ""); + + while (i < expr.length) { + const ch = expr[i]; + + // 숫자 (정수, 소수) + if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) { + let num = ""; + while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) { + num += expr[i]; + i++; + } + tokens.push({ type: "number", value: num }); + continue; + } + + // 변수 (A, B, C 등 알파벳) + if (/[A-Za-z]/.test(ch)) { + let varName = ""; + while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) { + varName += expr[i]; + i++; + } + tokens.push({ type: "variable", value: varName }); + continue; + } + + // 연산자 + if ("+-*/".includes(ch)) { + tokens.push({ type: "operator", value: ch }); + i++; + continue; + } + + // 괄호 + if (ch === "(") { + tokens.push({ type: "lparen", value: "(" }); + i++; + continue; + } + if (ch === ")") { + tokens.push({ type: "rparen", value: ")" }); + i++; + continue; + } + + // 알 수 없는 문자는 건너뜀 + i++; + } + + return tokens; +} + +// ===== 재귀 하강 파서 ===== + +/** + * 사칙연산 수식을 안전하게 평가 (재귀 하강 파서) + * + * 문법: + * expr = term (('+' | '-') term)* + * term = factor (('*' | '/') factor)* + * factor = NUMBER | VARIABLE | '(' expr ')' + * + * @param expression - 수식 문자열 (예: "A / B * 100") + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 계산 결과 (0으로 나누기 시 0 반환) + */ +export function evaluateFormula( + expression: string, + values: Record +): number { + const tokens = tokenize(expression); + let pos = 0; + + function peek(): Token | undefined { + return tokens[pos]; + } + + function consume(): Token { + return tokens[pos++]; + } + + // factor = NUMBER | VARIABLE | '(' expr ')' + function parseFactor(): number { + const token = peek(); + if (!token) return 0; + + if (token.type === "number") { + consume(); + return parseFloat(token.value); + } + + if (token.type === "variable") { + consume(); + return values[token.value] ?? 0; + } + + if (token.type === "lparen") { + consume(); // '(' 소비 + const result = parseExpr(); + if (peek()?.type === "rparen") { + consume(); // ')' 소비 + } + return result; + } + + // 예상치 못한 토큰 + consume(); + return 0; + } + + // term = factor (('*' | '/') factor)* + function parseTerm(): number { + let result = parseFactor(); + while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) { + const op = consume().value; + const right = parseFactor(); + if (op === "*") { + result *= right; + } else { + // 0으로 나누기 방지 + result = right === 0 ? 0 : result / right; + } + } + return result; + } + + // expr = term (('+' | '-') term)* + function parseExpr(): number { + let result = parseTerm(); + while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) { + const op = consume().value; + const right = parseTerm(); + result = op === "+" ? result + right : result - right; + } + return result; + } + + const result = parseExpr(); + return Number.isFinite(result) ? result : 0; +} + +/** + * 수식 결과를 displayFormat에 맞게 포맷팅 + * + * @param config - 수식 설정 + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 포맷된 문자열 + */ +export function formatFormulaResult( + config: FormulaConfig, + values: Record +): string { + const formatMap: Record string> = { + value: () => { + const result = evaluateFormula(config.expression, values); + return formatNumber(result); + }, + fraction: () => { + // "1,234 / 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + percent: () => { + const result = evaluateFormula(config.expression, values); + return `${(result * 100).toFixed(1)}%`; + }, + ratio: () => { + // "1,234 : 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + }; + + return formatMap[config.displayFormat](); +} + +/** + * 수식에 사용된 변수 ID가 모두 존재하는지 검증 + * + * @param expression - 수식 문자열 + * @param availableIds - 사용 가능한 변수 ID 배열 + * @returns 유효 여부 + */ +export function validateExpression( + expression: string, + availableIds: string[] +): boolean { + const tokens = tokenize(expression); + const usedVars = tokens + .filter((t) => t.type === "variable") + .map((t) => t.value); + + return usedVars.every((v) => availableIds.includes(v)); +} + +/** + * 큰 숫자 축약 (Container Query 축소 시 사용) + * + * 1234 -> "1,234" + * 12345 -> "1.2만" + * 1234567 -> "123.5만" + * 123456789 -> "1.2억" + */ +export function abbreviateNumber(value: number): string { + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + + if (abs >= 100_000_000) { + return `${sign}${(abs / 100_000_000).toFixed(1)}억`; + } + if (abs >= 10_000) { + return `${sign}${(abs / 10_000).toFixed(1)}만`; + } + return `${sign}${formatNumber(abs)}`; +} + +// ===== 내부 헬퍼 ===== + +/** 숫자를 천 단위 콤마 포맷 */ +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString("ko-KR"); + } + // 소수점 이하 최대 2자리 + return value.toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} diff --git a/frontend/lib/registry/pop-components/pop-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx index 8cad19ad..ab8b9e92 100644 --- a/frontend/lib/registry/pop-components/pop-text.tsx +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -269,11 +269,14 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) { function DateTimeDisplay({ config }: { config?: PopTextConfig }) { const [now, setNow] = useState(new Date()); + // isRealtime 기본값: true (설정 패널 UI와 일치) + const isRealtime = config?.isRealtime ?? true; + useEffect(() => { - if (!config?.isRealtime) return; + if (!isRealtime) return; const timer = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(timer); - }, [config?.isRealtime]); + }, [isRealtime]); // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) const dateFormat = config?.dateTimeConfig diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index f743d766..4e8ae079 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -86,3 +86,237 @@ export const JUSTIFY_CLASSES: Record = { center: "justify-center", right: "justify-end", }; + +// ============================================= +// Phase 0 공통 타입 (모든 POP 컴포넌트 공용) +// ============================================= + +// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 ----- + +export type ColumnMode = "read" | "write" | "readwrite" | "hidden"; + +export interface ColumnBinding { + columnName: string; + sourceTable?: string; + mode: ColumnMode; + label?: string; + defaultValue?: unknown; +} + +// ----- 조인 설정: 테이블 간 관계 정의 ----- + +export type JoinType = "inner" | "left" | "right"; + +export interface JoinConfig { + targetTable: string; + joinType: JoinType; + on: { + sourceColumn: string; + targetColumn: string; + }; + columns?: string[]; +} + +// ----- 데이터 소스: 테이블 조회/집계 통합 설정 ----- + +export type AggregationType = "count" | "sum" | "avg" | "min" | "max"; +export type FilterOperator = + | "=" + | "!=" + | ">" + | ">=" + | "<" + | "<=" + | "like" + | "in" + | "between"; + +export interface DataSourceFilter { + column: string; + operator: FilterOperator; + value: unknown; // between이면 [from, to] +} + +export interface SortConfig { + column: string; + direction: "asc" | "desc"; +} + +export interface DataSourceConfig { + tableName: string; + columns?: ColumnBinding[]; + filters?: DataSourceFilter[]; + sort?: SortConfig[]; + aggregation?: { + type: AggregationType; + column: string; + groupBy?: string[]; + }; + joins?: JoinConfig[]; + refreshInterval?: number; // 초 단위, 0이면 비활성 + limit?: number; +} + +// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 ----- + +export interface PopActionConfig { + type: + | "navigate" + | "modal" + | "save" + | "delete" + | "api" + | "event" + | "refresh"; + // navigate + targetScreenId?: string; + params?: Record; + // modal + modalScreenId?: string; + modalTitle?: string; + // save/delete + targetTable?: string; + confirmMessage?: string; + // api + apiEndpoint?: string; + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; + // event + eventName?: string; + eventPayload?: Record; +} + +// ============================================= +// pop-dashboard 전용 타입 +// ============================================= + +// ----- 표시 모드 / 서브타입 ----- + +export type DashboardDisplayMode = + | "arrows" + | "auto-slide" + | "grid" + | "scroll"; +export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card"; +export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio"; +export type ChartType = "bar" | "pie" | "line"; +export type TrendPeriod = "daily" | "weekly" | "monthly"; + +// ----- 색상 구간 ----- + +export interface ColorRange { + min: number; + max: number; + color: string; // hex 또는 Tailwind 색상 +} + +// ----- 수식(계산식) 설정 ----- + +export interface FormulaValue { + id: string; // "A", "B" 등 + dataSource: DataSourceConfig; + label: string; // "생산량", "총재고량" +} + +export interface FormulaConfig { + enabled: boolean; + values: FormulaValue[]; + expression: string; // "A / B", "A + B", "A / B * 100" + displayFormat: FormulaDisplayFormat; +} + +// ----- 아이템 내 요소별 보이기/숨기기 ----- + +export interface ItemVisibility { + showLabel: boolean; + showValue: boolean; + showUnit: boolean; + showTrend: boolean; + showSubLabel: boolean; + showTarget: boolean; +} + +// ----- 서브타입별 설정 ----- + +export interface KpiCardConfig { + unit?: string; // "EA", "톤", "원" + colorRanges?: ColorRange[]; + showTrend?: boolean; + trendPeriod?: TrendPeriod; +} + +export interface ChartItemConfig { + chartType: ChartType; + xAxisColumn?: string; + yAxisColumn?: string; + colors?: string[]; +} + +export interface GaugeConfig { + min: number; + max: number; + target?: number; // 고정 목표값 + targetDataSource?: DataSourceConfig; // 동적 목표값 + colorRanges?: ColorRange[]; +} + +export interface StatCategory { + label: string; // "대기", "진행", "완료" + filter: DataSourceFilter; + color?: string; +} + +export interface StatCardConfig { + categories: StatCategory[]; + showLink?: boolean; + linkAction?: PopActionConfig; +} + +// ----- 그리드 모드 셀 (엑셀형 분할/병합) ----- + +export interface DashboardCell { + id: string; + gridColumn: string; // CSS Grid 값: "1 / 3" + gridRow: string; // CSS Grid 값: "1 / 2" + itemId: string | null; // null이면 빈 셀 +} + +// ----- 대시보드 아이템 ----- + +export interface DashboardItem { + id: string; + label: string; // pop-system 보이기/숨기기용 + visible: boolean; + subType: DashboardSubType; + dataSource: DataSourceConfig; + + // 요소별 보이기/숨기기 + visibility: ItemVisibility; + + // 계산식 (선택사항) + formula?: FormulaConfig; + + // 서브타입별 설정 (subType에 따라 하나만 사용) + kpiConfig?: KpiCardConfig; + chartConfig?: ChartItemConfig; + gaugeConfig?: GaugeConfig; + statConfig?: StatCardConfig; +} + +// ----- 대시보드 전체 설정 ----- + +export interface PopDashboardConfig { + items: DashboardItem[]; + 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; +} From 73e3d5638136d591aad492301182ecee05c9d033 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 12:20:44 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(pop-dashboard):=20React=20Hooks=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=9C=84=EB=B0=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?+=20ConfigPanel=20props=20=EC=A0=95=ED=95=A9=EC=84=B1=20+=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PopDashboardComponent: early return을 모든 hooks 이후로 이동 (Rules of Hooks) - PopDashboardConfigPanel: onChange -> onUpdate prop 이름 정합, 빈 객체 config 방어 - PopDashboardPreview: Array.isArray 방어 추가 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardComponent.tsx | 30 ++++++++++--------- .../pop-dashboard/PopDashboardConfig.tsx | 7 +++-- .../pop-dashboard/PopDashboardPreview.tsx | 3 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 53940b0a..7c6e7c50 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -111,19 +111,10 @@ export function PopDashboardComponent({ const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(300); - // 빈 설정 - if (!config || !config.items.length) { - return ( -
- - 대시보드 아이템을 추가하세요 - -
- ); - } - - // 보이는 아이템만 필터링 - const visibleItems = config.items.filter((item) => item.visible); + // 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용) + const visibleItems = Array.isArray(config?.items) + ? config.items.filter((item) => item.visible) + : []; // 컨테이너 크기 감지 useEffect(() => { @@ -140,6 +131,7 @@ export function PopDashboardComponent({ }, []); // 데이터 로딩 함수 + // eslint-disable-next-line react-hooks/exhaustive-deps const fetchAllData = useCallback(async () => { if (!visibleItems.length) { setLoading(false); @@ -165,7 +157,6 @@ export function PopDashboardComponent({ setDataMap(newDataMap); setLoading(false); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(visibleItems.map((i) => i.id))]); // 초기 로딩 + 주기적 새로고침 @@ -186,6 +177,17 @@ export function PopDashboardComponent({ }; }, [fetchAllData, visibleItems]); + // 빈 설정 (모든 hooks 이후에 early return) + if (!config || !config.items?.length) { + return ( +
+ + 대시보드 아이템을 추가하세요 + +
+ ); + } + // 단일 아이템 렌더링 const renderSingleItem = (item: DashboardItem) => { const itemData = dataMap[item.id]; diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 74126c22..26da10f5 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -45,7 +45,7 @@ import { validateExpression } from "./utils/formula"; interface ConfigPanelProps { config: PopDashboardConfig | undefined; - onChange: (config: PopDashboardConfig) => void; + onUpdate: (config: PopDashboardConfig) => void; } // ===== 기본값 ===== @@ -806,9 +806,10 @@ function GridLayoutEditor({ export function PopDashboardConfigPanel({ config, - onChange, + onUpdate: onChange, }: ConfigPanelProps) { - const cfg = config ?? DEFAULT_CONFIG; + // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 + const cfg: PopDashboardConfig = { ...DEFAULT_CONFIG, ...(config || {}) }; const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( "basic" ); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx index db64f04c..8d530b96 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -64,7 +64,8 @@ export function PopDashboardPreviewComponent({ }: { config?: PopDashboardConfig; }) { - if (!config || !config.items.length) { + // config가 빈 객체 {} 또는 items가 없는 경우 방어 + if (!config || !Array.isArray(config.items) || !config.items.length) { return (
From dc523d86c3f2e484e4f6a21cb6fbc625e5371179 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 14:22:30 +0900 Subject: [PATCH 3/8] =?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; } From 578cca2687bcfbad541183e1cefc399ac271df12 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 16:12:29 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(pop-dashboard):=204=EA=B0=80=EC=A7=80?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=AA=A8=EB=93=9C=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20-=20=EC=84=A4=EC=A0=95=20UI=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 (PopDashboardConfig): - groupBy(X축 분류) Combobox 설정 UI 추가 - 차트 xAxisColumn/yAxisColumn 입력 UI 추가 - 통계 카드 카테고리 추가/삭제/편집 인라인 에디터 추가 - 대상 컬럼 Select를 Combobox(검색 가능)로 개선 데이터 처리 버그 수정 (PopDashboardComponent): - 차트: groupBy 있을 때 xAxisColumn 자동 보정 로직 추가 - 통계 카드: 카테고리별 필터 실제 적용 (기존: 모든 카테고리에 rows.length 동일 입력) - useCallback 의존성 안정화 (visibleItemIds 문자열 키 사용) - refreshInterval 최소 5초 강제 데이터 fetcher 방어 로직 (dataFetcher.ts): - validateDataSourceConfig() 추가: 설정 미완료 시 SQL 전송 차단 - 빈 필터/불완전 조인 건너뜀 처리 - COUNT 컬럼 미선택 시 COUNT(*) 자동 처리 - fetchTableColumns() 이중 폴백 (tableManagementApi -> dashboardApi) 아이템 UI 개선: - KPI/차트/게이지/통계 카드 패딩 및 폰트 크기 조정 - 작은 셀에서도 라벨/단위/증감율 표시되도록 hidden 제거 기타: - GridMode MIN_CELL_WIDTH 160 -> 80 축소 - PLAN.MD: 대시보드 4가지 아이템 모드 완성 계획으로 갱신 - STATUS.md: 프로젝트 상태 추적 파일 추가 Co-authored-by: Cursor --- PLAN.MD | 536 +++++++++++++++--- STATUS.md | 48 ++ .../src/controllers/screenGroupController.ts | 2 +- .../pop-dashboard/PopDashboardComponent.tsx | 60 +- .../pop-dashboard/PopDashboardConfig.tsx | 412 ++++++++++++-- .../pop-dashboard/items/ChartItem.tsx | 4 +- .../pop-dashboard/items/GaugeItem.tsx | 6 +- .../pop-dashboard/items/KpiCard.tsx | 14 +- .../pop-dashboard/items/StatCard.tsx | 6 +- .../pop-dashboard/modes/GridMode.tsx | 2 +- .../pop-dashboard/utils/dataFetcher.ts | 90 ++- 11 files changed, 1030 insertions(+), 150 deletions(-) create mode 100644 STATUS.md diff --git a/PLAN.MD b/PLAN.MD index e4f4e424..45468fa4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,135 +1,527 @@ -# 현재 구현 계획: POP 뷰어 스크롤 수정 +# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 -> **작성일**: 2026-02-09 -> **상태**: 계획 완료, 코딩 대기 -> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정 +> **작성일**: 2026-02-10 +> **상태**: 코딩 완료 (방어 로직 패치 포함) +> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 --- ## 1. 문제 요약 -설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만, -뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임. +pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. -**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단 - -| # | 컨테이너 (라인) | 현재 클래스 | 문제 | -|---|----------------|-------------|------| -| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 | -| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 | -| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 | +| # | 문제 | 심각도 | 영향 | +|---|------|--------|------| +| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | +| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | +| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | +| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | --- -## 2. 수정 대상 파일 (1개) +## 2. 수정 대상 파일 (2개) -### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` +### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` -**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음) +**변경 유형**: 설정 UI 추가 3건 -#### 변경 1: 라인 185 - 최외곽 컨테이너 +#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) -**현재 코드**: +집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. + +**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 + +**추가할 코드** (약 50줄): + +```tsx +{/* 그룹핑 (차트용 X축 분류) */} +{dataSource.aggregation && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + const current = dataSource.aggregation?.groupBy ?? []; + const isSelected = current.includes(col.name); + const newGroupBy = isSelected + ? current.filter((g) => g !== col.name) + : [...current, col.name]; + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, + }, + }); + setGroupByOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.type}) + + ))} + + + + + +

+ 차트에서 X축 카테고리로 사용됩니다 +

+
+)} ``` -
+ +**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆): + +```tsx +const [groupByOpen, setGroupByOpen] = useState(false); +``` + +#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근) + +**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음 + +**추가할 코드** (약 30줄): + +```tsx +{/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +

+
+``` + +#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음) + +**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가 + +**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록 + +```tsx +{item.subType === "stat-card" && ( +
+
+ + +
+ + {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( +
+
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, label: e.target.value }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="라벨 (예: 수주)" + className="h-6 flex-1 text-xs" + /> + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, color: e.target.value || undefined }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="#색상코드" + className="h-6 w-20 text-xs" + /> + +
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */} +
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, column: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="컬럼" + className="h-6 w-20 text-[10px]" + /> + + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, value: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> +
+
+ ))} + + {(item.statConfig?.categories ?? []).length === 0 && ( +

+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +

+ )} +
+)} +``` + +--- + +### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx` + +**변경 유형**: 데이터 처리 로직 수정 2건 + +#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근) + +차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영. + +**현재 코드** (라인 276~283): +```tsx +case "chart": + return ( + + ); ``` **변경 코드**: -``` -
+```tsx +case "chart": { + // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 + const chartItem = { ...item }; + if ( + item.dataSource.aggregation?.groupBy?.length && + !item.chartConfig?.xAxisColumn + ) { + chartItem.chartConfig = { + ...chartItem.chartConfig, + chartType: chartItem.chartConfig?.chartType ?? "bar", + xAxisColumn: item.dataSource.aggregation.groupBy[0], + }; + } + return ( + + ); +} ``` -**변경 내용**: `overflow-hidden` 제거 -**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거 +#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) -#### 변경 2: 라인 266 - 컨텐츠 영역 - -**현재 코드**: -``` -
+**현재 코드** (버그): +```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값 + } + } + return ( + + ); +} ``` **변경 코드**: -``` -
+```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + if (cat.filter.column && cat.filter.value) { + // 카테고리 필터로 rows 필터링 + const filtered = itemData.rows.filter((row) => { + const cellValue = String(row[cat.filter.column] ?? ""); + const filterValue = String(cat.filter.value ?? ""); + switch (cat.filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case "like": + return cellValue.toLowerCase().includes(filterValue.toLowerCase()); + default: + return cellValue === filterValue; + } + }); + categoryData[cat.label] = filtered.length; + } else { + categoryData[cat.label] = itemData.rows.length; + } + } + } + return ( + + ); +} ``` -**변경 내용**: `overflow-auto`를 조건문 밖으로 이동 (공통 적용) -**이유**: 프리뷰/일반 모드 모두 스크롤이 필요함 - -#### 변경 3: 라인 275 - 백색 배경 컨테이너 - -**현재 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`} -``` - -**변경 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`} -``` - -**변경 내용**: 일반 모드에 `min-h-full` 추가 -**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장 +**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. --- ## 3. 구현 순서 (의존성 기반) -| 순서 | 작업 | 라인 | 의존성 | 상태 | +| 순서 | 작업 | 파일 | 의존성 | 상태 | |------|------|------|--------|------| -| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 | -| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 | -| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 | -| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 | -| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 | +| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] | +| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] | +| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] | +| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] | +| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] | +| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] | + +순서 1, 2, 3은 서로 독립이므로 병렬 가능. +순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. +순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음. +순서 7, 8은 백엔드 부하 방지를 위한 방어 패치. --- ## 4. 사전 충돌 검사 결과 -**새로 추가할 변수/함수/타입: 없음** +### 새로 추가할 식별자 목록 -이번 수정은 기존 Tailwind CSS 클래스 문자열만 변경합니다. -새로운 식별자(변수, 함수, 타입)를 추가하지 않으므로 충돌 검사 대상이 없습니다. +| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | +|--------|------|-----------|-----------|-----------| +| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 | + +**Grep 검색 결과** (전체 pop-dashboard 폴더): +- `groupByOpen`: 0건 - 충돌 없음 +- `setGroupByOpen`: 0건 - 충돌 없음 +- `groupByColumns`: 0건 - 충돌 없음 +- `chartItem`: 0건 - 충돌 없음 +- `StatCategoryEditor`: 0건 - 충돌 없음 +- `loadCategoryData`: 0건 - 충돌 없음 + +### 기존 타입/함수 재사용 목록 + +| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | +|------------|-----------|------------------------| +| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 | +| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 | +| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 | +| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 | +| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select | +| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 | + +**사용처 있는데 정의 누락된 항목: 없음** --- ## 5. 에러 함정 경고 -### 함정 1: 순서 1만 하고 순서 2를 빼먹으면 -`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨. -부모는 열었지만 자식에 스크롤 속성이 없는 상태. +### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 +ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. +`name` 키가 없으므로 X축이 빈 채로 렌더링됨. +**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. -### 함정 2: 순서 2만 하고 순서 1을 빼먹으면 -자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨. -**반드시 순서 1과 2를 함께 적용해야 함.** +### 함정 2: 통계 카드에 집계 함수를 설정하면 +집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. +카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. +통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. +설정 가이드 문서에 이 점을 명시해야 함. -### 함정 3: 프리뷰 모드 영향 -프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음. -`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음. +### 함정 3: PopDashboardConfig.tsx의 import 누락 +현재 `FilterOperator`는 이미 import되어 있음 (라인 54). +`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요. +**새로운 import 추가 필요 없음.** + +### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교 +`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨. +`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음. +현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의. + +### 함정 5: DataSourceEditor의 columns state 타이밍 +`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. +기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음. --- ## 6. 검증 방법 -1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준) -2. 화면 아래로 스크롤 가능한지 확인 -3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인 -4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인 -5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인 +### 차트 (BUG-1, BUG-2) +1. 아이템 추가 > "차트" 선택 +2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status` +3. 차트 유형: 막대 차트 +4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1 + +### 통계 카드 (BUG-3, BUG-4) +1. 아이템 추가 > "통계 카드" 선택 +2. 테이블: `sales_order_mng`, **집계: 없음** (중요!) +3. 카테고리 추가: + - "수주" / status / = / 수주 + - "진행중" / status / = / 진행중 + - "완료" / status / = / 완료 +4. 기대 결과: 수주 79, 진행중 7, 완료 1 --- ## 이전 완료 계획 (아카이브) +
+POP 뷰어 스크롤 수정 (완료) + +- [x] 라인 185: overflow-hidden 제거 +- [x] 라인 266: overflow-auto 공통 적용 +- [x] 라인 275: 일반 모드 min-h-full 추가 +- [x] 린트 검사 통과 + +
+
POP 뷰어 실제 컴포넌트 렌더링 (완료) - [x] 뷰어 페이지에 레지스트리 초기화 import 추가 -- [x] `renderActualComponent()` 실제 컴포넌트 렌더링으로 교체 +- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - [x] 린트 검사 통과 -- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..5e83ff11 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,48 @@ +# 프로젝트 상태 추적 + +> **최종 업데이트**: 2026-02-10 + +--- + +## 현재 진행 중 + +### pop-dashboard 4가지 아이템 모드 완성 +**상태**: 코딩 완료, 브라우저 테스트 대기 +**계획서**: [PLAN.MD](./PLAN.MD) + +--- + +## 다음 작업 + +| 순서 | 작업 | 파일 | 상태 | +|------|------|------|------| +| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 | + +--- + +## 완료된 작업 (최근) + +| 날짜 | 작업 | 비고 | +|------|------|------| +| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI | +| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 | +| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 | +| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 | +| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 | +| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 | +| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | +| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | +| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | + +--- + +## 알려진 이슈 + +| # | 이슈 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) | +| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) | +| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) | +| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) | +| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..b53454b9 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 0f1aba1a..97c4df97 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -187,6 +187,9 @@ export function PopDashboardComponent({ return () => observer.disconnect(); }, []); + // 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지) + const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id)); + // 데이터 로딩 함수 // eslint-disable-next-line react-hooks/exhaustive-deps const fetchAllData = useCallback(async () => { @@ -214,15 +217,18 @@ export function PopDashboardComponent({ setDataMap(newDataMap); setLoading(false); - }, [JSON.stringify(visibleItems.map((i) => i.id))]); + }, [visibleItemIds]); // 초기 로딩 + 주기적 새로고침 useEffect(() => { fetchAllData(); - // refreshInterval 적용 (첫 번째 아이템 기준) - const refreshSec = visibleItems[0]?.dataSource.refreshInterval; - if (refreshSec && refreshSec > 0) { + // refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제) + const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval; + const refreshSec = rawRefreshSec && rawRefreshSec > 0 + ? Math.max(5, rawRefreshSec) + : 0; + if (refreshSec > 0) { refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000); } @@ -232,7 +238,9 @@ export function PopDashboardComponent({ refreshTimerRef.current = null; } }; - }, [fetchAllData, visibleItems]); + // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchAllData, visibleItemIds]); // 빈 설정 (모든 hooks 이후에 early return) if (!config || !config.items?.length) { @@ -273,23 +281,55 @@ export function PopDashboardComponent({ formulaDisplay={itemData.formulaDisplay} /> ); - case "chart": + case "chart": { + // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 + const chartItem = { ...item }; + if ( + item.dataSource.aggregation?.groupBy?.length && + !item.chartConfig?.xAxisColumn + ) { + chartItem.chartConfig = { + ...chartItem.chartConfig, + chartType: chartItem.chartConfig?.chartType ?? "bar", + xAxisColumn: item.dataSource.aggregation.groupBy[0], + }; + } return ( ); + } case "gauge": return ; case "stat-card": { - // StatCard: 카테고리별 건수 맵 구성 + // StatCard: 카테고리별 건수 맵 구성 (필터 적용) const categoryData: Record = {}; if (item.statConfig?.categories) { for (const cat of item.statConfig.categories) { - // 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준) - categoryData[cat.label] = itemData.rows.length; + if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") { + // 카테고리 필터로 rows 필터링 + const filtered = itemData.rows.filter((row) => { + const cellValue = String(row[cat.filter.column] ?? ""); + const filterValue = String(cat.filter.value ?? ""); + switch (cat.filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case "like": + return cellValue.toLowerCase().includes(filterValue.toLowerCase()); + default: + return cellValue === filterValue; + } + }); + categoryData[cat.label] = filtered.length; + } else { + // 필터 미설정 시 전체 건수 + categoryData[cat.label] = itemData.rows.length; + } } } return ( diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 1b0ec03c..66a56876 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -152,6 +152,10 @@ function DataSourceEditor({ // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); + const [columnOpen, setColumnOpen] = useState(false); + + // 그룹핑 컬럼 (차트 X축용) + const [groupByOpen, setGroupByOpen] = useState(false); // 마운트 시 테이블 목록 로드 useEffect(() => { @@ -285,32 +289,156 @@ function DataSourceEditor({ {dataSource.aggregation && (
- + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + column: col.name, + }, + }); + setColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + + ({col.type}) + + + ))} + + + + +
)}
+ {/* 그룹핑 (차트 X축 분류) */} + {dataSource.aggregation && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + const current = dataSource.aggregation?.groupBy ?? []; + const isSelected = current.includes(col.name); + const newGroupBy = isSelected + ? current.filter((g) => g !== col.name) + : [...current, col.name]; + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, + }, + }); + setGroupByOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.type}) + + ))} + + + + + +

+ 차트에서 X축 카테고리로 사용됩니다 +

+
+ )} + {/* 자동 새로고침 (Switch + 주기 입력) */}
@@ -1135,29 +1263,77 @@ function ItemEditor({ )} {item.subType === "chart" && ( -
- - +
+
+ + +
+ + {/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +

+
+ + {/* Y축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + yAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="집계 결과 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 집계 결과 컬럼. 비우면 집계 함수 결과를 자동 사용 +

+
)} @@ -1220,6 +1396,152 @@ function ItemEditor({
)} + + {/* 통계 카드 카테고리 설정 */} + {item.subType === "stat-card" && ( +
+
+ + +
+ + {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( +
+
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, label: e.target.value }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="라벨 (예: 수주)" + className="h-6 flex-1 text-xs" + /> + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, color: e.target.value || undefined }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="#색상코드" + className="h-6 w-20 text-xs" + /> + +
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */} +
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, column: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="컬럼" + className="h-6 w-20 text-[10px]" + /> + + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, value: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> +
+
+ ))} + + {(item.statConfig?.categories ?? []).length === 0 && ( +

+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +

+ )} +
+ )}
)}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index 66694a58..c1fbd6b6 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -84,10 +84,10 @@ export function ChartItemComponent({ } return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx index e2b5dd30..c7313a85 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -69,10 +69,10 @@ export function GaugeItemComponent({ const largeArcFlag = percentage > 50 ? 1 : 0; return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -128,7 +128,7 @@ export function GaugeItemComponent({ {/* 목표값 */} {visibility.showTarget && ( -

+

목표: {abbreviateNumber(target)}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 1cb09e74..29db2791 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -66,10 +66,10 @@ export function KpiCardComponent({ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -78,7 +78,7 @@ export function KpiCardComponent({ {visibility.showValue && (
{formulaDisplay ?? abbreviateNumber(displayValue)} @@ -86,7 +86,7 @@ export function KpiCardComponent({ {/* 단위 */} {visibility.showUnit && kpiConfig?.unit && ( - + {kpiConfig.unit} )} @@ -95,14 +95,12 @@ export function KpiCardComponent({ {/* 증감율 */} {visibility.showTrend && trendValue != null && ( -
- -
+ )} {/* 보조 라벨 (수식 표시 등) */} {visibility.showSubLabel && formulaDisplay && ( -

+

{item.formula?.values.map((v) => v.label).join(" / ")}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index f12e4e05..c3c02e7b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -37,10 +37,10 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -80,7 +80,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { {/* 보조 라벨 (단위 등) */} {visibility.showSubLabel && ( -

+

{visibility.showUnit && item.kpiConfig?.unit ? `단위: ${item.kpiConfig.unit}` : ""} 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 66c4f5e9..5e339fc5 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -18,7 +18,7 @@ import type { DashboardCell } from "../../types"; // ===== 상수 ===== /** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ -const MIN_CELL_WIDTH = 160; +const MIN_CELL_WIDTH = 80; // ===== Props ===== 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 64860699..4746b69b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -46,13 +46,55 @@ function escapeSQL(value: unknown): string { return `'${str}'`; } +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +function validateDataSourceConfig(config: DataSourceConfig): string | null { + // 테이블명 필수 + if (!config.tableName || !config.tableName.trim()) { + return "테이블이 선택되지 않았습니다"; + } + + // 집계 함수가 설정되었으면 대상 컬럼도 필수 + // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) + if (config.aggregation) { + const aggType = config.aggregation.type?.toLowerCase(); + const aggCol = config.aggregation.column?.trim(); + if (aggType !== "count" && !aggCol) { + return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; + } + } + + // 조인이 있으면 조인 조건 필수 + if (config.joins?.length) { + for (const join of config.joins) { + if (!join.targetTable?.trim()) { + return "조인 대상 테이블이 선택되지 않았습니다"; + } + if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + return "조인 조건 컬럼이 설정되지 않았습니다"; + } + } + } + + return null; +} + // ===== 필터 조건 SQL 생성 ===== /** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ function buildWhereClause(filters: DataSourceFilter[]): string { - if (!filters.length) return ""; + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; - const conditions = filters.map((f) => { + const conditions = validFilters.map((f) => { const col = sanitizeIdentifier(f.column); switch (f.operator) { @@ -98,8 +140,18 @@ export function buildAggregationSQL(config: DataSourceConfig): string { let selectClause: string; if (config.aggregation) { const aggType = config.aggregation.type.toUpperCase(); - const aggCol = sanitizeIdentifier(config.aggregation.column); - selectClause = `${aggType}(${aggCol}) as value`; + const aggCol = config.aggregation.column?.trim() + ? sanitizeIdentifier(config.aggregation.column) + : ""; + + // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + if (!aggCol) { + selectClause = aggType === "COUNT" + ? "COUNT(*) as value" + : `${aggType}(${tableName}.*) as value`; + } else { + selectClause = `${aggType}(${aggCol}) as value`; + } // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 if (config.aggregation.groupBy?.length) { @@ -110,10 +162,14 @@ export function buildAggregationSQL(config: DataSourceConfig): string { selectClause = "*"; } - // FROM 절 (조인 포함) + // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) let fromClause = tableName; if (config.joins?.length) { for (const join of config.joins) { + // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) + if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + continue; + } const joinTable = sanitizeIdentifier(join.targetTable); const joinType = join.joinType.toUpperCase(); const srcCol = sanitizeIdentifier(join.on.sourceColumn); @@ -173,6 +229,12 @@ export async function fetchAggregatedData( config: DataSourceConfig ): Promise { try { + // 설정 완료 여부 검증 (미완료 시 SQL 전송 차단) + const validationError = validateDataSourceConfig(config); + if (validationError) { + return { value: 0, rows: [], error: validationError }; + } + // 집계 또는 조인이 있으면 SQL 직접 실행 if (config.aggregation || (config.joins && config.joins.length > 0)) { const sql = buildAggregationSQL(config); @@ -228,6 +290,24 @@ export async function fetchAggregatedData( export async function fetchTableColumns( tableName: string ): Promise { + // 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적) + try { + const response = await tableManagementApi.getTableSchema(tableName); + if (response.success && response.data) { + const cols = Array.isArray(response.data) ? response.data : []; + if (cols.length > 0) { + return cols.map((col: any) => ({ + name: col.columnName || col.column_name || col.name, + type: col.dataType || col.data_type || col.type || "unknown", + udtName: col.dbType || col.udt_name || col.udtName || "unknown", + })); + } + } + } catch { + // tableManagementApi 실패 시 dashboardApi로 폴백 + } + + // 2차: dashboardApi (fetch 기반, 폴백) try { const schema = await dashboardApi.getTableSchema(tableName); return schema.columns.map((col) => ({ From 7a71fc6ca7b5674c8dcc10b3e70ca7197aef9324 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 16:55:34 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(pop-dashboard):=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?X/Y=EC=B6=95=20=EC=9E=90=EB=8F=99=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 간소화: - 차트 X축/Y축 수동 입력 필드 제거 (자동 적용 안내 문구로 대체) - groupBy 선택 시 X축 자동, 집계 결과를 Y축(value)으로 자동 매핑 차트 렌더링 개선 (ChartItem): - PieChart에 카테고리명+값+비율 라벨 표시 - Legend 컴포넌트 추가 (containerWidth 300px 이상 시) - Tooltip formatter로 이름/값 쌍 표시 데이터 fetcher 안정화 (dataFetcher): - apiClient(axios) 우선 호출, dashboardApi(fetch) 폴백 패턴 적용 - PostgreSQL bigint/numeric 문자열 -> 숫자 자동 변환 처리 - Recharts가 숫자 타입을 요구하는 문제 해결 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardConfig.tsx | 49 ++----------------- .../pop-dashboard/items/ChartItem.tsx | 23 +++++++-- .../pop-dashboard/utils/dataFetcher.ts | 38 ++++++++++++-- 3 files changed, 56 insertions(+), 54 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 66a56876..b75f9b66 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -1289,51 +1289,10 @@ function ItemEditor({

- {/* X축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - xAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="groupBy 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 -

-
- - {/* Y축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - yAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="집계 결과 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 집계 결과 컬럼. 비우면 집계 함수 결과를 자동 사용 -

-
+ {/* X축/Y축 자동 안내 */} +

+ X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용 +

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index c1fbd6b6..93f29b1c 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -19,6 +19,7 @@ import { XAxis, YAxis, Tooltip, + Legend, ResponsiveContainer, } from "recharts"; import type { DashboardItem } from "../../types"; @@ -124,7 +125,7 @@ export function ChartItemComponent({ /> ) : ( - /* pie */ + /* pie - 카테고리명 + 값 라벨 표시 */ []} @@ -132,8 +133,14 @@ export function ChartItemComponent({ nameKey={xKey} cx="50%" cy="50%" - outerRadius="80%" - label={containerWidth > 250} + outerRadius={containerWidth > 400 ? "70%" : "80%"} + label={ + containerWidth > 250 + ? ({ name, value, percent }: { name: string; value: number; percent: number }) => + `${name} ${value} (${(percent * 100).toFixed(0)}%)` + : false + } + labelLine={containerWidth > 250} > {rows.map((_, index) => ( ))} - + [value, name]} + /> + {containerWidth > 300 && ( + + )} )} 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 4746b69b..c2baaa55 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -10,6 +10,7 @@ * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 */ +import { apiClient } from "@/lib/api/client"; import { dashboardApi } from "@/lib/api/dashboard"; import { dataApi } from "@/lib/api/data"; import { tableManagementApi } from "@/lib/api/tableManagement"; @@ -238,19 +239,46 @@ export async function fetchAggregatedData( // 집계 또는 조인이 있으면 SQL 직접 실행 if (config.aggregation || (config.joins && config.joins.length > 0)) { const sql = buildAggregationSQL(config); - const result = await dashboardApi.executeQuery(sql); - if (result.rows.length === 0) { + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: any[] }; + try { + // 1차: apiClient (axios 기반, 인증/세션 안정적) + const response = await apiClient.post("/dashboards/execute-query", { query: sql }); + if (response.data?.success && response.data?.data) { + queryResult = response.data.data; + } else { + throw new Error(response.data?.message || "쿼리 실행 실패"); + } + } catch { + // 2차: dashboardApi (fetch 기반, 폴백) + queryResult = await dashboardApi.executeQuery(sql); + } + + if (queryResult.rows.length === 0) { return { value: 0, rows: [] }; } + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 + // Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리 + const processedRows = queryResult.rows.map((row: Record) => { + const converted: Record = { ...row }; + for (const key of Object.keys(converted)) { + const val = converted[key]; + if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { + converted[key] = Number(val); + } + } + return converted; + }); + // 첫 번째 행의 value 컬럼 추출 - const firstRow = result.rows[0]; - const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0); + const firstRow = processedRows[0]; + const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)); return { value: Number.isFinite(numericValue) ? numericValue : 0, - rows: result.rows, + rows: processedRows, }; } From bd7bf69a99fcfbc2b5a7657e5a997d37b8888dfa Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 17:18:00 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(pop-dashboard):=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C/=EB=AA=A8=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 버그 수정 (PopDashboardConfig): - gaugeConfig 스프레드 순서 수정: min/max/target 값이 기존값에 덮어씌워지는 문제 해결 - 스프레드를 먼저 적용 후 변경 필드를 뒤에 배치하여 올바르게 반영 아이템 레이아웃 개선: - KpiCard/StatCard: items-center justify-center 추가로 셀 내 중앙 정렬 - GaugeItem: SVG를 flex-1 영역에서 반응형 렌더링 (h-full w-auto) - GaugeItem: preserveAspectRatio로 비율 유지, 라벨/목표값 shrink-0 모드 레이아웃 개선: - ArrowsMode: 아이템이 전체 영역 사용, 화살표/인디케이터를 overlay로 변경 - ArrowsMode: 화살표 크기 축소 (h-11 -> h-8), backdrop-blur 추가 - AutoSlideMode: 슬라이드 컨테이너를 absolute inset-0으로 전체 영역 활용 - AutoSlideMode: 인디케이터를 하단 overlay로 변경 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardConfig.tsx | 6 +-- .../pop-dashboard/items/GaugeItem.tsx | 16 ++++--- .../pop-dashboard/items/KpiCard.tsx | 2 +- .../pop-dashboard/items/StatCard.tsx | 2 +- .../pop-dashboard/modes/ArrowsMode.tsx | 39 ++++++++--------- .../pop-dashboard/modes/AutoSlideMode.tsx | 42 +++++++++---------- 6 files changed, 53 insertions(+), 54 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index b75f9b66..1c19a856 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -1307,9 +1307,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: parseInt(e.target.value) || 0, max: item.gaugeConfig?.max ?? 100, - ...item.gaugeConfig, }, }) } @@ -1325,9 +1325,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: parseInt(e.target.value) || 100, - ...item.gaugeConfig, }, }) } @@ -1343,9 +1343,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: item.gaugeConfig?.max ?? 100, - ...item.gaugeConfig, target: parseInt(e.target.value) || undefined, }, }) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx index c7313a85..cca20686 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -69,17 +69,21 @@ export function GaugeItemComponent({ const largeArcFlag = percentage > 50 ? 1 : 0; return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 게이지 SVG */} -
- + {/* 게이지 SVG - 높이/너비 모두 반응형 */} +
+ {/* 배경 반원 (회색) */} +

목표: {abbreviateNumber(target)}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 29db2791..13086587 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -66,7 +66,7 @@ export function KpiCardComponent({ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); return ( -
+
{/* 라벨 */} {visibility.showLabel && (

diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index c3c02e7b..93cc1305 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -37,7 +37,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); return ( -

+
{/* 라벨 */} {visibility.showLabel && (

diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx index 51a05814..d91e6ea2 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx @@ -47,42 +47,37 @@ export function ArrowsModeComponent({ } return ( -

- {/* 콘텐츠 + 화살표 */} -
- {/* 왼쪽 화살표 */} - {itemCount > 1 && ( +
+ {/* 아이템 (전체 영역 사용) */} +
+ {renderItem(currentIndex)} +
+ + {/* 좌우 화살표 (콘텐츠 위에 겹침) */} + {itemCount > 1 && ( + <> - )} - - {/* 아이템 */} -
- {renderItem(currentIndex)} -
- - {/* 오른쪽 화살표 */} - {itemCount > 1 && ( - )} -
+ + )} - {/* 페이지 인디케이터 */} + {/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */} {showIndicator && itemCount > 1 && ( -
+
{Array.from({ length: itemCount }).map((_, i) => ( + ); + })} +
+
+
+ )} + +
+ ); + } + + // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return ( -
- {/* 헤더 */} -
- - {component.label || typeLabel} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} -
- - {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} -
- {PreviewComponent ? ( - - ) : ( - - {typeLabel} - - )} -
- - {/* 위치 정보 표시 (유효 위치 사용) */} -
- {effectivePosition.col},{effectivePosition.row} - ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) -
+ )}
); } From 960b1c9946c1bfddb05a6ccb4be7f1da0133027e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Feb 2026 14:23:20 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(pop-dashboard):=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20+=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20+=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라벨 정렬(좌/중/우) 기능 추가 (KPI, 차트, 게이지, 통계카드) - 글자 크기 커스텀 제거 (컨테이너 반응형 자동 적용) - 페이지별 미리보기 버튼 추가 (디자이너 캔버스에 즉시 반영) - 아이템 스타일 에디터 접기/펼치기 지원 - 차트 디자인: CartesianGrid, 대각선 X축 라벨, 숫자 약어(K/M), 축 여백 - handleUpdateComponent stale closure 버그 수정 (함수적 setState) - 디버그 console.log 전량 제거 Co-authored-by: Cursor --- STATUS.md | 36 ++--- .../components/pop/designer/PopCanvas.tsx | 4 + .../components/pop/designer/PopDesigner.tsx | 38 +++-- .../designer/panels/ComponentEditorPanel.tsx | 14 +- .../pop/designer/renderers/PopRenderer.tsx | 12 +- .../pop-dashboard/PopDashboardComponent.tsx | 54 +++++-- .../pop-dashboard/PopDashboardConfig.tsx | 150 +++++++++++++++++- .../pop-dashboard/items/ChartItem.tsx | 50 ++++-- .../pop-dashboard/items/GaugeItem.tsx | 33 +++- .../pop-dashboard/items/KpiCard.tsx | 14 +- .../pop-dashboard/items/StatCard.tsx | 14 +- frontend/lib/registry/pop-components/types.ts | 11 ++ 12 files changed, 347 insertions(+), 83 deletions(-) diff --git a/STATUS.md b/STATUS.md index 5e83ff11..3aa75278 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,22 +1,25 @@ # 프로젝트 상태 추적 -> **최종 업데이트**: 2026-02-10 +> **최종 업데이트**: 2026-02-11 --- ## 현재 진행 중 -### pop-dashboard 4가지 아이템 모드 완성 -**상태**: 코딩 완료, 브라우저 테스트 대기 -**계획서**: [PLAN.MD](./PLAN.MD) +### pop-dashboard 스타일 정리 +**상태**: 코딩 완료, 브라우저 확인 대기 +**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md) +**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정 --- ## 다음 작업 -| 순서 | 작업 | 파일 | 상태 | -|------|------|------|------| -| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 | +| 순서 | 작업 | 상태 | +|------|------|------| +| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 | +| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 | +| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 | --- @@ -24,12 +27,10 @@ | 날짜 | 작업 | 비고 | |------|------|------| -| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI | -| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 | -| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 | -| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 | -| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 | -| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 | +| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 | +| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 | +| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 | +| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 | | 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | | 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | | 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | @@ -40,9 +41,6 @@ | # | 이슈 | 심각도 | 상태 | |---|------|--------|------| -| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) | -| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) | -| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) | -| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) | -| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | -| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 7753a992..6ac2cea9 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -112,6 +112,8 @@ interface PopCanvasProps { onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; + /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ + previewPageIndex?: number; } // ======================================== @@ -135,6 +137,7 @@ export default function PopCanvas({ onLockLayout, onResetOverride, onChangeGapPreset, + previewPageIndex, }: PopCanvasProps) { // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -690,6 +693,7 @@ export default function PopCanvas({ onComponentResizeEnd={onResizeEnd} overrideGap={adjustedGap} overridePadding={adjustedPadding} + previewPageIndex={previewPageIndex} /> )}
diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index f4dfd3fa..16fa0da8 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -69,6 +69,9 @@ export default function PopDesigner({ // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); + // 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드) + const [previewPageIndex, setPreviewPageIndex] = useState(-1); + // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); @@ -217,24 +220,28 @@ export default function PopDesigner({ const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { - const existingComponent = layout.components[componentId]; - if (!existingComponent) return; + // 함수적 업데이트로 stale closure 방지 + setLayout((prev) => { + const existingComponent = prev.components[componentId]; + if (!existingComponent) return prev; - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...existingComponent, - ...updates, + const newComponent = { + ...existingComponent, + ...updates, + }; + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: newComponent, }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); + }; + saveToHistory(newLayout); + return newLayout; + }); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory] ); const handleDeleteComponent = useCallback( @@ -637,6 +644,7 @@ export default function PopDesigner({ onLockLayout={handleLockLayout} onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} + previewPageIndex={previewPageIndex} /> @@ -655,6 +663,8 @@ export default function PopDesigner({ allComponents={Object.values(layout.components)} onSelectComponent={setSelectedComponentId} selectedComponentId={selectedComponentId} + previewPageIndex={previewPageIndex} + onPreviewPage={setPreviewPageIndex} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 743574ad..e1c1eed5 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -42,6 +42,10 @@ interface ComponentEditorPanelProps { onSelectComponent?: (componentId: string) => void; /** 현재 선택된 컴포넌트 ID */ selectedComponentId?: string | null; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; + /** 페이지 미리보기 요청 콜백 */ + onPreviewPage?: (pageIndex: number) => void; } // ======================================== @@ -73,6 +77,8 @@ export default function ComponentEditorPanel({ allComponents, onSelectComponent, selectedComponentId, + previewPageIndex, + onPreviewPage, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -182,6 +188,8 @@ export default function ComponentEditorPanel({ @@ -362,9 +370,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate interface ComponentSettingsFormProps { component: PopComponentDefinitionV5; onUpdate?: (updates: Partial) => void; + previewPageIndex?: number; + onPreviewPage?: (pageIndex: number) => void; } -function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -393,6 +403,8 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro ) : (
diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 64ac63e6..a8559c36 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -54,6 +54,8 @@ interface PopRendererProps { overridePadding?: number; /** 추가 className */ className?: string; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; } // ======================================== @@ -85,6 +87,7 @@ export default function PopRenderer({ overrideGap, overridePadding, className, + previewPageIndex, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -250,6 +253,7 @@ export default function PopRenderer({ onComponentMove={onComponentMove} onComponentResize={onComponentResize} onComponentResizeEnd={onComponentResizeEnd} + previewPageIndex={previewPageIndex} /> ); } @@ -293,6 +297,7 @@ interface DraggableComponentProps { onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResizeEnd?: (componentId: string) => void; + previewPageIndex?: number; } function DraggableComponent({ @@ -310,6 +315,7 @@ function DraggableComponent({ onComponentMove, onComponentResize, onComponentResizeEnd, + previewPageIndex, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( () => ({ @@ -348,6 +354,7 @@ function DraggableComponent({ effectivePosition={position} isDesignMode={isDesignMode} isSelected={isSelected} + previewPageIndex={previewPageIndex} /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -498,9 +505,10 @@ interface ComponentContentProps { effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; + previewPageIndex?: number; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 @@ -515,7 +523,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { return (
- +
); } diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 97c4df97..8c9ce861 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -159,8 +159,11 @@ async function loadItemData(item: DashboardItem): Promise { export function PopDashboardComponent({ config, + previewPageIndex, }: { config?: PopDashboardConfig; + /** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */ + previewPageIndex?: number; }) { const [dataMap, setDataMap] = useState>({}); const [loading, setLoading] = useState(true); @@ -362,20 +365,22 @@ export function PopDashboardComponent({ const displayMode = migrated.displayMode; // 페이지 하나를 GridModeComponent로 렌더링 - const renderPageContent = (page: DashboardPage) => ( - { - const item = visibleItems.find((i) => i.id === itemId); - if (!item) return null; - return renderSingleItem(item); - }} - /> - ); + const renderPageContent = (page: DashboardPage) => { + return ( + { + 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; @@ -392,6 +397,27 @@ export function PopDashboardComponent({ return null; }; + // 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용) + if ( + typeof previewPageIndex === "number" && + previewPageIndex >= 0 && + pages[previewPageIndex] + ) { + return ( +
+ {renderPageContent(pages[previewPageIndex])} +
+ ); + } + // 표시 모드별 렌더링 return (
void; + /** 페이지 미리보기 요청 (-1이면 해제) */ + onPreviewPage?: (pageIndex: number) => void; + /** 현재 미리보기 중인 페이지 인덱스 */ + previewPageIndex?: number; } // ===== 기본값 ===== @@ -1528,18 +1537,92 @@ function generateDefaultCells( return cells; } +// ===================================================== +// 아이템 스타일 에디터 (접기/펼치기 지원) +// ===================================================== +function ItemStyleEditor({ + item, + onUpdate, +}: { + item: DashboardItem; + onUpdate: (updatedItem: DashboardItem) => void; +}) { + const [expanded, setExpanded] = useState(false); + + const updateStyle = (partial: Partial) => { + const updatedItem = { + ...item, + itemStyle: { ...item.itemStyle, ...partial }, + }; + onUpdate(updatedItem); + }; + + return ( +
+ {/* 헤더 - 클릭으로 접기/펼치기 */} + + + {/* 내용 - 접기/펼치기 */} + {expanded && ( +
+ {/* 라벨 정렬 */} +
+ + 라벨 정렬 + +
+ {(["left", "center", "right"] as const).map((align) => ( + + ))} +
+
+
+ )} +
+ ); +} + function GridLayoutEditor({ cells, gridColumns, gridRows, items, onChange, + onUpdateItem, }: { cells: DashboardCell[]; gridColumns: number; gridRows: number; items: DashboardItem[]; onChange: (cells: DashboardCell[], cols: number, rows: number) => void; + /** 아이템 스타일 업데이트 콜백 */ + onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const ensuredCells = cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); @@ -1710,6 +1793,34 @@ function GridLayoutEditor({ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을 추가/삭제할 수 있습니다.

+ + {/* 배정된 아이템별 스타일 설정 */} + {onUpdateItem && (() => { + const assignedItemIds = ensuredCells + .map((c) => c.itemId) + .filter((id): id is string => !!id); + const uniqueIds = [...new Set(assignedItemIds)]; + const assignedItems = uniqueIds + .map((id) => items.find((i) => i.id === id)) + .filter((i): i is DashboardItem => !!i); + + if (assignedItems.length === 0) return null; + + return ( +
+ + 아이템 스타일 + + {assignedItems.map((item) => ( + + ))} +
+ ); + })()}
); } @@ -1722,12 +1833,18 @@ function PageEditor({ items, onChange, onDelete, + onPreview, + isPreviewing, + onUpdateItem, }: { page: DashboardPage; pageIndex: number; items: DashboardItem[]; onChange: (updatedPage: DashboardPage) => void; onDelete: () => void; + onPreview?: () => void; + isPreviewing?: boolean; + onUpdateItem?: (updatedItem: DashboardItem) => void; }) { const [expanded, setExpanded] = useState(true); @@ -1741,6 +1858,15 @@ function PageEditor({ {page.gridColumns}x{page.gridRows} +
)} @@ -1802,10 +1929,8 @@ function PageEditor({ // ===== 메인 설정 패널 ===== -export function PopDashboardConfigPanel({ - config, - onUpdate: onChange, -}: ConfigPanelProps) { +export function PopDashboardConfigPanel(props: ConfigPanelProps) { + const { config, onUpdate: onChange } = props; // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; @@ -2068,6 +2193,23 @@ export function PopDashboardConfigPanel({ ); updateConfig({ pages: newPages }); }} + onPreview={() => { + if (props.onPreviewPage) { + // 같은 페이지를 다시 누르면 미리보기 해제 + props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx); + } + }} + isPreviewing={props.previewPageIndex === pageIdx} + onUpdateItem={(updatedItem) => { + const newItems = cfg.items.map((i) => + i.id === updatedItem.id ? updatedItem : i + ); + updateConfig({ items: newItems }); + // 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화 + if (props.onPreviewPage && props.previewPageIndex !== pageIdx) { + props.onPreviewPage(pageIdx); + } + }} /> ))} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index 93f29b1c..fc828925 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -21,8 +21,11 @@ import { Tooltip, Legend, ResponsiveContainer, + CartesianGrid, } from "recharts"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -58,7 +61,7 @@ export function ChartItemComponent({ rows, containerWidth, }: ChartItemProps) { - const { chartConfig, visibility } = item; + const { chartConfig, visibility, itemStyle } = item; const chartType = chartConfig?.chartType ?? "bar"; const colors = chartConfig?.colors?.length ? chartConfig.colors @@ -66,6 +69,9 @@ export function ChartItemComponent({ const xKey = chartConfig?.xAxisColumn ?? "name"; const yKey = chartConfig?.yAxisColumn ?? "value"; + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + // 컨테이너가 너무 작으면 메시지 표시 if (containerWidth < MIN_CHART_WIDTH) { return ( @@ -84,11 +90,23 @@ export function ChartItemComponent({ ); } + // X축 라벨이 긴지 판정 (7자 이상이면 대각선) + const hasLongLabels = rows.some( + (r) => String(r[xKey] ?? "").length > 7 + ); + const xAxisTickProps = hasLongLabels + ? { fontSize: 10, angle: -45, textAnchor: "end" as const } + : { fontSize: 10 }; + // 긴 라벨이 있으면 하단 여백 확보 + const chartMargin = hasLongLabels + ? { top: 5, right: 10, bottom: 40, left: 10 } + : { top: 5, right: 10, bottom: 5, left: 10 }; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -97,24 +115,34 @@ export function ChartItemComponent({
{chartType === "bar" ? ( - []}> + []} margin={chartMargin}> + - + abbreviateNumber(v)} + /> ) : chartType === "line" ? ( - []}> + []} margin={chartMargin}> + - + abbreviateNumber(v)} + /> 250 ? ({ name, value, percent }: { name: string; value: number; percent: number }) => - `${name} ${value} (${(percent * 100).toFixed(0)}%)` + `${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)` : false } labelLine={containerWidth > 250} @@ -150,7 +178,7 @@ export function ChartItemComponent({ ))} [value, name]} + formatter={(value: number, name: string) => [abbreviateNumber(value), name]} /> {containerWidth > 300 && ( SVG 직접 fontSize(px) 매핑 */ +const SVG_FONT_SIZE_MAP: Record = { + xs: 14, + sm: 18, + base: 24, + lg: 32, + xl: 48, +}; + // ===== Props ===== export interface GaugeItemProps { @@ -43,12 +53,19 @@ export function GaugeItemComponent({ data, targetValue, }: GaugeItemProps) { - const { visibility, gaugeConfig } = item; + const { visibility, gaugeConfig, itemStyle } = item; const current = data ?? 0; const min = gaugeConfig?.min ?? 0; const max = gaugeConfig?.max ?? 100; const target = targetValue ?? gaugeConfig?.target ?? max; + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + // SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거) + const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24 + const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14 + // 달성률 계산 (0~100) const range = max - min; const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0; @@ -70,9 +87,9 @@ export function GaugeItemComponent({ return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -110,8 +127,8 @@ export function GaugeItemComponent({ x={cx} y={cy - 10} textAnchor="middle" - className="fill-foreground text-2xl font-bold" - fontSize="24" + className="fill-foreground font-bold" + fontSize={svgValueFontSize} > {abbreviateNumber(current)} @@ -122,8 +139,8 @@ export function GaugeItemComponent({ x={cx} y={cy + 10} textAnchor="middle" - className="fill-muted-foreground text-xs" - fontSize="12" + className="fill-muted-foreground" + fontSize={svgSubFontSize} > {percentage.toFixed(1)}% diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 13086587..9e309a7b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -9,6 +9,7 @@ import React from "react"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -61,20 +62,23 @@ export function KpiCardComponent({ trendValue, formulaDisplay, }: KpiCardProps) { - const { visibility, kpiConfig } = item; + const { visibility, kpiConfig, itemStyle } = item; const displayValue = data ?? 0; const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); + // 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 메인 값 */} + {/* 메인 값 - @container 반응형 */} {visibility.showValue && (
+

{item.formula?.values.map((v) => v.label).join(" / ")}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index 93cc1305..eeae4dcb 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -9,6 +9,7 @@ import React from "react"; import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; import { abbreviateNumber } from "../utils/formula"; // ===== Props ===== @@ -32,20 +33,23 @@ const DEFAULT_STAT_COLORS = [ // ===== 메인 컴포넌트 ===== export function StatCardComponent({ item, categoryData }: StatCardProps) { - const { visibility, statConfig } = item; + const { visibility, statConfig, itemStyle } = item; const categories = statConfig?.categories ?? []; const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + return (
- {/* 라벨 */} + {/* 라벨 - 사용자 정렬 적용 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 총합 */} + {/* 총합 - @container 반응형 */} {visibility.showValue && (

{abbreviateNumber(total)} @@ -80,7 +84,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { {/* 보조 라벨 (단위 등) */} {visibility.showSubLabel && ( -

+

{visibility.showUnit && item.kpiConfig?.unit ? `단위: ${item.kpiConfig.unit}` : ""} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index e5927787..cee5fb06 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record = { xl: "text-[64px]", }; + export const FONT_WEIGHT_CLASSES: Record = { normal: "font-normal", medium: "font-medium", @@ -290,6 +291,13 @@ export interface DashboardPage { gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정) } +// ----- 대시보드 아이템 스타일 설정 ----- + +export interface ItemStyleConfig { + /** 라벨 텍스트 정렬 (기본: center) */ + labelAlign?: TextAlign; +} + // ----- 대시보드 아이템 ----- export interface DashboardItem { @@ -310,6 +318,9 @@ export interface DashboardItem { chartConfig?: ChartItemConfig; gaugeConfig?: GaugeConfig; statConfig?: StatCardConfig; + + /** 스타일 설정 (정렬, 글자 크기 3그룹) */ + itemStyle?: ItemStyleConfig; } // ----- 대시보드 전체 설정 -----