"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 { Button } from "@/components/ui/button"; import { Download } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import dynamic from "next/dynamic"; // 위젯 동적 import - 모든 위젯 const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false }); const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false }); const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false }); const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false }); const ListTestWidget = dynamic( () => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false }, ); const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false }); const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { 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 ; case "weather": return ; case "weather-map": return ; case "calculator": return ; case "clock": return ; case "map-summary": return ; case "map-test": return ; case "map-summary-v2": return ; case "chart": return ; case "list-v2": return ; case "custom-metric-v2": return ; case "risk-alert-v2": return ; case "risk-alert": return ; case "calendar": return ; case "status-summary": return ; case "custom-metric": return ; // === 운영/작업 지원 === case "todo": case "maintenance": return ; case "booking-alert": return ; case "document": return ; case "list": return ; 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 ; case "work-history": return ; 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 ; // === 차량 관련 (추가 위젯) === case "vehicle-status": return ; case "vehicle-list": return ; case "vehicle-map": return ; // === 배송 관련 (추가 위젯) === case "delivery-status": return ; case "delivery-status-summary": return ; case "delivery-today-stats": return ; case "cargo-list": return ; case "customer-issues": return ; // === 기본 fallback === default: return (
알 수 없는 위젯 타입: {element.subtype}
); } } interface DashboardViewerProps { elements: DashboardElement[]; dashboardId?: string; refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms) backgroundColor?: string; // 배경색 resolution?: string; // 대시보드 해상도 dashboardTitle?: string; // 대시보드 제목 (다운로드 파일명용) } /** * 대시보드 뷰어 컴포넌트 * - 저장된 대시보드를 읽기 전용으로 표시 * - 실시간 데이터 업데이트 * - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기) */ export function DashboardViewer({ elements, refreshInterval, backgroundColor = "#f9fafb", resolution = "fhd", dashboardTitle, }: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); // 대시보드 다운로드 // 헬퍼 함수: dataUrl로 다운로드 처리 const handleDownloadWithDataUrl = async ( dataUrl: string, format: "png" | "pdf", canvasWidth: number, canvasHeight: number ) => { if (format === "png") { console.log("💾 PNG 다운로드 시작..."); const link = document.createElement("a"); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; link.download = filename; link.href = dataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); console.log("✅ PNG 다운로드 완료:", filename); } else { console.log("📄 PDF 생성 중..."); const jsPDF = (await import("jspdf")).default; // dataUrl에서 이미지 크기 계산 const img = new Image(); img.src = dataUrl; await new Promise((resolve) => { img.onload = resolve; }); console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height }); console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight }); // PDF 크기 계산 (A4 기준) const imgWidth = 210; // A4 width in mm const actualHeight = canvasHeight; const actualWidth = canvasWidth; const imgHeight = (actualHeight * imgWidth) / actualWidth; console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight }); const pdf = new jsPDF({ orientation: imgHeight > imgWidth ? "portrait" : "landscape", unit: "mm", format: [imgWidth, imgHeight], }); pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; pdf.save(filename); console.log("✅ PDF 다운로드 완료:", filename); } }; const handleDownload = useCallback( async (format: "png" | "pdf") => { try { console.log("🔍 다운로드 시작:", format); const canvas = document.querySelector(".dashboard-viewer-canvas") as HTMLElement; console.log("🔍 캔버스 찾기:", canvas); if (!canvas) { alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요."); return; } console.log("📸 html-to-image 로딩 중..."); // html-to-image 동적 import const { toPng } = await import("html-to-image"); console.log("📸 캔버스 캡처 중..."); // 3D/WebGL 렌더링 완료 대기 console.log("⏳ 3D 렌더링 완료 대기 중..."); await new Promise((resolve) => setTimeout(resolve, 1000)); // WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존) console.log("🎨 WebGL 캔버스 처리 중..."); const webglCanvases = canvas.querySelectorAll("canvas"); const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = []; webglCanvases.forEach((webglCanvas) => { try { const rect = webglCanvas.getBoundingClientRect(); const dataUrl = webglCanvas.toDataURL("image/png"); webglImages.push({ canvas: webglCanvas, dataUrl, rect }); console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height, left: rect.left, top: rect.top, bottom: rect.bottom }); } catch (error) { console.warn("⚠️ WebGL 캔버스 캡처 실패:", error); } }); // 캔버스의 실제 크기와 위치 가져오기 const rect = canvas.getBoundingClientRect(); const canvasWidth = canvas.scrollWidth; // 실제 콘텐츠의 최하단 위치 계산 // 뷰어 모드에서는 모든 자식 요소를 확인 const children = canvas.querySelectorAll("*"); let maxBottom = 0; children.forEach((child) => { // canvas 자신이나 너무 작은 요소는 제외 if (child === canvas || child.clientHeight < 10) { return; } const childRect = child.getBoundingClientRect(); const relativeBottom = childRect.bottom - rect.top; if (relativeBottom > maxBottom) { maxBottom = relativeBottom; } }); // 실제 콘텐츠 높이 + 여유 공간 (50px) // maxBottom이 0이면 기본 캔버스 높이 사용 const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height); console.log("📐 캔버스 정보:", { rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, width: rect.width, height: rect.height }, scroll: { width: canvasWidth, height: canvas.scrollHeight }, calculated: { width: canvasWidth, height: canvasHeight }, maxBottom: maxBottom, webglCount: webglImages.length }); // html-to-image로 캔버스 캡처 (WebGL 제외) const dataUrl = await toPng(canvas, { backgroundColor: backgroundColor || "#ffffff", width: canvasWidth, height: canvasHeight, pixelRatio: 2, // 고해상도 cacheBust: true, skipFonts: false, preferredFontFormat: 'woff2', filter: (node) => { // WebGL 캔버스는 제외 (나중에 수동으로 합성) if (node instanceof HTMLCanvasElement) { return false; } return true; }, }); // WebGL 캔버스를 이미지 위에 합성 if (webglImages.length > 0) { console.log("🖼️ WebGL 이미지 합성 중..."); const img = new Image(); img.src = dataUrl; await new Promise((resolve) => { img.onload = resolve; }); // 새 캔버스에 합성 const compositeCanvas = document.createElement("canvas"); compositeCanvas.width = img.width; compositeCanvas.height = img.height; const ctx = compositeCanvas.getContext("2d"); if (ctx) { // 기본 이미지 그리기 ctx.drawImage(img, 0, 0); // WebGL 이미지들을 위치에 맞게 그리기 for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) { const webglImg = new Image(); webglImg.src = webglDataUrl; await new Promise((resolve) => { webglImg.onload = resolve; }); // 상대 위치 계산 (pixelRatio 2 고려) const relativeX = (webglRect.left - rect.left) * 2; const relativeY = (webglRect.top - rect.top) * 2; const width = webglRect.width * 2; const height = webglRect.height * 2; ctx.drawImage(webglImg, relativeX, relativeY, width, height); console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height }); } // 합성된 이미지를 dataUrl로 변환 const compositeDataUrl = compositeCanvas.toDataURL("image/png"); console.log("✅ 최종 합성 완료"); // 합성된 이미지로 다운로드 return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight); } } console.log("✅ 캡처 완료 (WebGL 없음)"); // WebGL이 없는 경우 기본 다운로드 await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight); } catch (error) { console.error("❌ 다운로드 실패:", error); alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`); } }, [backgroundColor, dashboardTitle], ); // 캔버스 설정 계산 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 (
📊
표시할 요소가 없습니다
대시보드 편집기에서 차트나 위젯을 추가해보세요
); } return ( {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
{/* 다운로드 버튼 */}
handleDownload("png")}>PNG 이미지로 저장 handleDownload("pdf")}>PDF 문서로 저장
{sortedElements.map((element) => ( loadElementData(element)} isMobile={false} canvasWidth={canvasConfig.width} /> ))}
{/* 태블릿 이하: 반응형 세로 정렬 */}
{/* 다운로드 버튼 */}
handleDownload("png")}>PNG 이미지로 저장 handleDownload("pdf")}>PDF 문서로 저장
{sortedElements.map((element) => ( loadElementData(element)} isMobile={true} /> ))}
); } 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 (
{element.showHeader !== false && (

{element.customTitle || element.title}

)}
{!isMounted ? (
) : element.type === "chart" ? ( ) : ( renderWidget(element) )}
{isLoading && (
업데이트 중...
)}
); } // 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning // 단, 너비는 화면 크기에 따라 비율로 조정 const widthPercentage = (element.size.width / canvasWidth) * 100; const leftPercentage = (element.position.x / canvasWidth) * 100; return (
{element.showHeader !== false && (

{element.customTitle || element.title}

)}
{!isMounted ? (
) : element.type === "chart" ? ( ) : ( renderWidget(element) )}
{isLoading && (
업데이트 중...
)}
); }