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

394 lines
12 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();
}, []);
// 데이터 로딩 함수
// 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);
}, [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]);
// 빈 설정 (모든 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":
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>
);
}
// 마이그레이션: 기존 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>
);
}