- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
736 lines
29 KiB
TypeScript
736 lines
29 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-muted/30 p-4">
|
|
<div className="text-center">
|
|
<div className="mb-2 text-3xl">❓</div>
|
|
<div className="text-sm text-muted-foreground">알 수 없는 위젯 타입: {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 _dvd = new Date();
|
|
const filename = `${dashboardTitle || "dashboard"}_${_dvd.getFullYear()}-${String(_dvd.getMonth() + 1).padStart(2, "0")}-${String(_dvd.getDate()).padStart(2, "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 _dvp = new Date();
|
|
const filename = `${dashboardTitle || "dashboard"}_${_dvp.getFullYear()}-${String(_dvp.getMonth() + 1).padStart(2, "0")}-${String(_dvp.getDate()).padStart(2, "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: 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)}`);
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[backgroundColor, dashboardTitle, handleDownloadWithDataUrl],
|
|
);
|
|
|
|
// 캔버스 설정 계산
|
|
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="bg-muted flex h-full items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="mb-4 text-6xl">📊</div>
|
|
<div className="text-foreground mb-2 text-xl font-medium">표시할 요소가 없습니다</div>
|
|
<div className="text-muted-foreground text-sm">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardProvider>
|
|
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
|
<div className="bg-muted hidden min-h-screen py-8 lg:block" style={{ backgroundColor }}>
|
|
<div className="mx-auto px-4" style={{ width: "100%", maxWidth: "none" }}>
|
|
{/* 다운로드 버튼 - 비활성화 */}
|
|
{/* <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="bg-muted block min-h-screen 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="border-border bg-background relative overflow-hidden rounded-lg border shadow-sm"
|
|
style={{ minHeight: "300px" }}
|
|
>
|
|
{element.showHeader !== false && (
|
|
<div className="flex items-center justify-between px-2 py-1">
|
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
|
)}
|
|
</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="border-primary h-6 w-6 animate-spin rounded-full border-2 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 bg-background absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
|
|
// 단, 너비는 화면 크기에 따라 비율로 조정
|
|
const widthPercentage = (element.size.width / canvasWidth) * 100;
|
|
const leftPercentage = (element.position.x / canvasWidth) * 100;
|
|
|
|
return (
|
|
<div
|
|
className="border-border bg-background absolute overflow-hidden rounded-lg border shadow-sm"
|
|
style={{
|
|
left: `${leftPercentage}%`,
|
|
top: element.position.y,
|
|
width: `${widthPercentage}%`,
|
|
height: element.size.height,
|
|
}}
|
|
>
|
|
{element.showHeader !== false && (
|
|
<div className="flex items-center justify-between px-2 py-1">
|
|
{/* map-summary-v2는 customTitle이 없으면 제목 숨김 */}
|
|
{element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
|
<h3 className="text-foreground text-xs font-semibold">{element.customTitle || element.title}</h3>
|
|
)}
|
|
</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="border-primary h-6 w-6 animate-spin rounded-full border-2 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 bg-background absolute inset-0 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
|
<div className="text-foreground text-sm">업데이트 중...</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|