ERP-node/frontend/components/dashboard/DashboardViewer.tsx

495 lines
19 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
import dynamic from "next/dynamic";
// 위젯 동적 import - 모든 위젯
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false });
const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false });
const VehicleMapOnlyWidget = dynamic(() => import("./widgets/VehicleMapOnlyWidget"), { ssr: false });
const CargoListWidget = dynamic(() => import("./widgets/CargoListWidget"), { ssr: false });
const CustomerIssuesWidget = dynamic(() => import("./widgets/CustomerIssuesWidget"), { ssr: false });
const DeliveryStatusWidget = dynamic(() => import("./widgets/DeliveryStatusWidget"), { ssr: false });
const DeliveryStatusSummaryWidget = dynamic(() => import("./widgets/DeliveryStatusSummaryWidget"), { ssr: false });
const DeliveryTodayStatsWidget = dynamic(() => import("./widgets/DeliveryTodayStatsWidget"), { ssr: false });
const TaskWidget = dynamic(() => import("./widgets/TaskWidget"), { ssr: false });
const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: false });
const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false });
const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false });
const CalendarWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/CalendarWidget").then((mod) => ({ default: mod.CalendarWidget })),
{ ssr: false },
);
const ClockWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
{ ssr: false },
);
const ListWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
{ ssr: false },
);
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
ssr: false,
});
const WorkHistoryWidget = dynamic(() => import("./widgets/WorkHistoryWidget"), {
ssr: false,
});
const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
ssr: false,
});
const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
ssr: false,
});
/**
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
*/
function renderWidget(element: DashboardElement) {
switch (element.subtype) {
// 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨)
// === 위젯 종류 ===
case "exchange":
return <ExchangeWidget element={element} />;
case "weather":
return <WeatherWidget element={element} />;
case "weather-map":
return <WeatherMapWidget element={element} />;
case "calculator":
return <CalculatorWidget element={element} />;
case "clock":
return <ClockWidget element={element} />;
case "map-summary":
return <MapSummaryWidget element={element} />;
case "risk-alert":
return <RiskAlertWidget element={element} />;
case "calendar":
return <CalendarWidget element={element} />;
case "status-summary":
return <StatusSummaryWidget element={element} />;
case "custom-metric":
return <CustomMetricWidget element={element} />;
// === 운영/작업 지원 ===
case "todo":
case "maintenance":
return <TaskWidget element={element} />;
case "booking-alert":
return <BookingAlertWidget element={element} />;
case "document":
return <DocumentWidget element={element} />;
case "list":
return <ListWidget element={element} />;
case "yard-management-3d":
// console.log("🏗️ 야드관리 위젯 렌더링:", {
// elementId: element.id,
// yardConfig: element.yardConfig,
// yardConfigType: typeof element.yardConfig,
// hasLayoutId: !!element.yardConfig?.layoutId,
// layoutId: element.yardConfig?.layoutId,
// layoutName: element.yardConfig?.layoutName,
// });
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
case "work-history":
return <WorkHistoryWidget element={element} />;
case "transport-stats":
// console.log("📊 [DashboardViewer] CustomStatsWidget 렌더링:", {
// elementId: element.id,
// hasDataSource: !!element.dataSource,
// query: element.dataSource?.query?.substring(0, 50) + "...",
// dataSourceType: element.dataSource?.type,
// });
return <CustomStatsWidget element={element} />;
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
return <VehicleStatusWidget element={element} />;
case "vehicle-list":
return <VehicleListWidget element={element} />;
case "vehicle-map":
return <VehicleMapOnlyWidget element={element} />;
// === 배송 관련 (추가 위젯) ===
case "delivery-status":
return <DeliveryStatusWidget element={element} />;
case "delivery-status-summary":
return <DeliveryStatusSummaryWidget element={element} />;
case "delivery-today-stats":
return <DeliveryTodayStatsWidget element={element} />;
case "cargo-list":
return <CargoListWidget element={element} />;
case "customer-issues":
return <CustomerIssuesWidget element={element} />;
// === 기본 fallback ===
default:
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 to-gray-600 p-4 text-white">
<div className="text-center">
<div className="mb-2 text-3xl"></div>
<div className="text-sm"> : {element.subtype}</div>
</div>
</div>
);
}
}
interface DashboardViewerProps {
elements: DashboardElement[];
dashboardId?: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
backgroundColor?: string; // 배경색
resolution?: string; // 대시보드 해상도
}
/**
* 대시보드 뷰어 컴포넌트
* - 저장된 대시보드를 읽기 전용으로 표시
* - 실시간 데이터 업데이트
* - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기)
*/
export function DashboardViewer({
elements,
refreshInterval,
backgroundColor = "#f9fafb",
resolution = "fhd",
}: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
// 캔버스 설정 계산
const canvasConfig = useMemo(() => {
return RESOLUTIONS[resolution as Resolution] || RESOLUTIONS.fhd;
}, [resolution]);
// 캔버스 높이 동적 계산
const canvasHeight = useMemo(() => {
if (elements.length === 0) {
return canvasConfig.height;
}
const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height));
return Math.max(canvasConfig.height, maxBottomY + 100);
}, [elements, canvasConfig.height]);
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
if (!element.dataSource?.query || element.type !== "chart") {
return;
}
setLoadingElements((prev) => new Set([...prev, element.id]));
try {
let result;
// 외부 DB vs 현재 DB 분기
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
// 외부 DB
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
const externalResult = await ExternalDbConnectionAPI.executeQuery(
parseInt(element.dataSource.externalConnectionId),
element.dataSource.query,
);
if (!externalResult.success) {
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
}
const data: QueryResult = {
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
rows: externalResult.data || [],
totalRows: externalResult.data?.length || 0,
executionTime: 0,
};
setElementData((prev) => ({
...prev,
[element.id]: data,
}));
} else {
// 현재 DB
const { dashboardApi } = await import("@/lib/api/dashboard");
result = await dashboardApi.executeQuery(element.dataSource.query);
const data: QueryResult = {
columns: result.columns || [],
rows: result.rows || [],
totalRows: result.rowCount || 0,
executionTime: 0,
};
setElementData((prev) => ({
...prev,
[element.id]: data,
}));
}
} catch {
// 에러 발생 시 무시 (차트는 빈 상태로 표시됨)
} finally {
setLoadingElements((prev) => {
const newSet = new Set(prev);
newSet.delete(element.id);
return newSet;
});
}
}, []);
// 모든 요소 데이터 로딩
const loadAllData = useCallback(async () => {
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
// 병렬로 모든 차트 데이터 로딩
await Promise.all(chartElements.map((element) => loadElementData(element)));
}, [elements, loadElementData]);
// 초기 데이터 로딩
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 전체 새로고침 간격 설정
useEffect(() => {
if (!refreshInterval || refreshInterval === 0) {
return;
}
const interval = setInterval(loadAllData, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, loadAllData]);
// 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용
const sortedElements = useMemo(() => {
return [...elements].sort((a, b) => {
// Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함)
const yDiff = a.position.y - b.position.y;
if (Math.abs(yDiff) > 50) {
return yDiff;
}
// 같은 행이면 X 좌표로 정렬
return a.position.x - b.position.x;
});
}, [elements]);
// 요소가 없는 경우
if (elements.length === 0) {
return (
<div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-4 text-6xl">📊</div>
<div className="mb-2 text-xl font-medium text-gray-700"> </div>
<div className="text-sm text-gray-500"> </div>
</div>
</div>
);
}
return (
<DashboardProvider>
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
<div
className="relative rounded-lg"
style={{
width: "100%",
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={false}
canvasWidth={canvasConfig.width}
/>
))}
</div>
</div>
</div>
{/* 태블릿 이하: 반응형 세로 정렬 */}
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
<div className="mx-auto max-w-3xl space-y-4">
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={true}
/>
))}
</div>
</div>
</DashboardProvider>
);
}
interface ViewerElementProps {
element: DashboardElement;
data?: QueryResult;
isLoading: boolean;
onRefresh: () => void;
isMobile: boolean;
canvasWidth?: number;
}
/**
* 개별 뷰어 요소 컴포넌트
* - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정)
* - 태블릿 이하: 세로 스택 카드 레이아웃
*/
function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) {
const [isMounted, setIsMounted] = useState(false);
// 마운트 확인 (Leaflet 지도 초기화 문제 해결)
useEffect(() => {
setIsMounted(true);
}, []);
if (isMobile) {
// 태블릿 이하: 세로 스택 카드 스타일
return (
<div
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{ minHeight: "300px" }}
>
{element.showHeader !== false && (
<div className="flex items-center justify-between px-2 py-1">
<h3 className="text-xs font-semibold text-gray-800">{element.customTitle || element.title}</h3>
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
<svg
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
)}
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
{!isMounted ? (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={undefined} height={250} />
) : (
renderWidget(element)
)}
</div>
{isLoading && (
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<div className="text-sm text-gray-600"> ...</div>
</div>
</div>
)}
</div>
);
}
// 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
// 단, 너비는 화면 크기에 따라 비율로 조정
const widthPercentage = (element.size.width / canvasWidth) * 100;
return (
<div
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{
left: `${(element.position.x / canvasWidth) * 100}%`,
top: element.position.y,
width: `${widthPercentage}%`,
height: element.size.height,
}}
>
{element.showHeader !== false && (
<div className="flex items-center justify-between px-2 py-1">
<h3 className="text-xs font-semibold text-gray-800">{element.customTitle || element.title}</h3>
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
<svg
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
)}
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
{!isMounted ? (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : element.type === "chart" ? (
<ChartRenderer
element={element}
data={data}
width={undefined}
height={element.showHeader !== false ? element.size.height - 32 : element.size.height}
/>
) : (
renderWidget(element)
)}
</div>
{isLoading && (
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<div className="text-sm text-gray-600"> ...</div>
</div>
</div>
)}
</div>
);
}