Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs 2025-11-03 14:42:44 +09:00
commit e089b41395
22 changed files with 1753 additions and 2839 deletions

View File

@ -13,18 +13,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
interface DashboardListClientProps {
@ -190,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" />
@ -206,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">
@ -229,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>
</>
)}
{/* 페이지네이션 */}
@ -307,26 +423,18 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
)}
{/* 삭제 확인 모달 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
&quot;{deleteTarget?.title}&quot; ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmModal
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="대시보드 삭제"
description={
<>
&quot;{deleteTarget?.title}&quot; ?
<br /> .
</>
}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

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

View File

@ -903,11 +903,6 @@ export function CanvasElement({
<div className="widget-interactive-area h-full w-full">
<ChartTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "list-v2" ? (
// 리스트 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
<ListTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? (
// 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
@ -1014,8 +1009,8 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "list" ? (
// 리스트 위젯 렌더링 (구버전)
) : element.type === "widget" && (element.subtype === "list" || element.subtype === "list-v2") ? (
// 리스트 위젯 렌더링 (v1 & v2)
<div className="h-full w-full">
<ListWidget element={element} />
</div>

View File

@ -4,17 +4,10 @@ import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigSidebar } from "./ElementConfigSidebar";
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
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";
@ -147,12 +140,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => {
if (initialDashboardId) {
console.log("📝 기존 대시보드 편집 모드");
loadDashboard(initialDashboardId);
} else {
console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDashboardId]);
// 대시보드 데이터 로드
@ -162,35 +151,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const { dashboardApi } = await import("@/lib/api/dashboard");
const dashboard = await dashboardApi.getDashboard(id);
console.log("📊 대시보드 로드:", {
id: dashboard.id,
title: dashboard.title,
settings: dashboard.settings,
settingsType: typeof dashboard.settings,
});
// 대시보드 정보 설정
setDashboardId(dashboard.id);
setDashboardTitle(dashboard.title);
// 저장된 설정 복원
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
console.log("🎨 설정 복원:", {
settings,
resolution: settings?.resolution,
backgroundColor: settings?.backgroundColor,
});
// 배경색 설정
if (settings?.backgroundColor) {
setCanvasBackgroundColor(settings.backgroundColor);
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
}
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
const loadedResolution = settings?.resolution || "fhd";
setResolution(loadedResolution);
console.log("✅ Resolution 설정됨:", loadedResolution);
// 요소들 설정
if (dashboard.elements && dashboard.elements.length > 0) {
@ -228,7 +203,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
// console.error("Invalid coordinates:", { x, y });
return;
}
@ -253,14 +227,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
// console.error("Invalid size calculated:", {
// canvasConfig,
// cellSize,
// cellWithGap,
// defaultCells,
// defaultWidth,
// defaultHeight,
// });
return;
}
@ -294,7 +260,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
// console.error("Invalid canvas config:", canvasConfig);
return;
}
@ -305,7 +270,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 요소 업데이트
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
setElements((prev) =>
prev.map((el) => {
if (el.id === id) {
return { ...el, ...updates };
}
return el;
}),
);
}, []);
// 요소 삭제
@ -382,16 +354,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setClearConfirmOpen(false);
}, []);
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
const saveWidgetConfig = useCallback(
(updates: Partial<DashboardElement>) => {
if (sidebarElement) {
updateElement(sidebarElement.id, updates);
}
},
[sidebarElement, updateElement],
);
// 사이드바 닫기
const handleCloseSidebar = useCallback(() => {
setSidebarOpen(false);
@ -404,14 +366,17 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
(updatedElement: DashboardElement) => {
// 현재 요소의 최신 상태를 가져와서 position과 size는 유지
const currentElement = elements.find((el) => el.id === updatedElement.id);
if (currentElement) {
// position과 size는 현재 상태 유지, 나머지만 업데이트
// id, position, size 제거 후 나머지만 업데이트
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, position, size, ...updates } = updatedElement;
const finalElement = {
...updatedElement,
position: currentElement.position,
size: currentElement.size,
...currentElement,
...updates,
};
updateElement(finalElement.id, finalElement);
updateElement(id, updates);
// 사이드바도 최신 상태로 업데이트
setSidebarElement(finalElement);
}
@ -443,14 +408,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const { dashboardApi } = await import("@/lib/api/dashboard");
const elementsData = elements.map((el) => {
// 야드 위젯인 경우 설정 로그 출력
// if (el.subtype === "yard-management-3d") {
// console.log("💾 야드 위젯 저장:", {
// id: el.id,
// yardConfig: el.yardConfig,
// hasLayoutId: !!el.yardConfig?.layoutId,
// });
// }
return {
id: el.id,
type: el.type,
@ -494,12 +451,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
},
};
console.log("💾 대시보드 업데이트 요청:", {
dashboardId,
updateData,
elementsCount: elementsData.length,
});
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
} else {
// 새 대시보드 생성
@ -560,18 +511,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 성공 모달 표시
setSuccessModalOpen(true);
} catch (error) {
console.error("❌ 대시보드 저장 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
// 상세한 에러 정보 로깅
if (error instanceof Error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
name: error.name,
});
}
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
throw error;
}
@ -582,11 +522,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 +534,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 +550,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={{
@ -651,8 +591,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</div>
</div>
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
<ElementConfigSidebar
{/* 요소 설정 사이드바 (통합) */}
<WidgetConfigSidebar
element={sidebarElement}
isOpen={sidebarOpen}
onClose={handleCloseSidebar}
@ -679,8 +619,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>
@ -761,13 +701,13 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
return "달력 위젯";
case "driver-management":
return "기사 관리 위젯";
case "list":
case "list-v2":
return "리스트 위젯";
case "map-summary":
case "map-summary-v2":
return "커스텀 지도 카드";
case "status-summary":
return "커스텀 상태 카드";
case "risk-alert":
case "risk-alert-v2":
return "리스크 알림 위젯";
case "todo":
return "할 일 위젯";
@ -821,7 +761,7 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
return "calendar";
case "driver-management":
return "driver-management";
case "list":
case "list-v2":
return "list-widget";
case "yard-management-3d":
return "yard-3d";

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,9 +88,7 @@ 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에서 이미지 크기 계산
@ -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,35 +113,27 @@ 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");
console.log("📸 캔버스 캡처 중...");
// @ts-expect-error - 동적 import
const { toPng } = await import("html-to-image");
// 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 }[] = [];
@ -158,9 +142,8 @@ export function DashboardTopMenu({
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 캔버스 캡처 실패 시 무시
}
});
@ -182,14 +165,6 @@ export function DashboardTopMenu({
// 실제 콘텐츠 높이 + 여유 공간 (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 = () => {
if (typeof window === "undefined") return "#ffffff";
@ -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;
@ -216,7 +191,6 @@ export function DashboardTopMenu({
// WebGL 캔버스를 이미지 위에 합성
if (webglImages.length > 0) {
console.log("🖼️ WebGL 이미지 합성 중...");
const img = new Image();
img.src = dataUrl;
await new Promise((resolve) => {
@ -248,42 +222,37 @@ export function DashboardTopMenu({
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

@ -1,563 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { MapTestConfigPanel } from "./MapTestConfigPanel";
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
interface ElementConfigSidebarProps {
element: DashboardElement | null;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
* - /
* -
* - "적용"
*/
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
const [dataSource, setDataSource] = useState<ChartDataSource>({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
const [dataSources, setDataSources] = useState<ChartDataSource[]>([]);
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
new Map(),
);
// 사이드바가 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
setDataSources(initialDataSources);
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setTestResults(new Map()); // 테스트 결과도 초기화
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
} else if (!isOpen) {
// 사이드바가 닫힐 때 모든 상태 초기화
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setChartConfig({});
setQueryResult(null);
setTestResults(new Map());
setCustomTitle("");
setShowHeader(true);
}
}, [isOpen, element]);
// Esc 키로 사이드바 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
setQueryResult(null);
setChartConfig({});
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 차트 설정 변경 처리
const handleChartConfigChange = useCallback(
(newConfig: ChartConfig) => {
setChartConfig(newConfig);
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용)
if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) {
onApply({
...element,
chartConfig: newConfig,
dataSource: dataSource,
customTitle: customTitle,
showHeader: showHeader,
});
}
},
[element, dataSource, customTitle, showHeader, onApply],
);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
setChartConfig({});
}, []);
// 적용 처리
const handleApply = useCallback(() => {
if (!element) return;
// 다중 데이터 소스 위젯 체크
const isMultiDS =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
const updatedElement: DashboardElement = {
...element,
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
dataSource: isMultiDS ? undefined : dataSource,
customTitle: customTitle.trim() || undefined,
showHeader,
};
onApply(updatedElement);
// 사이드바는 열린 채로 유지 (연속 수정 가능)
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
// 요소가 없으면 렌더링하지 않음
if (!element) return null;
// 리스트 위젯은 별도 사이드바로 처리
if (element.subtype === "list-v2") {
return (
<ListWidgetConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updatedElement) => {
onApply(updatedElement);
}}
/>
);
}
// 야드 위젯은 사이드바로 처리
if (element.subtype === "yard-management-3d") {
return (
<YardWidgetConfigSidebar
element={element}
isOpen={isOpen}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
onClose={onClose}
/>
);
}
// 사용자 커스텀 카드 위젯은 사이드바로 처리
if (element.subtype === "custom-metric-v2") {
return (
<CustomMetricConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
/>
);
}
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||
element.subtype === "booking-alert" ||
element.subtype === "maintenance" ||
element.subtype === "document" ||
element.subtype === "vehicle-status" ||
element.subtype === "vehicle-list" ||
element.subtype === "status-summary" ||
element.subtype === "delivery-status" ||
element.subtype === "delivery-status-summary" ||
element.subtype === "delivery-today-stats" ||
element.subtype === "cargo-list" ||
element.subtype === "customer-issues" ||
element.subtype === "driver-management" ||
element.subtype === "work-history" ||
element.subtype === "transport-stats";
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
const isSelfContainedWidget =
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
// 지도 위젯 (위도/경도 매핑 필요)
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary-v2";
// 헤더 전용 위젯
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 다중 데이터 소스 위젯
const isMultiDataSourceWidget =
(element.subtype as string) === "map-summary-v2" ||
(element.subtype as string) === "chart" ||
(element.subtype as string) === "list-v2" ||
(element.subtype as string) === "custom-metric-v2" ||
(element.subtype as string) === "risk-alert-v2";
// 저장 가능 여부 확인
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
const isApiSource = dataSource.type === "api";
const hasYAxis =
chartConfig.yAxis &&
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
const isHeaderChanged = showHeader !== (element.showHeader !== false);
const canApply =
isTitleChanged ||
isHeaderChanged ||
(isMultiDataSourceWidget
? true // 다중 데이터 소스 위젯은 항상 적용 가능
: isSimpleWidget
? queryResult && queryResult.rows.length > 0
: isMapWidget
? element.subtype === "map-summary-v2"
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
: queryResult &&
queryResult.rows.length > 0 &&
chartConfig.xAxis &&
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
return (
<div
className={cn(
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="bg-background flex items-center justify-between px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold"></span>
</div>
<span className="text-foreground text-xs font-semibold">{element.title}</span>
</div>
<Button onClick={onClose} variant="ghost" size="icon" className="h-6 w-6">
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 카드 */}
<div className="bg-background mb-3 rounded-lg p-3 shadow-sm">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase"> </div>
<div className="space-y-2">
{/* 커스텀 제목 입력 */}
<div>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="위젯 제목"
className="bg-muted focus:bg-background h-8 text-xs"
/>
</div>
{/* 헤더 표시 옵션 */}
<div className="border-border bg-muted flex items-center gap-2 rounded border px-2 py-1.5">
<Checkbox
id="showHeader"
checked={showHeader}
onCheckedChange={(checked) => setShowHeader(checked === true)}
/>
<Label htmlFor="showHeader" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
</div>
{/* 다중 데이터 소스 위젯 */}
{isMultiDataSourceWidget && (
<>
<div className="bg-background rounded-lg p-3 shadow-sm">
<MultiDataSourceConfig
dataSources={dataSources}
onChange={setDataSources}
onTestResult={(result, dataSourceId) => {
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
setQueryResult({
...result,
totalRows: result.rows.length,
executionTime: 0,
});
// 각 데이터 소스의 테스트 결과 저장
setTestResults((prev) => {
const updated = new Map(prev);
updated.set(dataSourceId, result);
return updated;
});
}}
/>
</div>
{/* 지도 위젯: 타일맵 URL 설정 */}
{element.subtype === "map-summary-v2" && (
<div className="bg-background rounded-lg shadow-sm">
<details className="group">
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
<div>
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
()
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]"> VWorld </div>
</div>
<svg
className="h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="border-t p-3">
<MapTestConfigPanel
config={chartConfig}
queryResult={undefined}
onConfigChange={handleChartConfigChange}
/>
</div>
</details>
</div>
)}
{/* 차트 위젯: 차트 설정 */}
{element.subtype === "chart" && (
<div className="bg-background rounded-lg shadow-sm">
<details className="group" open>
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
<div>
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{testResults.size > 0
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
</div>
</div>
<svg
className="h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="border-t p-3">
<MultiChartConfigPanel
config={chartConfig}
dataSources={dataSources}
testResults={testResults}
onConfigChange={handleChartConfigChange}
/>
</div>
</details>
</div>
)}
</>
)}
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">
</div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="bg-muted grid h-7 w-full grid-cols-2 p-0.5">
<TabsTrigger
value="database"
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
{/* 차트/지도 설정 */}
{!isSimpleWidget &&
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-summary-v2" ? (
<MapTestConfigPanel
config={chartConfig}
queryResult={queryResult || undefined}
onConfigChange={handleChartConfigChange}
/>
) : (
queryResult &&
queryResult.rows.length > 0 && (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
)
)
) : (
queryResult &&
queryResult.rows.length > 0 && (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)
)}
</div>
)}
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
{/* 차트/지도 설정 */}
{!isSimpleWidget &&
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-summary-v2" ? (
<MapTestConfigPanel
config={chartConfig}
queryResult={queryResult || undefined}
onConfigChange={handleChartConfigChange}
/>
) : (
queryResult &&
queryResult.rows.length > 0 && (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
)
)
) : (
queryResult &&
queryResult.rows.length > 0 && (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)
)}
</div>
)}
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="bg-success/10 mt-2 flex items-center gap-1.5 rounded px-2 py-1">
<div className="bg-success h-1.5 w-1.5 rounded-full" />
<span className="text-success text-[10px] font-medium">{queryResult.rows.length} </span>
</div>
)}
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="bg-background flex gap-2 p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<Button onClick={onClose} variant="outline" className="flex-1 text-xs">
</Button>
<Button onClick={handleApply} disabled={isHeaderOnlyWidget ? false : !canApply} className="flex-1 text-xs">
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,543 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import {
DashboardElement,
ChartDataSource,
ElementSubtype,
QueryResult,
ListWidgetConfig,
ChartConfig,
CustomMetricConfig,
} from "./types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { QueryEditor } from "./QueryEditor";
import { ListWidgetSection } from "./widget-sections/ListWidgetSection";
import { ChartConfigSection } from "./widget-sections/ChartConfigSection";
import { CustomMetricSection } from "./widget-sections/CustomMetricSection";
import { MapConfigSection } from "./widget-sections/MapConfigSection";
import { RiskAlertSection } from "./widget-sections/RiskAlertSection";
import MultiDataSourceConfig from "@/components/admin/dashboard/data-sources/MultiDataSourceConfig";
interface WidgetConfigSidebarProps {
element: DashboardElement | null;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
// 위젯 분류 헬퍼 함수
const needsDataSource = (subtype: ElementSubtype): boolean => {
// 차트 타입들
const chartTypes = ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"];
const dataWidgets = [
"list-v2",
"custom-metric-v2",
"chart",
"map-summary-v2",
"risk-alert-v2",
"yard-management-3d",
"todo",
"document",
"work-history",
"transport-stats",
"booking-alert",
"maintenance",
"vehicle-status",
"vehicle-list",
"status-summary",
"delivery-status",
"delivery-status-summary",
"delivery-today-stats",
"cargo-list",
"customer-issues",
"driver-management",
];
return chartTypes.includes(subtype) || dataWidgets.includes(subtype);
};
const getWidgetIcon = (subtype: ElementSubtype): string => {
const iconMap: Record<string, string> = {
"list-v2": "📋",
"custom-metric-v2": "📊",
chart: "📈",
"map-summary-v2": "🗺️",
"risk-alert-v2": "⚠️",
"yard-management-3d": "🏗️",
weather: "🌤️",
exchange: "💱",
calculator: "🧮",
clock: "🕐",
calendar: "📅",
todo: "✅",
document: "📄",
};
return iconMap[subtype] || "🔧";
};
const getWidgetTitle = (subtype: ElementSubtype): string => {
const titleMap: Record<string, string> = {
"list-v2": "리스트 위젯",
"custom-metric-v2": "통계 카드",
chart: "차트",
"map-summary-v2": "지도",
"risk-alert-v2": "리스크 알림",
"yard-management-3d": "야드 관리 3D",
weather: "날씨 위젯",
exchange: "환율 위젯",
calculator: "계산기",
clock: "시계",
calendar: "달력",
todo: "할 일",
document: "문서",
};
return titleMap[subtype] || "위젯";
};
/**
*
* - UI
* - : 제목,
* - : 데이터
*/
export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: WidgetConfigSidebarProps) {
// 일반 설정 state
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 데이터 소스 state
const [dataSource, setDataSource] = useState<ChartDataSource>({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
// 다중 데이터 소스 상태 추가
const [dataSources, setDataSources] = useState<ChartDataSource[]>(element?.dataSources || []);
// 쿼리 결과
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
// 리스트 위젯 설정
const [listConfig, setListConfig] = useState<ListWidgetConfig>({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
// 차트 설정
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
// 커스텀 메트릭 설정
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources(element.dataSources || []);
setQueryResult(null);
// 리스트 위젯 설정 초기화
if (element.subtype === "list-v2" && element.listConfig) {
setListConfig(element.listConfig);
} else {
setListConfig({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
}
// 차트 설정 초기화
setChartConfig(element.chartConfig || {});
// 커스텀 메트릭 설정 초기화
setCustomMetricConfig(element.customMetricConfig || {});
} else if (!isOpen) {
// 사이드바 닫힐 때 초기화
setCustomTitle("");
setShowHeader(true);
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setQueryResult(null);
setListConfig({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
setChartConfig({});
setCustomMetricConfig({});
}
}, [isOpen, element]);
// Esc 키로 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 다중 데이터 소스 변경 핸들러
const handleDataSourcesChange = useCallback((updatedSources: ChartDataSource[]) => {
setDataSources(updatedSources);
}, []);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback(
(result: QueryResult) => {
setQueryResult(result);
// 리스트 위젯: 쿼리 결과로 컬럼 자동 생성
if (element?.subtype === "list-v2" && result.columns && result.columns.length > 0) {
const newColumns = result.columns.map((col: string, idx: number) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
sortable: true,
filterable: false,
align: "left" as const,
}));
setListConfig((prev) => ({ ...prev, columns: newColumns }));
}
},
[element],
);
// 리스트 설정 변경
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
setListConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 차트 설정 변경
const handleChartConfigChange = useCallback((config: ChartConfig) => {
setChartConfig(config);
}, []);
// 커스텀 메트릭 설정 변경
const handleCustomMetricConfigChange = useCallback((updates: Partial<CustomMetricConfig>) => {
setCustomMetricConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 적용
const handleApply = useCallback(() => {
if (!element) return;
// 다중 데이터 소스를 사용하는 위젯 체크
const isMultiDataSourceWidget =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
const updatedElement: DashboardElement = {
...element,
customTitle: customTitle.trim() || undefined,
showHeader,
// 데이터 소스 처리
...(needsDataSource(element.subtype)
? {
dataSource,
// 다중 데이터 소스 위젯은 dataSources도 포함
...(isMultiDataSourceWidget
? {
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
}
: {}),
}
: {}),
// 리스트 위젯 설정
...(element.subtype === "list-v2"
? {
listConfig,
}
: {}),
// 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯)
...(element.type === "chart" ||
element.subtype === "chart" ||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
? {
// 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함
chartConfig: isMultiDataSourceWidget
? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] }
: chartConfig,
// 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함
...(isMultiDataSourceWidget
? {
dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [],
}
: {}),
}
: {}),
// 커스텀 메트릭 설정
...(element.subtype === "custom-metric-v2"
? {
customMetricConfig,
}
: {}),
};
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
subtype: element.subtype,
customMetricConfig,
updatedElement,
});
onApply(updatedElement);
onClose();
}, [
element,
customTitle,
showHeader,
dataSource,
dataSources,
listConfig,
chartConfig,
customMetricConfig,
onApply,
onClose,
]);
if (!element) return null;
const hasDataTab = needsDataSource(element.subtype);
const widgetIcon = getWidgetIcon(element.subtype);
const widgetTitle = getWidgetTitle(element.subtype);
return (
<div
className={cn(
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="bg-background flex items-center justify-between px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">{widgetIcon}</span>
</div>
<span className="text-foreground text-xs font-semibold">{widgetTitle} </span>
</div>
<button
onClick={onClose}
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded transition-colors"
>
<X className="text-muted-foreground h-3.5 w-3.5" />
</button>
</div>
{/* 탭 영역 */}
<Tabs defaultValue="general" className="flex flex-1 flex-col overflow-hidden">
<TabsList className="bg-background mx-3 mt-3 grid h-9 w-auto grid-cols-2">
<TabsTrigger value="general" className="text-xs">
</TabsTrigger>
{hasDataTab && (
<TabsTrigger value="data" className="text-xs">
</TabsTrigger>
)}
</TabsList>
{/* 일반 탭 */}
<TabsContent value="general" className="mt-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 위젯 제목 */}
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label htmlFor="widget-title" className="mb-2 block text-xs font-semibold">
</Label>
<Input
id="widget-title"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder={`기본 제목: ${element.title}`}
className="h-9 text-sm"
/>
<p className="text-muted-foreground mt-1.5 text-xs"> </p>
</div>
{/* 헤더 표시 */}
<div className="bg-background rounded-lg p-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex-1">
<Label htmlFor="show-header" className="text-xs font-semibold">
</Label>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<Switch id="show-header" checked={showHeader} onCheckedChange={setShowHeader} />
</div>
</div>
</div>
</TabsContent>
{/* 데이터 탭 */}
{hasDataTab && (
<TabsContent value="data" className="mt-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */}
{!["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Tabs
value={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="bg-muted grid h-8 w-full grid-cols-2 p-0.5">
<TabsTrigger
value="database"
className="data-[state=active]:bg-background h-7 rounded text-xs data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="data-[state=active]:bg-background h-7 rounded text-xs data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig
dataSource={dataSource}
onChange={handleDataSourceUpdate}
onTestResult={handleQueryTest}
/>
</TabsContent>
</Tabs>
</div>
)}
{/* 다중 데이터 소스 설정 */}
{["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
<MultiDataSourceConfig dataSources={dataSources} onChange={handleDataSourcesChange} />
)}
{/* 위젯별 커스텀 섹션 */}
{element.subtype === "list-v2" && (
<ListWidgetSection
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
)}
{/* 차트 설정 */}
{(element.type === "chart" ||
element.subtype === "chart" ||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(
element.subtype,
)) && (
<ChartConfigSection
queryResult={queryResult}
dataSource={dataSource}
config={chartConfig}
chartType={element.subtype}
onConfigChange={handleChartConfigChange}
/>
)}
{/* 커스텀 메트릭 설정 */}
{element.subtype === "custom-metric-v2" && (
<CustomMetricSection
queryResult={queryResult}
config={customMetricConfig}
onConfigChange={handleCustomMetricConfigChange}
/>
)}
{/* 지도 설정 */}
{element.subtype === "map-summary-v2" && <MapConfigSection queryResult={queryResult} />}
{/* 리스크 알림 설정 */}
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
</div>
</TabsContent>
)}
</Tabs>
{/* 푸터 */}
<div className="bg-background flex gap-2 border-t p-3">
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
</Button>
<Button onClick={handleApply} className="h-9 flex-1 text-sm">
</Button>
</div>
</div>
);
}

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>

View File

@ -380,15 +380,18 @@ export interface YardManagementConfig {
// 사용자 커스텀 카드 설정
export interface CustomMetricConfig {
groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false)
groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항)
metrics: Array<{
id: string; // 고유 ID
field: string; // 집계할 컬럼명
label: string; // 표시할 라벨
aggregation: "count" | "sum" | "avg" | "min" | "max"; // 집계 함수
unit: string; // 단위 (%, 건, 일, km, 톤 등)
color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray";
decimals: number; // 소수점 자릿수
// 단일 통계 카드 설정
valueColumn?: string; // 계산할 컬럼명
aggregation?: "sum" | "avg" | "count" | "min" | "max"; // 계산 방식
title?: string; // 카드 제목
unit?: string; // 표시 단위 (원, 건, % 등)
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
decimals?: number; // 소수점 자릿수 (기본: 0)
// 필터 조건
filters?: Array<{
column: string; // 필터 컬럼명
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "contains" | "not_contains"; // 조건 연산자
value: string; // 비교값
}>;
}

View File

@ -0,0 +1,46 @@
"use client";
import React from "react";
import { ChartConfig, QueryResult, ChartDataSource } from "../types";
import { Label } from "@/components/ui/label";
import { ChartConfigPanel } from "../ChartConfigPanel";
interface ChartConfigSectionProps {
queryResult: QueryResult | null;
dataSource: ChartDataSource;
config: ChartConfig;
chartType?: string;
onConfigChange: (config: ChartConfig) => void;
}
/**
*
* - , ,
*/
export function ChartConfigSection({
queryResult,
dataSource,
config,
chartType,
onConfigChange,
}: ChartConfigSectionProps) {
// 쿼리 결과가 없으면 표시하지 않음
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return null;
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<ChartConfigPanel
config={config}
queryResult={queryResult}
onConfigChange={onConfigChange}
chartType={chartType}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
</div>
);
}

View File

@ -0,0 +1,260 @@
"use client";
import React from "react";
import { CustomMetricConfig, QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, Plus, X } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface CustomMetricSectionProps {
queryResult: QueryResult | null;
config: CustomMetricConfig;
onConfigChange: (updates: Partial<CustomMetricConfig>) => void;
}
/**
*
* -
* - , (// ),
* -
*/
export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) {
console.log("⚙️ [CustomMetricSection] 렌더링:", { config, queryResult });
// 초기값 설정 (aggregation이 없으면 기본값 "sum" 설정)
React.useEffect(() => {
if (queryResult && queryResult.columns && queryResult.columns.length > 0 && !config.aggregation) {
console.log("🔧 기본 aggregation 설정: sum");
onConfigChange({ aggregation: "sum" });
}
}, [queryResult, config.aggregation, onConfigChange]);
// 쿼리 결과가 없으면 안내 메시지
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
// 필터 추가
const addFilter = () => {
const newFilters = [
...(config.filters || []),
{ column: queryResult.columns[0] || "", operator: "=" as const, value: "" },
];
onConfigChange({ filters: newFilters });
};
// 필터 제거
const removeFilter = (index: number) => {
const newFilters = [...(config.filters || [])];
newFilters.splice(index, 1);
onConfigChange({ filters: newFilters });
};
// 필터 업데이트
const updateFilter = (index: number, field: string, value: string) => {
const newFilters = [...(config.filters || [])];
newFilters[index] = { ...newFilters[index], [field]: value };
onConfigChange({ filters: newFilters });
};
// 통계 설정
return (
<div className="bg-background space-y-4 rounded-lg p-3 shadow-sm">
<div>
<Label className="mb-2 block text-xs font-semibold"> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 1. 필터 조건 (선택사항) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> ()</Label>
<Button onClick={addFilter} variant="outline" size="sm" className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{config.filters && config.filters.length > 0 ? (
<div className="space-y-2">
{config.filters.map((filter, index) => (
<div key={index} className="bg-muted/50 flex items-center gap-2 rounded-md border p-2">
{/* 컬럼 선택 */}
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{queryResult.columns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 연산자 선택 */}
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=" className="text-xs">
(=)
</SelectItem>
<SelectItem value="!=" className="text-xs">
()
</SelectItem>
<SelectItem value=">" className="text-xs">
(&gt;)
</SelectItem>
<SelectItem value="<" className="text-xs">
(&lt;)
</SelectItem>
<SelectItem value=">=" className="text-xs">
()
</SelectItem>
<SelectItem value="<=" className="text-xs">
()
</SelectItem>
<SelectItem value="contains" className="text-xs">
</SelectItem>
<SelectItem value="not_contains" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
{/* 값 입력 */}
<Input
value={filter.value}
onChange={(e) => updateFilter(index, "value", e.target.value)}
placeholder="값"
className="h-8 flex-1 text-xs"
/>
{/* 삭제 버튼 */}
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-8 w-8">
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-xs"> ( )</p>
)}
</div>
{/* 2. 계산할 컬럼 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={config.valueColumn || ""} onValueChange={(value) => onConfigChange({ valueColumn: value })}>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{queryResult.columns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 3. 계산 방식 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.aggregation || "sum"}
onValueChange={(value) => {
console.log("📐 계산 방식 변경:", value);
onConfigChange({ aggregation: value as "sum" | "avg" | "count" | "min" | "max" });
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="계산 방식" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum" className="text-xs">
(SUM)
</SelectItem>
<SelectItem value="avg" className="text-xs">
(AVG)
</SelectItem>
<SelectItem value="count" className="text-xs">
(COUNT)
</SelectItem>
<SelectItem value="min" className="text-xs">
(MIN)
</SelectItem>
<SelectItem value="max" className="text-xs">
(MAX)
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 4. 카드 제목 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.title || ""}
onChange={(e) => onConfigChange({ title: e.target.value })}
placeholder="예: 총 매출액"
className="h-9 text-xs"
/>
</div>
{/* 5. 표시 단위 (선택사항) */}
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
<Input
value={config.unit || ""}
onChange={(e) => onConfigChange({ unit: e.target.value })}
placeholder="예: 원, 건, %"
className="h-9 text-xs"
/>
</div>
{/* 미리보기 */}
{config.valueColumn && config.aggregation && (
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
<p className="text-muted-foreground text-xs font-semibold"> </p>
{/* 필터 조건 표시 */}
{config.filters && config.filters.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium">:</p>
{config.filters.map((filter, idx) => (
<p key={idx} className="text-muted-foreground text-xs">
· {filter.column} {filter.operator} &quot;{filter.value}&quot;
</p>
))}
</div>
)}
{/* 계산 표시 */}
<p className="text-xs font-medium">
{config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn})
{config.unit ? ` ${config.unit}` : ""}
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import React from "react";
import { ListWidgetConfig, QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
interface ListWidgetSectionProps {
queryResult: QueryResult | null;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
*
* -
* -
*/
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
return (
<div className="space-y-3">
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && queryResult.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<UnifiedColumnEditor queryResult={queryResult} config={config} onConfigChange={onConfigChange} />
</div>
)}
{/* 테이블 옵션 */}
{config.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<ListTableOptions config={config} onChange={onConfigChange} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface MapConfigSectionProps {
queryResult: QueryResult | null;
}
/**
*
* - /
*
* TODO: 상세 UI
*/
export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
UI는 .
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface RiskAlertSectionProps {
queryResult: QueryResult | null;
}
/**
*
* -
*
* TODO: 상세 UI
*/
export function RiskAlertSection({ queryResult }: RiskAlertSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
UI는 .
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -39,7 +39,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) {
return;
}
setIsLoading(true);
setError(null);
@ -168,8 +170,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div className="text-sm text-foreground"> ...</div>
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
<div className="text-foreground text-sm"> ...</div>
</div>
</div>
);
@ -181,8 +183,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-2xl"></div>
<div className="text-sm font-medium text-destructive"> </div>
<div className="mt-1 text-xs text-muted-foreground">{error}</div>
<div className="text-destructive text-sm font-medium"> </div>
<div className="text-muted-foreground mt-1 text-xs">{error}</div>
</div>
</div>
);
@ -194,8 +196,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
<div className="text-center">
<div className="mb-2 text-4xl">📋</div>
<div className="text-sm font-medium text-foreground"> </div>
<div className="mt-1 text-xs text-muted-foreground"> </div>
<div className="text-foreground text-sm font-medium"> </div>
<div className="text-muted-foreground mt-1 text-xs"> </div>
</div>
</div>
);
@ -222,7 +224,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
<div className="flex h-full w-full flex-col p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-foreground">{element.customTitle || element.title}</h3>
<h3 className="text-foreground text-sm font-semibold">{element.customTitle || element.title}</h3>
</div>
{/* 테이블 뷰 */}
@ -251,7 +253,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
<TableRow>
<TableCell
colSpan={displayColumns.filter((col) => col.visible).length}
className="text-center text-muted-foreground"
className="text-muted-foreground text-center"
>
</TableCell>
@ -281,7 +283,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{config.viewMode === "card" && (
<div className="flex-1 overflow-auto">
{paginatedRows.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground"> </div>
<div className="text-muted-foreground flex h-full items-center justify-center"> </div>
) : (
<div
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
@ -296,9 +298,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
.filter((col) => col.visible)
.map((col) => (
<div key={col.id}>
<div className="text-xs font-medium text-muted-foreground">{col.label || col.name}</div>
<div className="text-muted-foreground text-xs font-medium">{col.label || col.name}</div>
<div
className={`font-medium text-foreground ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
className={`text-foreground font-medium ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
>
{String(row[col.dataKey || col.field] ?? "")}
</div>

View File

@ -1,260 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
import { ApiConfig } from "../data-sources/ApiConfig";
import { QueryEditor } from "../QueryEditor";
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "./list-widget/ListTableOptions";
interface ListWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
*/
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
const [title, setTitle] = useState(element.title || "📋 리스트");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
element.listConfig || {
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
},
);
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen) {
setTitle(element.title || "📋 리스트");
if (element.dataSource) {
setDataSource(element.dataSource);
}
if (element.listConfig) {
setListConfig(element.listConfig);
}
}
}, [isOpen, element]);
// Esc 키로 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
setQueryResult(null);
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기)
const newColumns = result.columns.map((col, idx) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
align: "left" as const,
}));
setListConfig((prev) => ({
...prev,
columns: newColumns,
}));
}, []);
// 컬럼 설정 변경
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
setListConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 적용
const handleApply = useCallback(() => {
const updatedElement: DashboardElement = {
...element,
title,
dataSource,
listConfig,
};
onApply(updatedElement);
}, [element, title, dataSource, listConfig, onApply]);
// 저장 가능 여부
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">📋</span>
</div>
<span className="text-xs font-semibold text-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 */}
<div className="mb-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="리스트 이름"
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none"
/>
</div>
</div>
</div>
{/* 데이터 소스 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="grid h-7 w-full grid-cols-2 bg-muted p-0.5">
<TabsTrigger
value="database"
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-2 flex items-center gap-1.5 rounded bg-success/10 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-success" />
<span className="text-[10px] font-medium text-success">{queryResult.rows.length} </span>
</div>
)}
</div>
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && (
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<UnifiedColumnEditor
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
</div>
)}
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
{listConfig.columns.length > 0 && (
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
</button>
<button
onClick={handleApply}
disabled={!canApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@ -1,119 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DashboardElement } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface YardWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (updates: Partial<DashboardElement>) => void;
}
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
useEffect(() => {
if (isOpen) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
}
}, [isOpen, element]);
const handleApply = () => {
onApply({
customTitle,
showHeader,
});
onClose();
};
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">🏗</span>
</div>
<span className="text-xs font-semibold text-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 위젯 제목 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
<p className="mt-1 text-[10px] text-muted-foreground"> 제목: 야드 3D</p>
</div>
{/* 헤더 표시 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<RadioGroup
value={showHeader ? "show" : "hide"}
onValueChange={(value) => setShowHeader(value === "show")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
</button>
<button
onClick={handleApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors"
>
</button>
</div>
</div>
);
}

View File

@ -1,516 +0,0 @@
"use client";
import React, { useState } from "react";
import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react";
import { DatabaseConfig } from "../../data-sources/DatabaseConfig";
import { ChartDataSource } from "../../types";
import { ApiConfig } from "../../data-sources/ApiConfig";
import { QueryEditor } from "../../QueryEditor";
import { v4 as uuidv4 } from "uuid";
import { cn } from "@/lib/utils";
interface CustomMetricConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (updates: Partial<DashboardElement>) => void;
}
export default function CustomMetricConfigSidebar({
element,
isOpen,
onClose,
onApply,
}: CustomMetricConfigSidebarProps) {
const [metrics, setMetrics] = useState<CustomMetricConfig["metrics"]>(element.customMetricConfig?.metrics || []);
const [expandedMetric, setExpandedMetric] = useState<string | null>(null);
const [queryColumns, setQueryColumns] = useState<string[]>([]);
const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
const [groupByMode, setGroupByMode] = useState<boolean>(element.customMetricConfig?.groupByMode || false);
const [groupByDataSource, setGroupByDataSource] = useState<ChartDataSource | undefined>(
element.customMetricConfig?.groupByDataSource,
);
const [groupByQueryColumns, setGroupByQueryColumns] = useState<string[]>([]);
// 쿼리 실행 결과 처리 (일반 지표용)
const handleQueryTest = (result: any) => {
// QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } }
if (result.success && result.data?.columns) {
setQueryColumns(result.data.columns);
}
// ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] }
else if (result.columns && Array.isArray(result.columns)) {
setQueryColumns(result.columns);
}
// 오류 처리
else {
setQueryColumns([]);
}
};
// 쿼리 실행 결과 처리 (그룹별 카드용)
const handleGroupByQueryTest = (result: any) => {
if (result.success && result.data?.columns) {
setGroupByQueryColumns(result.data.columns);
} else if (result.columns && Array.isArray(result.columns)) {
setGroupByQueryColumns(result.columns);
} else {
setGroupByQueryColumns([]);
}
};
// 메트릭 추가
const addMetric = () => {
const newMetric = {
id: uuidv4(),
field: "",
label: "새 지표",
aggregation: "count" as const,
unit: "",
color: "gray" as const,
decimals: 1,
};
setMetrics([...metrics, newMetric]);
setExpandedMetric(newMetric.id);
};
// 메트릭 삭제
const deleteMetric = (id: string) => {
setMetrics(metrics.filter((m) => m.id !== id));
if (expandedMetric === id) {
setExpandedMetric(null);
}
};
// 메트릭 업데이트
const updateMetric = (id: string, field: string, value: any) => {
setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
};
// 메트릭 순서 변경
// 드래그 앤 드롭 핸들러
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const newMetrics = [...metrics];
const [draggedItem] = newMetrics.splice(draggedIndex, 1);
newMetrics.splice(dropIndex, 0, draggedItem);
setMetrics(newMetrics);
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
// 데이터 소스 업데이트
const handleDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
const newDataSource = { ...dataSource, ...updates };
setDataSource(newDataSource);
onApply({ dataSource: newDataSource });
};
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = (type: "database" | "api") => {
setDataSourceType(type);
const newDataSource: ChartDataSource =
type === "database"
? { type: "database", connectionType: "current", refreshInterval: 0 }
: { type: "api", method: "GET", refreshInterval: 0 };
setDataSource(newDataSource);
onApply({ dataSource: newDataSource });
setQueryColumns([]);
};
// 그룹별 데이터 소스 업데이트
const handleGroupByDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource;
setGroupByDataSource(newDataSource);
};
// 저장
const handleSave = () => {
onApply({
customTitle: customTitle,
showHeader: showHeader,
customMetricConfig: {
groupByMode,
groupByDataSource: groupByMode ? groupByDataSource : undefined,
metrics,
},
});
};
if (!isOpen) return null;
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">📊</span>
</div>
<span className="text-xs font-semibold text-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 헤더 설정 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
{/* 제목 입력 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="위젯 제목을 입력하세요"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center justify-between">
<label className="text-[9px] font-medium text-muted-foreground"> </label>
<button
onClick={() => setShowHeader(!showHeader)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showHeader ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
showHeader ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleDataSourceTypeChange("database")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "database"
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted text-foreground hover:border-border"
}`}
>
<span className="text-sm font-medium"></span>
</button>
<button
onClick={() => handleDataSourceTypeChange("api")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "api"
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted text-foreground hover:border-border"
}`}
>
<span className="text-sm font-medium">REST API</span>
</button>
</div>
</div>
{/* 데이터 소스 설정 */}
{dataSourceType === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</>
) : (
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
{/* 일반 지표 설정 (항상 표시) */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
{queryColumns.length > 0 && (
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{queryColumns.length === 0 ? (
<p className="text-xs text-muted-foreground"> </p>
) : (
<div className="space-y-2">
{metrics.length === 0 ? (
<p className="text-xs text-muted-foreground"> </p>
) : (
metrics.map((metric, index) => (
<div
key={metric.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
className={cn(
"rounded-md border bg-background p-2 transition-all",
draggedIndex === index && "opacity-50",
dragOverIndex === index && draggedIndex !== index && "border-primary border-2",
)}
>
{/* 헤더 */}
<div className="flex w-full items-center gap-2">
<div
draggable
onDragStart={() => handleDragStart(index)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{metric.label || "새 지표"}
</span>
<span className="text-[10px] text-muted-foreground">{metric.aggregation.toUpperCase()}</span>
<button
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
className="flex items-center justify-center rounded p-0.5 hover:bg-muted"
>
{expandedMetric === metric.id ? (
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</div>
</div>
{/* 설정 영역 */}
{expandedMetric === metric.id && (
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
{/* 2열 그리드 레이아웃 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 컬럼 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={metric.field}
onValueChange={(value) => updateMetric(metric.id, "field", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{queryColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 집계 함수 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={metric.aggregation}
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">COUNT</SelectItem>
<SelectItem value="sum">SUM</SelectItem>
<SelectItem value="avg">AVG</SelectItem>
<SelectItem value="min">MIN</SelectItem>
<SelectItem value="max">MAX</SelectItem>
</SelectContent>
</Select>
</div>
{/* 단위 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Input
value={metric.unit}
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="건, %, km"
/>
</div>
{/* 소수점 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={String(metric.decimals)}
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[0, 1, 2].map((num) => (
<SelectItem key={num} value={String(num)}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 표시 이름 (전체 너비) */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"> </label>
<Input
value={metric.label}
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="라벨"
/>
</div>
{/* 삭제 버튼 */}
<div className="border-t border-border pt-1.5">
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-6 w-full gap-1 text-[10px]"
onClick={() => deleteMetric(metric.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
))
)}
</div>
)}
</div>
{/* 그룹별 카드 생성 모드 (항상 표시) */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-medium text-foreground"> </label>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
<button
onClick={() => {
setGroupByMode(!groupByMode);
if (!groupByMode && !groupByDataSource) {
// 그룹별 모드 활성화 시 기본 데이터 소스 초기화
setGroupByDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
groupByMode ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
groupByMode ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
{groupByMode && (
<div className="rounded-md bg-primary/10 p-2 text-[9px] text-primary">
<p className="font-medium">💡 </p>
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
<li> 컬럼: 카드 </li>
<li> 컬럼: 카드 </li>
<li> : SELECT status, COUNT(*) FROM drivers GROUP BY status</li>
<li> <strong> </strong> ( )</li>
</ul>
</div>
)}
</div>
</div>
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
{groupByMode && groupByDataSource && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">
</div>
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
<QueryEditor
dataSource={groupByDataSource}
onDataSourceChange={handleGroupByDataSourceUpdate}
onQueryTest={handleGroupByQueryTest}
/>
</div>
)}
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 border-t bg-background p-3 shadow-sm">
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
</Button>
<Button className="focus:ring-primary/20 h-8 flex-1 text-xs" onClick={handleSave}>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
"use client";
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Loader2 } from "lucide-react";
interface DeleteConfirmModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: React.ReactNode;
onConfirm: () => void | Promise<void>;
confirmText?: string;
isLoading?: boolean;
}
/**
* ( )
* - 디자인: shadcn AlertDialog
* - 반응형: 모바일/
* -
*/
export function DeleteConfirmModal({
open,
onOpenChange,
title,
description,
onConfirm,
confirmText = "삭제",
isLoading = false,
}: DeleteConfirmModalProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel disabled={isLoading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isLoading}
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -8,6 +8,39 @@ interface CustomMetricWidgetProps {
element?: DashboardElement;
}
// 필터 적용 함수
const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => {
if (!filters || filters.length === 0) return rows;
return rows.filter((row) => {
return filters.every((filter) => {
const cellValue = String(row[filter.column] || "");
const filterValue = filter.value;
switch (filter.operator) {
case "=":
return cellValue === filterValue;
case "!=":
return cellValue !== filterValue;
case ">":
return parseFloat(cellValue) > parseFloat(filterValue);
case "<":
return parseFloat(cellValue) < parseFloat(filterValue);
case ">=":
return parseFloat(cellValue) >= parseFloat(filterValue);
case "<=":
return parseFloat(cellValue) <= parseFloat(filterValue);
case "contains":
return cellValue.includes(filterValue);
case "not_contains":
return !cellValue.includes(filterValue);
default:
return true;
}
});
});
};
// 집계 함수 실행
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
if (rows.length === 0) return 0;
@ -33,22 +66,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe
}
};
// 색상 스타일 매핑
const colorMap = {
indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
};
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
const [value, setValue] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
const config = element?.customMetricConfig;
useEffect(() => {
loadData();
@ -64,14 +87,111 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
setLoading(true);
setError(null);
// 그룹별 카드 데이터 로드
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
await loadGroupByData();
}
const dataSourceType = element?.dataSource?.type;
// 일반 지표 데이터 로드
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
await loadMetricsData();
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: (element.dataSource as any).connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
let rows = result.data.rows;
// 필터 적용
if (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setValue(0);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint,
headers: (element.dataSource as any).headers || {},
body: (element.dataSource as any).body,
authType: (element.dataSource as any).authType,
authConfig: (element.dataSource as any).authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
// API 응답 데이터 구조 확인 및 처리
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
// 필터 적용
if (config?.filters && config.filters.length > 0) {
rows = applyFilters(rows, config.filters);
}
// 집계 계산
if (config?.valueColumn && config?.aggregation) {
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
setValue(calculatedValue);
} else {
setValue(0);
}
} else {
throw new Error("API 응답 형식 오류");
}
}
} catch (err) {
console.error("데이터 로드 실패:", err);
@ -81,221 +201,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
}
};
// 그룹별 카드 데이터 로드
const loadGroupByData = async () => {
const groupByDS = element?.customMetricConfig?.groupByDataSource;
if (!groupByDS) return;
const dataSourceType = groupByDS.type;
// Database 타입
if (dataSourceType === "database") {
if (!groupByDS.query) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: groupByDS.query,
connectionType: groupByDS.connectionType || "current",
connectionId: (groupByDS as any).connectionId,
}),
});
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
if (rows.length > 0) {
const columns = result.data.columns || Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
// API 타입
else if (dataSourceType === "api") {
if (!groupByDS.endpoint) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: (groupByDS as any).method || "GET",
url: groupByDS.endpoint,
headers: (groupByDS as any).headers || {},
body: (groupByDS as any).body,
authType: (groupByDS as any).authType,
authConfig: (groupByDS as any).authConfig,
}),
});
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
};
// 일반 지표 데이터 로드
const loadMetricsData = async () => {
const dataSourceType = element?.dataSource?.type;
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setMetrics([]);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: (element.dataSource as any).connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
const calculatedMetrics =
element.customMetricConfig?.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
}) || [];
setMetrics(calculatedMetrics);
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setMetrics([]);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint,
headers: (element.dataSource as any).headers || {},
body: (element.dataSource as any).body,
authType: (element.dataSource as any).authType,
authConfig: (element.dataSource as any).authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
// API 응답 데이터 구조 확인 및 처리
let rows: any[] = [];
// result.data가 배열인 경우
if (Array.isArray(result.data)) {
rows = result.data;
}
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
}
// result.data.items가 배열인 경우
else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
}
// result.data.data가 배열인 경우
else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
}
// 그 외의 경우 단일 객체를 배열로 래핑
else {
rows = [result.data];
}
const calculatedMetrics =
element.customMetricConfig?.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
}) || [];
setMetrics(calculatedMetrics);
} else {
throw new Error("API 응답 형식 오류");
}
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center bg-background">
@ -323,103 +228,64 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
);
}
// 데이터 소스 체크
const hasMetricsDataSource =
// 설정 체크
const hasDataSource =
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
const hasGroupByDataSource =
isGroupByMode &&
element?.customMetricConfig?.groupByDataSource &&
((element.customMetricConfig.groupByDataSource.type === "database" &&
element.customMetricConfig.groupByDataSource.query) ||
(element.customMetricConfig.groupByDataSource.type === "api" &&
element.customMetricConfig.groupByDataSource.endpoint));
const hasConfig = config?.valueColumn && config?.aggregation;
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
// 둘 다 없으면 빈 화면 표시
const shouldShowEmpty =
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
if (shouldShowEmpty) {
// 설정이 없으면 안내 화면
if (!hasDataSource || !hasConfig) {
return (
<div className="flex h-full items-center justify-center bg-background p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-sm font-bold text-foreground"> </h3>
<h3 className="text-sm font-bold text-foreground"> </h3>
<div className="space-y-1.5 text-xs text-foreground">
<p className="font-medium">📊 </p>
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> SQL </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
<li> </li>
<li>
<strong> </strong>
</li>
<li> </li>
<li> </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
<p className="font-medium"> </p>
<p className="mb-1">
{isGroupByMode
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
</p>
{isGroupByMode && <p className="text-[9px]">💡 컬럼: 카드 , 컬럼: 카드 </p>}
<p>1. </p>
<p>2. ()</p>
<p>3. </p>
<p>4. </p>
</div>
</div>
</div>
);
}
// 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로)
// 실제 측정된 1칸 높이: 119px
const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진)
// 소수점 자릿수 (기본: 0)
const decimals = config?.decimals ?? 0;
const formattedValue = value.toFixed(decimals);
// 통계 카드 렌더링
return (
<div
className={`flex h-full w-full overflow-hidden bg-background p-0.5 ${
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
}`}
>
{/* 그룹별 카드 (활성화 시) */}
{isGroupByMode &&
groupedCards.map((card, index) => {
// 색상 순환 (6가지 색상)
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
const colorKey = colorKeys[index % colorKeys.length];
const colors = colorMap[colorKey];
<div className="flex h-full w-full items-center justify-center bg-background p-4">
<div className="flex flex-col items-center justify-center rounded-lg border bg-card p-6 text-center shadow-sm">
{/* 제목 */}
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
return (
<div
key={`group-${index}`}
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
>
<div className="text-[8px] leading-tight text-foreground">{card.label}</div>
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
</div>
);
})}
{/* 값 */}
<div className="flex items-baseline gap-1">
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
</div>
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
return (
<div
key={metric.id}
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
>
<div className="text-[8px] leading-tight text-foreground">{metric.label}</div>
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
{formattedValue}
<span className="ml-0 text-[8px]">{metric.unit}</span>
</div>
{/* 필터 표시 (디버깅용, 작게) */}
{config?.filters && config.filters.length > 0 && (
<div className="text-muted-foreground mt-2 text-xs">
: {config.filters.length}
</div>
);
})}
)}
</div>
</div>
);
}

View File

@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "relative top-0 z-auto translate-x-0"
} flex h-[calc(100vh-3.5rem)] w-[240px] max-w-[240px] min-w-[240px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
} flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
>
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||