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; +}