ERP-node/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx

315 lines
9.2 KiB
TypeScript
Raw Normal View History

"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>
);
}