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

411 lines
16 KiB
TypeScript
Raw Normal View History

2025-10-14 17:09:07 +09:00
"use client";
2025-10-14 17:09:07 +09:00
import React, { useState, useEffect, useCallback } from "react";
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
import dynamic from "next/dynamic";
2025-10-15 17:14:42 +09:00
// 위젯 동적 import - 모든 위젯
const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false });
2025-10-15 17:14:42 +09:00
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 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 TodoWidget = dynamic(() => import("./widgets/TodoWidget"), { ssr: false });
const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: false });
const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false });
const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { 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 },
);
2025-10-15 17:14:42 +09:00
/**
* - DashboardSidebar의 subtype
* ViewerElement에서
*/
function renderWidget(element: DashboardElement) {
switch (element.subtype) {
// 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨)
2025-10-15 17:14:42 +09:00
// === 위젯 종류 ===
case "exchange":
return <ExchangeWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "weather":
return <WeatherWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "calculator":
return <CalculatorWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "clock":
return <ClockWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "map-summary":
return <MapSummaryWidget element={element} />;
case "list-summary":
return <ListSummaryWidget element={element} />;
case "risk-alert":
return <RiskAlertWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "calendar":
return <CalendarWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "status-summary":
return <StatusSummaryWidget element={element} />;
2025-10-15 17:14:42 +09:00
// === 운영/작업 지원 ===
case "todo":
return <TodoWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "booking-alert":
return <BookingAlertWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "maintenance":
return <MaintenanceWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "document":
return <DocumentWidget element={element} />;
2025-10-15 17:14:42 +09:00
case "list":
return <ListWidget element={element} />;
2025-10-15 17:14:42 +09:00
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
return <VehicleStatusWidget />;
case "vehicle-list":
return <VehicleListWidget />;
case "vehicle-map":
return <VehicleMapOnlyWidget element={element} />;
2025-10-15 17:14:42 +09:00
// === 배송 관련 (추가 위젯) ===
case "delivery-status":
return <DeliveryStatusWidget />;
case "delivery-status-summary":
return <DeliveryStatusSummaryWidget />;
case "delivery-today-stats":
return <DeliveryTodayStatsWidget />;
case "cargo-list":
return <CargoListWidget />;
case "customer-issues":
return <CustomerIssuesWidget />;
2025-10-15 17:14:42 +09:00
// === 기본 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)
}
/**
*
* -
* -
* -
*/
export function DashboardViewer({ elements, dashboardId, refreshInterval }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
2025-10-14 17:09:07 +09:00
if (!element.dataSource?.query || element.type !== "chart") {
return;
}
2025-10-14 17:09:07 +09:00
setLoadingElements((prev) => new Set([...prev, element.id]));
try {
2025-10-14 17:09:07 +09:00
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 (error) {
2025-10-14 17:09:07 +09:00
// 에러 발생 시 무시 (차트는 빈 상태로 표시됨)
} finally {
2025-10-14 17:09:07 +09:00
setLoadingElements((prev) => {
const newSet = new Set(prev);
newSet.delete(element.id);
return newSet;
});
}
}, []);
// 모든 요소 데이터 로딩
const loadAllData = useCallback(async () => {
setLastRefresh(new Date());
2025-10-14 17:09:07 +09:00
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
// 병렬로 모든 차트 데이터 로딩
2025-10-14 17:09:07 +09:00
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]);
// 요소가 없는 경우
if (elements.length === 0) {
return (
2025-10-14 17:09:07 +09:00
<div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center">
2025-10-14 17:09:07 +09:00
<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 (
2025-10-14 17:09:07 +09:00
<div className="relative h-full w-full overflow-auto bg-gray-100">
{/* 새로고침 상태 표시 */}
2025-10-14 17:09:07 +09:00
<div className="text-muted-foreground absolute top-4 right-4 z-10 rounded-lg bg-white px-3 py-2 text-xs shadow-sm">
: {lastRefresh.toLocaleTimeString()}
{Array.from(loadingElements).length > 0 && (
2025-10-14 17:09:07 +09:00
<span className="text-primary ml-2">({Array.from(loadingElements).length} ...)</span>
)}
</div>
{/* 대시보드 요소들 */}
2025-10-14 17:09:07 +09:00
<div className="relative" style={{ minHeight: "100%" }}>
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
</div>
);
}
interface ViewerElementProps {
element: DashboardElement;
data?: QueryResult;
isLoading: boolean;
onRefresh: () => void;
}
/**
*
*/
function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
2025-10-14 17:09:07 +09:00
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{
left: element.position.x,
top: element.position.y,
width: element.size.width,
2025-10-14 17:09:07 +09:00
height: element.size.height,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 헤더 (showHeader가 false가 아닐 때만 표시) */}
{element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
)}
</div>
)}
{/* 내용 */}
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
2025-10-14 17:09:07 +09:00
{element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
) : (
renderWidget(element)
)}
</div>
{/* 로딩 오버레이 */}
{isLoading && (
2025-10-14 17:09:07 +09:00
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
<div className="text-center">
2025-10-14 17:09:07 +09:00
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
<div className="text-muted-foreground text-sm"> ...</div>
</div>
</div>
)}
</div>
);
}
/**
* ()
*/
function generateSampleQueryResult(query: string, chartType: string): QueryResult {
// 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
2025-10-14 17:09:07 +09:00
const isMonthly = query.toLowerCase().includes("month");
const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
const isWeekly = query.toLowerCase().includes("week");
let columns: string[];
let rows: Record<string, any>[];
if (isMonthly && isSales) {
2025-10-14 17:09:07 +09:00
columns = ["month", "sales", "order_count"];
rows = [
2025-10-14 17:09:07 +09:00
{ month: "2024-01", sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
{ month: "2024-02", sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
{ month: "2024-03", sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
{ month: "2024-04", sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
{ month: "2024-05", sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
{ month: "2024-06", sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
];
} else if (isWeekly && isUsers) {
2025-10-14 17:09:07 +09:00
columns = ["week", "new_users"];
rows = [
2025-10-14 17:09:07 +09:00
{ week: "2024-W10", new_users: Math.round(23 * timeVariation) },
{ week: "2024-W11", new_users: Math.round(31 * timeVariation) },
{ week: "2024-W12", new_users: Math.round(28 * timeVariation) },
{ week: "2024-W13", new_users: Math.round(35 * timeVariation) },
{ week: "2024-W14", new_users: Math.round(42 * timeVariation) },
{ week: "2024-W15", new_users: Math.round(38 * timeVariation) },
];
} else if (isProducts) {
2025-10-14 17:09:07 +09:00
columns = ["product_name", "total_sold", "revenue"];
rows = [
2025-10-14 17:09:07 +09:00
{
product_name: "스마트폰",
total_sold: Math.round(156 * timeVariation),
revenue: Math.round(234000000 * timeVariation),
},
{
product_name: "노트북",
total_sold: Math.round(89 * timeVariation),
revenue: Math.round(178000000 * timeVariation),
},
{
product_name: "태블릿",
total_sold: Math.round(134 * timeVariation),
revenue: Math.round(67000000 * timeVariation),
},
{
product_name: "이어폰",
total_sold: Math.round(267 * timeVariation),
revenue: Math.round(26700000 * timeVariation),
},
{
product_name: "스마트워치",
total_sold: Math.round(98 * timeVariation),
revenue: Math.round(49000000 * timeVariation),
},
];
} else {
2025-10-14 17:09:07 +09:00
columns = ["category", "value", "count"];
rows = [
2025-10-14 17:09:07 +09:00
{ category: "A", value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
{ category: "B", value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
{ category: "C", value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
{ category: "D", value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
{ category: "E", value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
];
}
return {
columns,
rows,
totalRows: rows.length,
executionTime: Math.floor(Math.random() * 100) + 50,
};
}