Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
e089b41395
|
|
@ -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">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<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={
|
||||
<>
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; // 비교값
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
큼 (>)
|
||||
</SelectItem>
|
||||
<SelectItem value="<" className="text-xs">
|
||||
작음 (<)
|
||||
</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} "{filter.value}"
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산 표시 */}
|
||||
<p className="text-xs font-medium">
|
||||
{config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn})
|
||||
{config.unit ? ` ${config.unit}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue