315 lines
9.2 KiB
TypeScript
315 lines
9.2 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,
|
||
|
|
} 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<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);
|
||
|
|
|
||
|
|
// 빈 설정
|
||
|
|
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 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<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);
|
||
|
|
// 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 (
|
||
|
|
<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":
|
||
|
|
return (
|
||
|
|
<ChartItemComponent
|
||
|
|
item={item}
|
||
|
|
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) {
|
||
|
|
// 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준)
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 표시 모드별 렌더링
|
||
|
|
const displayMode = config.displayMode;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={containerRef}
|
||
|
|
className="h-full w-full"
|
||
|
|
style={
|
||
|
|
config.backgroundColor
|
||
|
|
? { backgroundColor: config.backgroundColor }
|
||
|
|
: undefined
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{displayMode === "arrows" && (
|
||
|
|
<ArrowsModeComponent
|
||
|
|
itemCount={visibleItems.length}
|
||
|
|
showIndicator={config.showIndicator}
|
||
|
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{displayMode === "auto-slide" && (
|
||
|
|
<AutoSlideModeComponent
|
||
|
|
itemCount={visibleItems.length}
|
||
|
|
interval={config.autoSlideInterval}
|
||
|
|
resumeDelay={config.autoSlideResumeDelay}
|
||
|
|
showIndicator={config.showIndicator}
|
||
|
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{displayMode === "grid" && (
|
||
|
|
<GridModeComponent
|
||
|
|
cells={config.gridCells ?? []}
|
||
|
|
columns={config.gridColumns ?? 2}
|
||
|
|
rows={config.gridRows ?? 2}
|
||
|
|
gap={config.gap}
|
||
|
|
renderItem={(itemId) => {
|
||
|
|
const item = visibleItems.find((i) => i.id === itemId);
|
||
|
|
if (!item) return null;
|
||
|
|
return renderSingleItem(item);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{displayMode === "scroll" && (
|
||
|
|
<ScrollModeComponent
|
||
|
|
itemCount={visibleItems.length}
|
||
|
|
showIndicator={config.showIndicator}
|
||
|
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|