대시보드 관리 수정

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

@ -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>
</>
)}
{/* 페이지네이션 */}

View File

@ -1,6 +1,3 @@
"use client";
import React from "react";
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
/**

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>