434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
"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<string, unknown>
|
|
): PopDashboardConfig {
|
|
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
|
|
|
|
// pages가 이미 있으면 마이그레이션 불필요
|
|
if (
|
|
Array.isArray(config.pages) &&
|
|
config.pages.length > 0
|
|
) {
|
|
return config;
|
|
}
|
|
|
|
// 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
|
|
const wasGrid =
|
|
config.displayMode === ("grid" as string) ||
|
|
(config as Record<string, unknown>).useGridLayout === true;
|
|
|
|
if (wasGrid) {
|
|
const cols =
|
|
((config as Record<string, unknown>).gridColumns as number) ?? 2;
|
|
const rows =
|
|
((config as Record<string, unknown>).gridRows as number) ?? 2;
|
|
const cells =
|
|
((config as Record<string, unknown>).gridCells as DashboardPage["gridCells"]) ?? [];
|
|
|
|
const page: DashboardPage = {
|
|
id: "migrated-page-1",
|
|
label: "페이지 1",
|
|
gridColumns: cols,
|
|
gridRows: rows,
|
|
gridCells: cells,
|
|
};
|
|
|
|
config.pages = [page];
|
|
|
|
// displayMode="grid" 보정
|
|
if (config.displayMode === ("grid" as string)) {
|
|
(config as Record<string, unknown>).displayMode = "arrows";
|
|
}
|
|
}
|
|
|
|
return config as PopDashboardConfig;
|
|
}
|
|
|
|
// ===== 내부 타입 =====
|
|
|
|
interface ItemData {
|
|
/** 단일 집계 값 */
|
|
value: number;
|
|
/** 데이터 행 (차트용) */
|
|
rows: Record<string, unknown>[];
|
|
/** 수식 결과 표시 문자열 */
|
|
formulaDisplay: string | null;
|
|
/** 에러 메시지 */
|
|
error: string | null;
|
|
}
|
|
|
|
// ===== 데이터 로딩 함수 =====
|
|
|
|
/** 단일 아이템의 데이터를 조회 */
|
|
async function loadItemData(item: DashboardItem): Promise<ItemData> {
|
|
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<string, number> = {};
|
|
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<Record<string, ItemData>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(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<string, ItemData> = {};
|
|
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 (
|
|
<div className="flex h-full w-full items-center justify-center bg-muted/20">
|
|
<span className="text-sm text-muted-foreground">
|
|
대시보드 아이템을 추가하세요
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 단일 아이템 렌더링
|
|
const renderSingleItem = (item: DashboardItem) => {
|
|
const itemData = dataMap[item.id];
|
|
if (!itemData) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-xs text-muted-foreground">로딩 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (itemData.error) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-xs text-destructive">{itemData.error}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
switch (item.subType) {
|
|
case "kpi-card":
|
|
return (
|
|
<KpiCardComponent
|
|
item={item}
|
|
data={itemData.value}
|
|
formulaDisplay={itemData.formulaDisplay}
|
|
/>
|
|
);
|
|
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 (
|
|
<ChartItemComponent
|
|
item={chartItem}
|
|
rows={itemData.rows}
|
|
containerWidth={containerWidth}
|
|
/>
|
|
);
|
|
}
|
|
case "gauge":
|
|
return <GaugeItemComponent item={item} data={itemData.value} />;
|
|
case "stat-card": {
|
|
// StatCard: 카테고리별 건수 맵 구성 (필터 적용)
|
|
const categoryData: Record<string, number> = {};
|
|
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 (
|
|
<StatCardComponent item={item} categoryData={categoryData} />
|
|
);
|
|
}
|
|
default:
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-xs text-muted-foreground">
|
|
미지원 타입: {item.subType}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 로딩 상태
|
|
if (loading && !Object.keys(dataMap).length) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 마이그레이션: 기존 config를 페이지 기반으로 변환
|
|
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
|
const pages = migrated.pages ?? [];
|
|
const displayMode = migrated.displayMode;
|
|
|
|
// 페이지 하나를 GridModeComponent로 렌더링
|
|
const renderPageContent = (page: DashboardPage) => (
|
|
<GridModeComponent
|
|
cells={page.gridCells}
|
|
columns={page.gridColumns}
|
|
rows={page.gridRows}
|
|
gap={config.gap}
|
|
containerWidth={containerWidth}
|
|
renderItem={(itemId) => {
|
|
const item = visibleItems.find((i) => i.id === itemId);
|
|
if (!item) return null;
|
|
return renderSingleItem(item);
|
|
}}
|
|
/>
|
|
);
|
|
|
|
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
|
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
|
|
|
// 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
|
|
const renderSlide = (index: number) => {
|
|
if (pages.length > 0 && pages[index]) {
|
|
return renderPageContent(pages[index]);
|
|
}
|
|
// fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
|
|
if (visibleItems[index]) {
|
|
return renderSingleItem(visibleItems[index]);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// 표시 모드별 렌더링
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="h-full w-full"
|
|
style={
|
|
config.backgroundColor
|
|
? { backgroundColor: config.backgroundColor }
|
|
: undefined
|
|
}
|
|
>
|
|
{displayMode === "arrows" && (
|
|
<ArrowsModeComponent
|
|
itemCount={slideCount}
|
|
showIndicator={config.showIndicator}
|
|
renderItem={renderSlide}
|
|
/>
|
|
)}
|
|
|
|
{displayMode === "auto-slide" && (
|
|
<AutoSlideModeComponent
|
|
itemCount={slideCount}
|
|
interval={config.autoSlideInterval}
|
|
resumeDelay={config.autoSlideResumeDelay}
|
|
showIndicator={config.showIndicator}
|
|
renderItem={renderSlide}
|
|
/>
|
|
)}
|
|
|
|
{displayMode === "scroll" && (
|
|
<ScrollModeComponent
|
|
itemCount={slideCount}
|
|
showIndicator={config.showIndicator}
|
|
renderItem={renderSlide}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|