대시보드 관리 수정

This commit is contained in:
dohyeons
2025-10-30 18:05:45 +09:00
parent 95dc16160e
commit 5d1d11869c
5 changed files with 255 additions and 166 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>