766 lines
30 KiB
TypeScript
766 lines
30 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 { 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 <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 "map-test":
|
|
return <MapTestWidget element={element} />;
|
|
case "map-summary-v2":
|
|
return <MapTestWidgetV2 element={element} />;
|
|
case "chart":
|
|
return <ChartTestWidget element={element} />;
|
|
case "list-v2":
|
|
return <ListTestWidget element={element} />;
|
|
case "custom-metric-v2":
|
|
return <CustomMetricTestWidget element={element} />;
|
|
case "risk-alert-v2":
|
|
return <RiskAlertTestWidget 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; // 대시보드 해상도
|
|
dashboardTitle?: string; // 대시보드 제목 (다운로드 파일명용)
|
|
}
|
|
|
|
/**
|
|
* 대시보드 뷰어 컴포넌트
|
|
* - 저장된 대시보드를 읽기 전용으로 표시
|
|
* - 실시간 데이터 업데이트
|
|
* - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기)
|
|
*/
|
|
export function DashboardViewer({
|
|
elements,
|
|
refreshInterval,
|
|
backgroundColor = "#f9fafb",
|
|
resolution = "fhd",
|
|
dashboardTitle,
|
|
}: DashboardViewerProps) {
|
|
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
|
const [loadingElements, setLoadingElements] = useState<Set<string>>(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 (
|
|
<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="mb-4 flex justify-end">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Download className="h-4 w-4" />
|
|
다운로드
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG 이미지로 저장</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div
|
|
className="dashboard-viewer-canvas 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">
|
|
{/* 다운로드 버튼 */}
|
|
<div className="flex justify-end">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Download className="h-4 w-4" />
|
|
다운로드
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG 이미지로 저장</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div className="dashboard-viewer-canvas">
|
|
{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>
|
|
</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>
|
|
);
|
|
}
|