대시보드 관리 수정
This commit is contained in:
parent
95dc16160e
commit
5d1d11869c
|
|
@ -181,14 +181,19 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
<>
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
|
|
@ -197,12 +202,65 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
|
||||
{/* 대시보드 목록 */}
|
||||
{loading ? (
|
||||
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||
<>
|
||||
{/* 데스크톱 테이블 스켈레톤 */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<TableRow key={index} className="border-b">
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : error ? (
|
||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
|
|
@ -220,70 +278,137 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
</div>
|
||||
</div>
|
||||
) : dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
<>
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
{dashboards.map((dashboard) => (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">설명</span>
|
||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">수정일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
onClick={() => handleCopy(dashboard)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue