대시보드 관리 수정
This commit is contained in:
@@ -7,14 +7,7 @@ import { DashboardTopMenu } from "./DashboardTopMenu";
|
||||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import {
|
||||
GRID_CONFIG,
|
||||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
calculateCellSize,
|
||||
calculateGridConfig,
|
||||
calculateBoxSize,
|
||||
} from "./gridUtils";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
|
||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
@@ -199,7 +192,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
...el,
|
||||
dataSources: el.chartConfig?.dataSources || el.dataSources,
|
||||
}));
|
||||
|
||||
|
||||
setElements(elementsWithDataSources);
|
||||
|
||||
// elementCounter를 가장 큰 ID 번호로 설정
|
||||
@@ -582,11 +575,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
// 로딩 중이면 로딩 화면 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="bg-muted flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-foreground">대시보드 로딩 중...</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">잠시만 기다려주세요</div>
|
||||
<div className="text-foreground text-lg font-medium">대시보드 로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-1 text-sm">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -594,7 +587,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<div className="flex h-full flex-col bg-muted">
|
||||
<div className="bg-muted flex h-full flex-col">
|
||||
{/* 상단 메뉴바 */}
|
||||
<DashboardTopMenu
|
||||
onSaveLayout={saveLayout}
|
||||
@@ -610,7 +603,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="dashboard-canvas-container flex flex-1 items-start justify-center bg-muted p-8">
|
||||
<div className="dashboard-canvas-container bg-muted flex flex-1 items-start justify-center p-8">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
@@ -679,8 +672,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircle2 className="h-6 w-6 text-success" />
|
||||
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<CheckCircle2 className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||
|
||||
@@ -78,10 +78,9 @@ export function DashboardTopMenu({
|
||||
dataUrl: string,
|
||||
format: "png" | "pdf",
|
||||
canvasWidth: number,
|
||||
canvasHeight: 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;
|
||||
@@ -89,11 +88,9 @@ export function DashboardTopMenu({
|
||||
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;
|
||||
@@ -101,17 +98,12 @@ export function DashboardTopMenu({
|
||||
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",
|
||||
@@ -121,53 +113,44 @@ export function DashboardTopMenu({
|
||||
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 = async (format: "png" | "pdf") => {
|
||||
try {
|
||||
console.log("🔍 다운로드 시작:", format);
|
||||
|
||||
// 실제 위젯들이 있는 캔버스 찾기
|
||||
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
|
||||
console.log("🔍 캔버스 찾기:", canvas);
|
||||
|
||||
|
||||
if (!canvas) {
|
||||
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📸 html-to-image 로딩 중...");
|
||||
// html-to-image 동적 import
|
||||
const { toPng, toJpeg } = await import("html-to-image");
|
||||
// @ts-expect-error - 동적 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 });
|
||||
} catch (error) {
|
||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||
} catch {
|
||||
// WebGL 캔버스 캡처 실패 시 무시
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 캔버스의 실제 크기와 위치 가져오기
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = canvas.scrollWidth;
|
||||
|
||||
|
||||
// 실제 콘텐츠의 최하단 위치 계산
|
||||
const children = canvas.querySelectorAll(".canvas-element");
|
||||
let maxBottom = 0;
|
||||
@@ -178,17 +161,9 @@ export function DashboardTopMenu({
|
||||
maxBottom = relativeBottom;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
|
||||
|
||||
console.log("📐 캔버스 정보:", {
|
||||
rect: { x: rect.x, y: rect.y, 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 getDefaultBackgroundColor = () => {
|
||||
@@ -204,8 +179,8 @@ export function DashboardTopMenu({
|
||||
pixelRatio: 2, // 고해상도
|
||||
cacheBust: true,
|
||||
skipFonts: false,
|
||||
preferredFontFormat: 'woff2',
|
||||
filter: (node) => {
|
||||
preferredFontFormat: "woff2",
|
||||
filter: (node: Node) => {
|
||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||
if (node instanceof HTMLCanvasElement) {
|
||||
return false;
|
||||
@@ -213,26 +188,25 @@ export function DashboardTopMenu({
|
||||
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();
|
||||
@@ -240,50 +214,45 @@ export function DashboardTopMenu({
|
||||
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("✅ 최종 합성 완료");
|
||||
|
||||
|
||||
// 기존 dataUrl을 합성된 것으로 교체
|
||||
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)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-auto min-h-16 flex-col gap-3 border-b bg-background px-4 py-3 shadow-sm sm:h-16 sm:flex-row sm:items-center sm:justify-between sm:gap-0 sm:px-6 sm:py-0">
|
||||
<div className="bg-background flex h-16 items-center justify-between border-b px-4 py-3 shadow-sm">
|
||||
{/* 좌측: 대시보드 제목 */}
|
||||
<div className="flex flex-1 items-center gap-2 sm:gap-4">
|
||||
<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-base font-semibold text-foreground sm:text-lg">{dashboardTitle}</span>
|
||||
<span className="w-fit rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">편집 중</span>
|
||||
<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-2 sm:gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 해상도 선택 */}
|
||||
{onResolutionChange && (
|
||||
<ResolutionSelector
|
||||
@@ -293,7 +262,7 @@ export function DashboardTopMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="bg-border h-6 w-px" />
|
||||
|
||||
{/* 배경색 선택 */}
|
||||
{onBackgroundColorChange && (
|
||||
@@ -301,7 +270,7 @@ export function DashboardTopMenu({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<div className="h-4 w-4 rounded border border-border" style={{ backgroundColor }} />
|
||||
<div className="border-border h-4 w-4 rounded border" style={{ backgroundColor }} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[99999] w-64">
|
||||
@@ -355,7 +324,7 @@ export function DashboardTopMenu({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-border hidden sm:block" />
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
|
||||
{/* 차트 선택 */}
|
||||
<Select value={chartValue} onValueChange={handleChartSelect}>
|
||||
@@ -422,8 +391,13 @@ export function DashboardTopMenu({
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼 */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
||||
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-destructive hover:text-destructive">
|
||||
<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>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
<div className="space-y-3">
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-foreground">데이터베이스 선택</Label>
|
||||
<Label className="text-foreground mb-2 block text-xs font-medium">데이터베이스 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -88,12 +88,12 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
{dataSource.connectionType === "external" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-foreground">외부 커넥션</Label>
|
||||
<Label className="text-foreground text-xs font-medium">외부 커넥션</Label>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="flex items-center gap-1 text-[11px] text-primary transition-colors hover:text-primary"
|
||||
className="text-primary hover:text-primary flex items-center gap-1 text-[11px] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
커넥션 관리
|
||||
@@ -102,17 +102,17 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-blue-600" />
|
||||
<span className="ml-2 text-xs text-foreground">로딩 중...</span>
|
||||
<div className="border-border h-4 w-4 animate-spin rounded-full border-2 border-t-blue-600" />
|
||||
<span className="text-foreground ml-2 text-xs">로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
<div className="bg-destructive/10 rounded px-2 py-1.5">
|
||||
<div className="text-destructive text-xs">{error}</div>
|
||||
<button
|
||||
onClick={loadExternalConnections}
|
||||
className="mt-1 text-[11px] text-destructive underline hover:no-underline"
|
||||
className="text-destructive mt-1 text-[11px] underline hover:no-underline"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
@@ -120,13 +120,13 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded bg-warning/10 px-2 py-2 text-center">
|
||||
<div className="mb-1 text-xs text-warning">등록된 커넥션이 없습니다</div>
|
||||
<div className="bg-warning/10 rounded px-2 py-2 text-center">
|
||||
<div className="text-warning mb-1 text-xs">등록된 커넥션이 없습니다</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="text-[11px] text-warning underline hover:no-underline"
|
||||
className="text-warning text-[11px] underline hover:no-underline"
|
||||
>
|
||||
커넥션 등록하기
|
||||
</button>
|
||||
@@ -149,7 +149,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({conn.db_type.toUpperCase()})</span>
|
||||
<span className="text-muted-foreground text-[10px]">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -157,7 +157,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="space-y-0.5 rounded bg-muted px-2 py-1.5 text-[11px] text-foreground">
|
||||
<div className="bg-muted text-foreground space-y-0.5 rounded px-2 py-1.5 text-[11px]">
|
||||
<div>
|
||||
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user