Files
vexplor/frontend/components/admin/dashboard/DashboardTopMenu.tsx
2025-11-25 15:06:55 +09:00

426 lines
16 KiB
TypeScript

"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Save, Trash2, Palette, Download } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ElementType, ElementSubtype } from "./types";
import { ResolutionSelector, Resolution } from "./ResolutionSelector";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DashboardTopMenuProps {
onSaveLayout: () => void;
onClearCanvas: () => void;
dashboardTitle?: string;
onAddElement?: (type: ElementType, subtype: ElementSubtype) => void;
resolution?: Resolution;
onResolutionChange?: (resolution: Resolution) => void;
currentScreenResolution?: Resolution;
backgroundColor?: string;
onBackgroundColorChange?: (color: string) => void;
}
/**
* 대시보드 편집 화면 상단 메뉴바
* - 차트/위젯 선택 (셀렉트박스)
* - 저장/초기화 버튼
*/
export function DashboardTopMenu({
onSaveLayout,
onClearCanvas,
dashboardTitle,
onAddElement,
resolution = "fhd",
onResolutionChange,
currentScreenResolution,
backgroundColor = "#f9fafb",
onBackgroundColorChange,
}: DashboardTopMenuProps) {
const [chartValue, setChartValue] = React.useState<string>("");
const [widgetValue, setWidgetValue] = React.useState<string>("");
// 차트 선택 시 캔버스 중앙에 추가
const handleChartSelect = (value: string) => {
if (onAddElement) {
onAddElement("chart", value as ElementSubtype);
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
setTimeout(() => setChartValue(""), 0);
}
};
// 위젯 선택 시 캔버스 중앙에 추가
const handleWidgetSelect = (value: string) => {
if (onAddElement) {
onAddElement("widget", value as ElementSubtype);
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
setTimeout(() => setWidgetValue(""), 0);
}
};
// 대시보드 다운로드
// 헬퍼 함수: dataUrl로 다운로드 처리
const handleDownloadWithDataUrl = async (
dataUrl: string,
format: "png" | "pdf",
canvasWidth: number,
canvasHeight: number,
) => {
if (format === "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);
} else {
const jsPDF = (await import("jspdf")).default;
// dataUrl에서 이미지 크기 계산
const img = new Image();
img.src = dataUrl;
await new Promise((resolve) => {
img.onload = resolve;
});
// PDF 크기 계산 (A4 기준)
const imgWidth = 210; // A4 width in mm
const actualHeight = canvasHeight;
const actualWidth = canvasWidth;
const imgHeight = (actualHeight * imgWidth) / actualWidth;
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);
}
};
const handleDownload = async (format: "png" | "pdf") => {
try {
// 실제 위젯들이 있는 캔버스 찾기
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
if (!canvas) {
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
return;
}
// html-to-image 동적 import
// @ts-expect-error - 동적 import
const { toPng } = await import("html-to-image");
// 3D/WebGL 렌더링 완료 대기
await new Promise((resolve) => setTimeout(resolve, 1000));
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
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 });
} catch {
// WebGL 캔버스 캡처 실패 시 무시
}
});
// 캔버스의 실제 크기와 위치 가져오기
const rect = canvas.getBoundingClientRect();
const canvasWidth = canvas.scrollWidth;
// 실제 콘텐츠의 최하단 위치 계산
const children = canvas.querySelectorAll(".canvas-element");
let maxBottom = 0;
children.forEach((child) => {
const childRect = child.getBoundingClientRect();
const relativeBottom = childRect.bottom - rect.top;
if (relativeBottom > maxBottom) {
maxBottom = relativeBottom;
}
});
// 실제 콘텐츠 높이 + 여유 공간 (50px)
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
// html-to-image로 캔버스 캡처 (WebGL 제외)
const getDefaultBackgroundColor = () => {
if (typeof window === "undefined") return "#ffffff";
const bgValue = getComputedStyle(document.documentElement).getPropertyValue("--background").trim();
return bgValue ? `hsl(${bgValue})` : "#ffffff";
};
const dataUrl = await toPng(canvas, {
backgroundColor: backgroundColor || getDefaultBackgroundColor(),
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) {
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);
}
// 합성된 이미지를 dataUrl로 변환
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
// 기존 dataUrl을 합성된 것으로 교체
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
}
}
// WebGL이 없는 경우 기본 다운로드
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
} catch (error) {
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
}
};
return (
<div className="bg-background flex h-16 items-center justify-between border-b px-4 py-3 shadow-sm">
{/* 좌측: 대시보드 제목 */}
<div className="flex items-center gap-2 sm:gap-4">
{dashboardTitle && (
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="text-foreground text-base font-semibold sm:text-lg">{dashboardTitle}</span>
<span className="bg-primary/10 text-primary w-fit rounded px-2 py-0.5 text-xs font-medium"> </span>
</div>
)}
</div>
{/* 중앙: 해상도 선택 & 요소 추가 */}
<div className="flex flex-wrap items-center gap-3">
{/* 해상도 선택 */}
{onResolutionChange && (
<ResolutionSelector
value={resolution}
onChange={onResolutionChange}
currentScreenResolution={currentScreenResolution}
/>
)}
<div className="bg-border h-6 w-px" />
{/* 배경색 선택 */}
{onBackgroundColorChange && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Palette className="h-4 w-4" />
<div className="border-border h-4 w-4 rounded border" style={{ backgroundColor }} />
</Button>
</PopoverTrigger>
<PopoverContent className="z-[99999] w-64">
<div className="space-y-3">
<div>
<label className="text-sm font-medium"> </label>
</div>
<div className="flex gap-2">
<Input
type="color"
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
className="h-10 w-20 cursor-pointer"
/>
<Input
type="text"
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
placeholder="#f9fafb"
className="flex-1"
/>
</div>
<div className="grid grid-cols-6 gap-2">
{[
"#ffffff",
"#f9fafb",
"#f3f4f6",
"#e5e7eb",
"#1f2937",
"#111827",
"#fef3c7",
"#fde68a",
"#dbeafe",
"#bfdbfe",
"#fecaca",
"#fca5a5",
].map((color) => (
<button
key={color}
className="h-8 w-8 rounded border-2 transition-transform hover:scale-110"
style={{
backgroundColor: color,
borderColor: backgroundColor === color ? "#3b82f6" : "#d1d5db",
}}
onClick={() => onBackgroundColorChange(color)}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 차트 선택 */}
<Select value={chartValue} onValueChange={handleChartSelect}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="차트 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="horizontal-bar"> </SelectItem>
<SelectItem value="stacked-bar"> </SelectItem>
<SelectItem value="line"> </SelectItem>
<SelectItem value="area"> </SelectItem>
<SelectItem value="combo"> </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="pie"> </SelectItem>
<SelectItem value="donut"> </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{/* 위젯 선택 */}
<Select value={widgetValue} onValueChange={handleWidgetSelect}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="위젯 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="map-summary-v2"></SelectItem>
<SelectItem value="chart"> </SelectItem>
<SelectItem value="list-v2"></SelectItem>
<SelectItem value="custom-metric-v2"> </SelectItem>
<SelectItem value="risk-alert-v2">/</SelectItem>
<SelectItem value="yard-management-3d">3D </SelectItem>
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="weather"></SelectItem>
{/* <SelectItem value="weather-map">날씨 지도</SelectItem> */}
<SelectItem value="exchange"></SelectItem>
<SelectItem value="calculator"></SelectItem>
<SelectItem value="calendar"></SelectItem>
<SelectItem value="clock"></SelectItem>
<SelectItem value="todo"> </SelectItem>
{/* <SelectItem value="booking-alert">예약 알림</SelectItem> */}
<SelectItem value="document"></SelectItem>
{/* <SelectItem value="risk-alert">리스크 알림</SelectItem> */}
</SelectGroup>
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
{/* <SelectGroup>
<SelectLabel>차량 관리</SelectLabel>
<SelectItem value="vehicle-status">차량 상태</SelectItem>
<SelectItem value="vehicle-list">차량 목록</SelectItem>
<SelectItem value="vehicle-map">차량 위치</SelectItem>
</SelectGroup> */}
</SelectContent>
</Select>
</div>
{/* 우측: 액션 버튼 */}
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap">
<Button
variant="outline"
size="sm"
onClick={onClearCanvas}
className="text-destructive hover:text-destructive gap-2"
>
<Trash2 className="h-4 w-4" />
</Button>
<Button size="sm" onClick={onSaveLayout} className="gap-2">
<Save className="h-4 w-4" />
</Button>
{/* 다운로드 버튼 */}
<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>
);
}