"use client"; /** * pop-dashboard 메인 컴포넌트 (뷰어용) * * 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링 * * @INFRA-EXTRACT 대상: * - fetchAggregatedData 호출부 -> useDataSource로 교체 예정 * - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정 */ import React, { useState, useEffect, useCallback, useRef } from "react"; import type { PopDashboardConfig, DashboardItem, DashboardPage, } 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"; // ===== 마이그레이션: 기존 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 { /** 단일 집계 값 */ 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); // 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용) const visibleItems = Array.isArray(config?.items) ? 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(); }, []); // 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지) const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id)); // 데이터 로딩 함수 // eslint-disable-next-line react-hooks/exhaustive-deps 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); }, [visibleItemIds]); // 초기 로딩 + 주기적 새로고침 useEffect(() => { fetchAllData(); // 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); } return () => { if (refreshTimerRef.current) { clearInterval(refreshTimerRef.current); refreshTimerRef.current = null; } }; // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용) // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchAllData, visibleItemIds]); // 빈 설정 (모든 hooks 이후에 early return) if (!config || !config.items?.length) { return (
대시보드 아이템을 추가하세요
); } // 단일 아이템 렌더링 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": { // 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: 카테고리별 건수 맵 구성 (필터 적용) const categoryData: Record = {}; if (item.statConfig?.categories) { for (const cat of item.statConfig.categories) { 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 ( ); } default: return (
미지원 타입: {item.subType}
); } }; // 로딩 상태 if (loading && !Object.keys(dataMap).length) { return (
); } // 마이그레이션: 기존 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" && ( )} {displayMode === "auto-slide" && ( )} {displayMode === "scroll" && ( )}
); }