From 7b6132953ca5f42be20c960cb42077585a92c431 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 16:20:19 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 00f467c7..17fa484a 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -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" || From 95dc16160e1e4e9ce944ce8df3fc91aaaeadbd52 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 16:25:57 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC(=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardListClient.tsx | 43 ++++--------- .../components/common/DeleteConfirmModal.tsx | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 frontend/components/common/DeleteConfirmModal.tsx diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index 8c55e366..a67761e4 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -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 { @@ -307,26 +298,18 @@ export default function DashboardListClient({ initialDashboards, initialPaginati )} {/* 삭제 확인 모달 */} - - - - 대시보드 삭제 - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
+ + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. + + } + onConfirm={handleDeleteConfirm} + /> ); } diff --git a/frontend/components/common/DeleteConfirmModal.tsx b/frontend/components/common/DeleteConfirmModal.tsx new file mode 100644 index 00000000..6168a069 --- /dev/null +++ b/frontend/components/common/DeleteConfirmModal.tsx @@ -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; + confirmText?: string; + isLoading?: boolean; +} + +/** + * 삭제 확인 모달 (공통 컴포넌트) + * - 표준 디자인: shadcn AlertDialog 기반 + * - 반응형: 모바일/데스크톱 최적화 + * - 로딩 상태 지원 + */ +export function DeleteConfirmModal({ + open, + onOpenChange, + title, + description, + onConfirm, + confirmText = "삭제", + isLoading = false, +}: DeleteConfirmModalProps) { + return ( + + + + {title} + {description} + + + + 취소 + + + {isLoading && } + {confirmText} + + + + + ); +} From 5d1d11869c573c5088fcdadfb6136417949995cb Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 18:05:45 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardListClient.tsx | 267 +++++++++++++----- .../app/(main)/admin/dashboard/new/page.tsx | 3 - .../admin/dashboard/DashboardDesigner.tsx | 25 +- .../admin/dashboard/DashboardTopMenu.tsx | 100 +++---- .../dashboard/data-sources/DatabaseConfig.tsx | 26 +- 5 files changed, 255 insertions(+), 166 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index a67761e4..d8eeae61 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -181,14 +181,19 @@ export default function DashboardListClient({ initialDashboards, initialPaginati <> {/* 검색 및 액션 */}
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ 총 {totalCount.toLocaleString()} 건 +
+ + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + + +
+ + {/* 모바일/태블릿 카드 뷰 (lg 미만) */} +
+ {dashboards.map((dashboard) => ( +
+ {/* 헤더 */} +
+
+

{dashboard.title}

+

{dashboard.id}

+
+
+ + {/* 정보 */} +
+
+ 설명 + {dashboard.description || "-"} +
+
+ 생성일 + {formatDate(dashboard.createdAt)} +
+
+ 수정일 + {formatDate(dashboard.updatedAt)} +
+
+ + {/* 액션 */} +
+ + + +
+
+ ))} +
+ )} {/* 페이지네이션 */} diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx index d2f2ce11..56d28f46 100644 --- a/frontend/app/(main)/admin/dashboard/new/page.tsx +++ b/frontend/app/(main)/admin/dashboard/new/page.tsx @@ -1,6 +1,3 @@ -"use client"; - -import React from "react"; import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; /** diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index e643b9bd..39546d91 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,14 +7,7 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { - GRID_CONFIG, - snapToGrid, - snapSizeToGrid, - calculateCellSize, - calculateGridConfig, - calculateBoxSize, -} from "./gridUtils"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -199,7 +192,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D ...el, dataSources: el.chartConfig?.dataSources || el.dataSources, })); - + setElements(elementsWithDataSources); // elementCounter를 가장 큰 ID 번호로 설정 @@ -582,11 +575,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 로딩 중이면 로딩 화면 표시 if (isLoading) { return ( -
+
-
대시보드 로딩 중...
-
잠시만 기다려주세요
+
대시보드 로딩 중...
+
잠시만 기다려주세요
); @@ -594,7 +587,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return ( -
+
{/* 상단 메뉴바 */} +
-
- +
+
저장 완료 대시보드가 성공적으로 저장되었습니다. diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 611c7a97..7c9505e2 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -78,10 +78,9 @@ export function DashboardTopMenu({ dataUrl: string, format: "png" | "pdf", canvasWidth: number, - canvasHeight: number + canvasHeight: number, ) => { if (format === "png") { - console.log("💾 PNG 다운로드 시작..."); const link = document.createElement("a"); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; link.download = filename; @@ -89,11 +88,9 @@ export function DashboardTopMenu({ document.body.appendChild(link); link.click(); document.body.removeChild(link); - console.log("✅ PNG 다운로드 완료:", filename); } else { - console.log("📄 PDF 생성 중..."); const jsPDF = (await import("jspdf")).default; - + // dataUrl에서 이미지 크기 계산 const img = new Image(); img.src = dataUrl; @@ -101,17 +98,12 @@ export function DashboardTopMenu({ img.onload = resolve; }); - console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height }); - console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight }); - // PDF 크기 계산 (A4 기준) const imgWidth = 210; // A4 width in mm const actualHeight = canvasHeight; const actualWidth = canvasWidth; const imgHeight = (actualHeight * imgWidth) / actualWidth; - console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight }); - const pdf = new jsPDF({ orientation: imgHeight > imgWidth ? "portrait" : "landscape", unit: "mm", @@ -121,53 +113,44 @@ export function DashboardTopMenu({ pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; pdf.save(filename); - console.log("✅ PDF 다운로드 완료:", filename); } }; const handleDownload = async (format: "png" | "pdf") => { try { - console.log("🔍 다운로드 시작:", format); - // 실제 위젯들이 있는 캔버스 찾기 const canvas = document.querySelector(".dashboard-canvas") as HTMLElement; - console.log("🔍 캔버스 찾기:", canvas); - + if (!canvas) { alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요."); return; } - console.log("📸 html-to-image 로딩 중..."); // html-to-image 동적 import - const { toPng, toJpeg } = await import("html-to-image"); + // @ts-expect-error - 동적 import + const { toPng } = await import("html-to-image"); - console.log("📸 캔버스 캡처 중..."); - // 3D/WebGL 렌더링 완료 대기 - console.log("⏳ 3D 렌더링 완료 대기 중..."); await new Promise((resolve) => setTimeout(resolve, 1000)); - + // WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존) - console.log("🎨 WebGL 캔버스 처리 중..."); const webglCanvases = canvas.querySelectorAll("canvas"); const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = []; - + webglCanvases.forEach((webglCanvas) => { try { const rect = webglCanvas.getBoundingClientRect(); const dataUrl = webglCanvas.toDataURL("image/png"); webglImages.push({ canvas: webglCanvas, dataUrl, rect }); - console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height }); - } catch (error) { - console.warn("⚠️ WebGL 캔버스 캡처 실패:", error); + } catch { + // WebGL 캔버스 캡처 실패 시 무시 } }); - + // 캔버스의 실제 크기와 위치 가져오기 const rect = canvas.getBoundingClientRect(); const canvasWidth = canvas.scrollWidth; - + // 실제 콘텐츠의 최하단 위치 계산 const children = canvas.querySelectorAll(".canvas-element"); let maxBottom = 0; @@ -178,17 +161,9 @@ export function DashboardTopMenu({ maxBottom = relativeBottom; } }); - + // 실제 콘텐츠 높이 + 여유 공간 (50px) const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight; - - console.log("📐 캔버스 정보:", { - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - scroll: { width: canvasWidth, height: canvas.scrollHeight }, - calculated: { width: canvasWidth, height: canvasHeight }, - maxBottom: maxBottom, - webglCount: webglImages.length - }); // html-to-image로 캔버스 캡처 (WebGL 제외) const getDefaultBackgroundColor = () => { @@ -204,8 +179,8 @@ export function DashboardTopMenu({ pixelRatio: 2, // 고해상도 cacheBust: true, skipFonts: false, - preferredFontFormat: 'woff2', - filter: (node) => { + preferredFontFormat: "woff2", + filter: (node: Node) => { // WebGL 캔버스는 제외 (나중에 수동으로 합성) if (node instanceof HTMLCanvasElement) { return false; @@ -213,26 +188,25 @@ export function DashboardTopMenu({ return true; }, }); - + // WebGL 캔버스를 이미지 위에 합성 if (webglImages.length > 0) { - console.log("🖼️ WebGL 이미지 합성 중..."); const img = new Image(); img.src = dataUrl; await new Promise((resolve) => { img.onload = resolve; }); - + // 새 캔버스에 합성 const compositeCanvas = document.createElement("canvas"); compositeCanvas.width = img.width; compositeCanvas.height = img.height; const ctx = compositeCanvas.getContext("2d"); - + if (ctx) { // 기본 이미지 그리기 ctx.drawImage(img, 0, 0); - + // WebGL 이미지들을 위치에 맞게 그리기 for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) { const webglImg = new Image(); @@ -240,50 +214,45 @@ export function DashboardTopMenu({ await new Promise((resolve) => { webglImg.onload = resolve; }); - + // 상대 위치 계산 (pixelRatio 2 고려) const relativeX = (webglRect.left - rect.left) * 2; const relativeY = (webglRect.top - rect.top) * 2; const width = webglRect.width * 2; const height = webglRect.height * 2; - + ctx.drawImage(webglImg, relativeX, relativeY, width, height); - console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height }); } - + // 합성된 이미지를 dataUrl로 변환 const compositeDataUrl = compositeCanvas.toDataURL("image/png"); - console.log("✅ 최종 합성 완료"); - + // 기존 dataUrl을 합성된 것으로 교체 return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight); } } - console.log("✅ 캡처 완료 (WebGL 없음)"); - // WebGL이 없는 경우 기본 다운로드 await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight); } catch (error) { - console.error("❌ 다운로드 실패:", error); alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`); } }; return ( -
+
{/* 좌측: 대시보드 제목 */} -
+
{dashboardTitle && (
- {dashboardTitle} - 편집 중 + {dashboardTitle} + 편집 중
)}
{/* 중앙: 해상도 선택 & 요소 추가 */} -
+
{/* 해상도 선택 */} {onResolutionChange && ( )} -
+
{/* 배경색 선택 */} {onBackgroundColorChange && ( @@ -301,7 +270,7 @@ export function DashboardTopMenu({ @@ -355,7 +324,7 @@ export function DashboardTopMenu({ )} -
+
{/* 차트 선택 */} {selectedConnection && ( -
+
커넥션: {selectedConnection.connection_name}
From 9953014b888381b797d8b1e6cac47e5fd8cd42d0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 30 Oct 2025 18:10:52 +0900 Subject: [PATCH 4/9] =?UTF-8?q?DashboardDesigner=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 71 ++----------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 39546d91..4f1b546b 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -140,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]); // 대시보드 데이터 로드 @@ -155,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) { @@ -221,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; } @@ -246,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; } @@ -287,7 +260,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 좌표 유효성 확인 if (isNaN(centerX) || isNaN(centerY)) { - // console.error("Invalid canvas config:", canvasConfig); return; } @@ -375,16 +347,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setClearConfirmOpen(false); }, []); - // 리스트/야드 위젯 설정 저장 (Partial 업데이트) - const saveWidgetConfig = useCallback( - (updates: Partial) => { - if (sidebarElement) { - updateElement(sidebarElement.id, updates); - } - }, - [sidebarElement, updateElement], - ); - // 사이드바 닫기 const handleCloseSidebar = useCallback(() => { setSidebarOpen(false); @@ -436,14 +398,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, @@ -487,12 +441,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D }, }; - console.log("💾 대시보드 업데이트 요청:", { - dashboardId, - updateData, - elementsCount: elementsData.length, - }); - savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 @@ -553,18 +501,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; } @@ -754,13 +691,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 "할 일 위젯"; @@ -814,7 +751,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"; From e08671923562a4a0bfc402c89c80691764187c26 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 11:02:06 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 6 +- .../admin/dashboard/ElementConfigSidebar.tsx | 563 ------------------ .../admin/dashboard/WidgetConfigSidebar.tsx | 485 +++++++++++++++ .../widget-sections/ChartConfigSection.tsx | 46 ++ .../widget-sections/CustomMetricSection.tsx | 49 ++ .../widget-sections/ListWidgetSection.tsx | 41 ++ .../widget-sections/MapConfigSection.tsx | 47 ++ .../widget-sections/RiskAlertSection.tsx | 47 ++ .../widgets/ListWidgetConfigSidebar.tsx | 260 -------- .../widgets/YardWidgetConfigSidebar.tsx | 119 ---- .../CustomMetricConfigSidebar.tsx | 516 ---------------- 11 files changed, 718 insertions(+), 1461 deletions(-) delete mode 100644 frontend/components/admin/dashboard/ElementConfigSidebar.tsx create mode 100644 frontend/components/admin/dashboard/WidgetConfigSidebar.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx create mode 100644 frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx delete mode 100644 frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4f1b546b..cdd080b6 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -4,7 +4,7 @@ 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, calculateBoxSize } from "./gridUtils"; @@ -581,8 +581,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
- {/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */} - void; - onApply: (element: DashboardElement) => void; -} - -/** - * 요소 설정 사이드바 컴포넌트 - * - 왼쪽에서 슬라이드 인/아웃 - * - 캔버스 위에 오버레이 - * - "적용" 버튼으로 명시적 저장 - */ -export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) { - const [dataSource, setDataSource] = useState({ - type: "database", - connectionType: "current", - refreshInterval: 0, - }); - const [dataSources, setDataSources] = useState([]); - const [chartConfig, setChartConfig] = useState({}); - const [queryResult, setQueryResult] = useState(null); - const [customTitle, setCustomTitle] = useState(""); - const [showHeader, setShowHeader] = useState(true); - - // 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용) - const [testResults, setTestResults] = useState[] }>>( - 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) => { - 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 ( - { - onApply(updatedElement); - }} - /> - ); - } - - // 야드 위젯은 사이드바로 처리 - if (element.subtype === "yard-management-3d") { - return ( - { - onApply({ ...element, ...updates }); - }} - onClose={onClose} - /> - ); - } - - // 사용자 커스텀 카드 위젯은 사이드바로 처리 - if (element.subtype === "custom-metric-v2") { - return ( - { - 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 ( -
- {/* 헤더 */} -
-
-
- -
- {element.title} -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
- {/* 기본 설정 카드 */} -
-
기본 설정
-
- {/* 커스텀 제목 입력 */} -
- setCustomTitle(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - placeholder="위젯 제목" - className="bg-muted focus:bg-background h-8 text-xs" - /> -
- - {/* 헤더 표시 옵션 */} -
- setShowHeader(checked === true)} - /> - -
-
-
- - {/* 다중 데이터 소스 위젯 */} - {isMultiDataSourceWidget && ( - <> -
- { - // API 테스트 결과를 queryResult로 설정 (차트 설정용) - setQueryResult({ - ...result, - totalRows: result.rows.length, - executionTime: 0, - }); - - // 각 데이터 소스의 테스트 결과 저장 - setTestResults((prev) => { - const updated = new Map(prev); - updated.set(dataSourceId, result); - return updated; - }); - }} - /> -
- - {/* 지도 위젯: 타일맵 URL 설정 */} - {element.subtype === "map-summary-v2" && ( -
-
- -
-
- 타일맵 설정 (선택사항) -
-
기본 VWorld 타일맵 사용 중
-
- - - -
-
- -
-
-
- )} - - {/* 차트 위젯: 차트 설정 */} - {element.subtype === "chart" && ( -
-
- -
-
- 차트 설정 -
-
- {testResults.size > 0 - ? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정` - : "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"} -
-
- - - -
-
- -
-
-
- )} - - )} - - {/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */} - {!isHeaderOnlyWidget && !isMultiDataSourceWidget && ( -
-
- 데이터 소스 -
- - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - - - - - - - {/* 차트/지도 설정 */} - {!isSimpleWidget && - (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && ( -
- {isMapWidget ? ( - element.subtype === "map-summary-v2" ? ( - - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - ) - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - )} -
- )} -
- - - - - {/* 차트/지도 설정 */} - {!isSimpleWidget && - (element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && ( -
- {isMapWidget ? ( - element.subtype === "map-summary-v2" ? ( - - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - ) - ) : ( - queryResult && - queryResult.rows.length > 0 && ( - - ) - )} -
- )} -
-
- - {/* 데이터 로드 상태 */} - {queryResult && ( -
-
- {queryResult.rows.length}개 데이터 로드됨 -
- )} -
- )} -
- - {/* 푸터: 적용 버튼 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx new file mode 100644 index 00000000..f4104e73 --- /dev/null +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -0,0 +1,485 @@ +"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"; + +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 = { + "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 = { + "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(""); + const [showHeader, setShowHeader] = useState(true); + + // 데이터 소스 state + const [dataSource, setDataSource] = useState({ + type: "database", + connectionType: "current", + refreshInterval: 0, + }); + + // 쿼리 결과 + const [queryResult, setQueryResult] = useState(null); + + // 리스트 위젯 설정 + const [listConfig, setListConfig] = useState({ + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }); + + // 차트 설정 + const [chartConfig, setChartConfig] = useState({}); + + // 커스텀 메트릭 설정 + const [customMetricConfig, setCustomMetricConfig] = useState({ metrics: [] }); + + // 사이드바 열릴 때 초기화 + useEffect(() => { + if (isOpen && element) { + setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); + setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + 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 || { metrics: [] }); + } else if (!isOpen) { + // 사이드바 닫힐 때 초기화 + setCustomTitle(""); + setShowHeader(true); + setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); + setQueryResult(null); + setListConfig({ + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }); + setChartConfig({}); + setCustomMetricConfig({ metrics: [] }); + } + }, [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) => { + setDataSource((prev) => ({ ...prev, ...updates })); + }, []); + + // 쿼리 테스트 결과 처리 + 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) => { + setListConfig((prev) => ({ ...prev, ...updates })); + }, []); + + // 차트 설정 변경 + const handleChartConfigChange = useCallback((config: ChartConfig) => { + setChartConfig(config); + }, []); + + // 커스텀 메트릭 설정 변경 + const handleCustomMetricConfigChange = useCallback((updates: Partial) => { + setCustomMetricConfig((prev) => ({ ...prev, ...updates })); + }, []); + + // 적용 + const handleApply = useCallback(() => { + if (!element) return; + + const updatedElement: DashboardElement = { + ...element, + customTitle: customTitle.trim() || undefined, + showHeader, + // 데이터 소스가 필요한 위젯만 dataSource 포함 + ...(needsDataSource(element.subtype) + ? { + dataSource, + } + : {}), + // 리스트 위젯 설정 + ...(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, + } + : {}), + // 커스텀 메트릭 설정 + ...(element.subtype === "custom-metric-v2" + ? { + customMetricConfig, + } + : {}), + }; + + onApply(updatedElement); + onClose(); + }, [element, customTitle, showHeader, dataSource, 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 ( +
+ {/* 헤더 */} +
+
+
+ {widgetIcon} +
+ {widgetTitle} 설정 +
+ +
+ + {/* 탭 영역 */} + + + + 일반 + + {hasDataTab && ( + + 데이터 + + )} + + + {/* 일반 탭 */} + +
+ {/* 위젯 제목 */} +
+ + setCustomTitle(e.target.value)} + placeholder={`기본 제목: ${element.title}`} + className="h-9 text-sm" + /> +

비워두면 기본 제목이 표시됩니다

+
+ + {/* 헤더 표시 */} +
+
+
+ +

위젯 상단 헤더를 표시합니다

+
+ +
+
+
+
+ + {/* 데이터 탭 */} + {hasDataTab && ( + +
+ {/* 데이터 소스 선택 */} +
+ + + handleDataSourceTypeChange(value as "database" | "api")} + className="w-full" + > + + + 데이터베이스 + + + REST API + + + + + + + + + + + + +
+ + {/* 위젯별 커스텀 섹션 */} + {element.subtype === "list-v2" && ( + + )} + + {/* 차트 설정 */} + {(element.type === "chart" || + element.subtype === "chart" || + ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes( + element.subtype, + )) && ( + + )} + + {/* 커스텀 메트릭 설정 */} + {element.subtype === "custom-metric-v2" && ( + + )} + + {/* 지도 설정 */} + {element.subtype === "map-summary-v2" && } + + {/* 리스크 알림 설정 */} + {element.subtype === "risk-alert-v2" && } +
+
+ )} +
+ + {/* 푸터 */} +
+ + +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx new file mode 100644 index 00000000..cf6e38e7 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/ChartConfigSection.tsx @@ -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 ( +
+ + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx new file mode 100644 index 00000000..eca4b018 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx @@ -0,0 +1,49 @@ +"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 } from "lucide-react"; + +interface CustomMetricSectionProps { + queryResult: QueryResult | null; + config: CustomMetricConfig; + onConfigChange: (updates: Partial) => void; +} + +/** + * 통계 카드 설정 섹션 + * - 메트릭 설정, 아이콘, 색상 + * + * TODO: 상세 설정 UI 추가 필요 + */ +export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) { + // 쿼리 결과가 없으면 안내 메시지 표시 + if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { + return ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 통계 카드 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx new file mode 100644 index 00000000..8459716e --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -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) => void; +} + +/** + * 리스트 위젯 설정 섹션 + * - 컬럼 설정 + * - 테이블 옵션 + */ +export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) { + return ( +
+ {/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} + {queryResult && queryResult.columns.length > 0 && ( +
+ + +
+ )} + + {/* 테이블 옵션 */} + {config.columns.length > 0 && ( +
+ + +
+ )} +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx new file mode 100644 index 00000000..ae75f7cb --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx @@ -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 ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 지도 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx b/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx new file mode 100644 index 00000000..77c30d80 --- /dev/null +++ b/frontend/components/admin/dashboard/widget-sections/RiskAlertSection.tsx @@ -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 ( +
+ + + + + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + + +
+ ); + } + + return ( +
+ + + + + 리스크 알림 상세 설정 UI는 추후 추가 예정입니다. + + +
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx deleted file mode 100644 index 5e7d9131..00000000 --- a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx +++ /dev/null @@ -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( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [queryResult, setQueryResult] = useState(null); - const [listConfig, setListConfig] = useState( - 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) => { - 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) => { - 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 ( -
- {/* 헤더 */} -
-
-
- 📋 -
- 리스트 위젯 설정 -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
- {/* 기본 설정 */} -
-
기본 설정
-
-
- 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" - /> -
-
-
- - {/* 데이터 소스 */} -
-
데이터 소스
- - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - - - - - - - - - - - - - {/* 데이터 로드 상태 */} - {queryResult && ( -
-
- {queryResult.rows.length}개 데이터 로드됨 -
- )} -
- - {/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} - {queryResult && ( -
-
컬럼 설정
- -
- )} - - {/* 테이블 옵션 - 컬럼이 있을 때만 표시 */} - {listConfig.columns.length > 0 && ( -
-
테이블 옵션
- -
- )} -
- - {/* 푸터: 적용 버튼 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx deleted file mode 100644 index 3a72453a..00000000 --- a/frontend/components/admin/dashboard/widgets/YardWidgetConfigSidebar.tsx +++ /dev/null @@ -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) => 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 ( -
- {/* 헤더 */} -
-
-
- 🏗️ -
- 야드 관리 위젯 설정 -
- -
- - {/* 컨텐츠 */} -
-
- {/* 위젯 제목 */} -
-
위젯 제목
- setCustomTitle(e.target.value)} - placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)" - className="h-8 text-xs" - style={{ fontSize: "12px" }} - /> -

기본 제목: 야드 관리 3D

-
- - {/* 헤더 표시 */} -
-
헤더 표시
- setShowHeader(value === "show")} - className="flex items-center gap-3" - > -
- - -
-
- - -
-
-
-
-
- - {/* 푸터 */} -
- - -
-
- ); -} diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx deleted file mode 100644 index 6d341612..00000000 --- a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx +++ /dev/null @@ -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) => void; -} - -export default function CustomMetricConfigSidebar({ - element, - isOpen, - onClose, - onApply, -}: CustomMetricConfigSidebarProps) { - const [metrics, setMetrics] = useState(element.customMetricConfig?.metrics || []); - const [expandedMetric, setExpandedMetric] = useState(null); - const [queryColumns, setQueryColumns] = useState([]); - const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database"); - const [dataSource, setDataSource] = useState( - element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, - ); - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || ""); - const [showHeader, setShowHeader] = useState(element.showHeader !== false); - const [groupByMode, setGroupByMode] = useState(element.customMetricConfig?.groupByMode || false); - const [groupByDataSource, setGroupByDataSource] = useState( - element.customMetricConfig?.groupByDataSource, - ); - const [groupByQueryColumns, setGroupByQueryColumns] = useState([]); - - // 쿼리 실행 결과 처리 (일반 지표용) - 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) => { - 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) => { - 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 ( -
- {/* 헤더 */} -
-
-
- 📊 -
- 커스텀 카드 설정 -
- -
- - {/* 본문: 스크롤 가능 영역 */} -
-
- {/* 헤더 설정 */} -
-
헤더 설정
-
- {/* 제목 입력 */} -
- - setCustomTitle(e.target.value)} - placeholder="위젯 제목을 입력하세요" - className="h-8 text-xs" - style={{ fontSize: "12px" }} - /> -
- - {/* 헤더 표시 여부 */} -
- - -
-
-
- - {/* 데이터 소스 타입 선택 */} -
-
데이터 소스 타입
-
- - -
-
- - {/* 데이터 소스 설정 */} - {dataSourceType === "database" ? ( - <> - - - - ) : ( - - )} - - {/* 일반 지표 설정 (항상 표시) */} -
-
-
일반 지표
- {queryColumns.length > 0 && ( - - )} -
- - {queryColumns.length === 0 ? ( -

먼저 쿼리를 실행하세요

- ) : ( -
- {metrics.length === 0 ? ( -

추가된 지표가 없습니다

- ) : ( - metrics.map((metric, index) => ( -
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", - )} - > - {/* 헤더 */} -
-
handleDragStart(index)} - onDragEnd={handleDragEnd} - className="cursor-grab active:cursor-grabbing" - > - -
-
- - {metric.label || "새 지표"} - - {metric.aggregation.toUpperCase()} - -
-
- - {/* 설정 영역 */} - {expandedMetric === metric.id && ( -
- {/* 2열 그리드 레이아웃 */} -
- {/* 컬럼 */} -
- - -
- - {/* 집계 함수 */} -
- - -
- - {/* 단위 */} -
- - updateMetric(metric.id, "unit", e.target.value)} - className="h-6 w-full text-[10px]" - placeholder="건, %, km" - /> -
- - {/* 소수점 */} -
- - -
-
- - {/* 표시 이름 (전체 너비) */} -
- - updateMetric(metric.id, "label", e.target.value)} - className="h-6 w-full text-[10px]" - placeholder="라벨" - /> -
- - {/* 삭제 버튼 */} -
- -
-
- )} -
- )) - )} -
- )} -
- - {/* 그룹별 카드 생성 모드 (항상 표시) */} -
-
표시 모드
-
-
-
- -

- 쿼리 결과의 각 행을 개별 카드로 표시 -

-
- -
- {groupByMode && ( -
-

💡 사용 방법

-
    -
  • • 첫 번째 컬럼: 카드 제목
  • -
  • • 두 번째 컬럼: 카드 값
  • -
  • • 예: SELECT status, COUNT(*) FROM drivers GROUP BY status
  • -
  • 아래 별도 쿼리로 설정 (일반 지표와 독립적)
  • -
-
- )} -
-
- - {/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */} - {groupByMode && groupByDataSource && ( -
-
- 그룹별 카드 쿼리 -
- - -
- )} -
-
- - {/* 푸터 */} -
- - -
-
- ); -} From 085679a95a0ea0639d1b103b687a1c96e5877ec7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 12:10:46 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=BB=A8=ED=85=90=EC=B8=A0=EA=B0=80=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 9 ++----- .../admin/dashboard/DashboardDesigner.tsx | 22 ++++++++++----- .../admin/dashboard/WidgetConfigSidebar.tsx | 27 +++++++++++++++++-- .../admin/dashboard/widgets/ListWidget.tsx | 26 +++++++++--------- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 599718b9..90eec9ca 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -903,11 +903,6 @@ export function CanvasElement({
- ) : element.type === "widget" && element.subtype === "list-v2" ? ( - // 리스트 위젯 (다중 데이터 소스) - 승격 완료 -
- -
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? ( // 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
@@ -1014,8 +1009,8 @@ export function CanvasElement({ }} />
- ) : element.type === "widget" && element.subtype === "list" ? ( - // 리스트 위젯 렌더링 (구버전) + ) : element.type === "widget" && (element.subtype === "list" || element.subtype === "list-v2") ? ( + // 리스트 위젯 렌더링 (v1 & v2)
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index cdd080b6..3df5abdd 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -270,7 +270,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 요소 업데이트 const updateElement = useCallback((id: string, updates: Partial) => { - 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; + }), + ); }, []); // 요소 삭제 @@ -359,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); } diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index f4104e73..4f2b14a5 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -270,14 +270,28 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge 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, - // 데이터 소스가 필요한 위젯만 dataSource 포함 + // 데이터 소스 처리 ...(needsDataSource(element.subtype) ? { dataSource, + // 다중 데이터 소스 위젯은 dataSources도 포함 + ...(isMultiDataSourceWidget + ? { + dataSources: element.dataSources || [], + } + : {}), } : {}), // 리스트 위젯 설정 @@ -291,7 +305,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge element.subtype === "chart" || ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype) ? { - chartConfig, + // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 + chartConfig: isMultiDataSourceWidget + ? { ...chartConfig, dataSources: element.dataSources || [] } + : chartConfig, + // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 + ...(isMultiDataSourceWidget + ? { + dataSources: element.dataSources || [], + } + : {}), } : {}), // 커스텀 메트릭 설정 diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 54237d26..8193aea4 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -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 (
-
-
데이터 로딩 중...
+
+
데이터 로딩 중...
); @@ -181,8 +183,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
⚠️
-
오류 발생
-
{error}
+
오류 발생
+
{error}
); @@ -194,8 +196,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
📋
-
리스트를 설정하세요
-
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
+
리스트를 설정하세요
+
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
); @@ -222,7 +224,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
{/* 제목 - 항상 표시 */}
-

{element.customTitle || element.title}

+

{element.customTitle || element.title}

{/* 테이블 뷰 */} @@ -251,7 +253,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { col.visible).length} - className="text-center text-muted-foreground" + className="text-muted-foreground text-center" > 데이터가 없습니다 @@ -281,7 +283,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {config.viewMode === "card" && (
{paginatedRows.length === 0 ? ( -
데이터가 없습니다
+
데이터가 없습니다
) : (
col.visible) .map((col) => (
-
{col.label || col.name}
+
{col.label || col.name}
{String(row[col.dataKey || col.field] ?? "")}
From 6d9c7ed7bf09dea54920bee1f215160b3b6df280 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 14:07:02 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=EC=A7=80=EB=8F=84=EC=AA=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/WidgetConfigSidebar.tsx | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 4f2b14a5..4fb44de0 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -25,6 +25,7 @@ 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; @@ -121,6 +122,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge refreshInterval: 0, }); + // 다중 데이터 소스 상태 추가 + const [dataSources, setDataSources] = useState(element?.dataSources || []); + // 쿼리 결과 const [queryResult, setQueryResult] = useState(null); @@ -148,6 +152,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(element.customTitle || ""); setShowHeader(element.showHeader !== false); setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + setDataSources(element.dataSources || []); setQueryResult(null); // 리스트 위젯 설정 초기화 @@ -176,6 +181,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(""); setShowHeader(true); setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 }); + setDataSources([]); setQueryResult(null); setListConfig({ viewMode: "table", @@ -228,6 +234,11 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setDataSource((prev) => ({ ...prev, ...updates })); }, []); + // 다중 데이터 소스 변경 핸들러 + const handleDataSourcesChange = useCallback((updatedSources: ChartDataSource[]) => { + setDataSources(updatedSources); + }, []); + // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback( (result: QueryResult) => { @@ -289,7 +300,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge // 다중 데이터 소스 위젯은 dataSources도 포함 ...(isMultiDataSourceWidget ? { - dataSources: element.dataSources || [], + dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], } : {}), } @@ -307,12 +318,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge ? { // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 chartConfig: isMultiDataSourceWidget - ? { ...chartConfig, dataSources: element.dataSources || [] } + ? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] } : chartConfig, // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 ...(isMultiDataSourceWidget ? { - dataSources: element.dataSources || [], + dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], } : {}), } @@ -327,7 +338,18 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge onApply(updatedElement); onClose(); - }, [element, customTitle, showHeader, dataSource, listConfig, chartConfig, customMetricConfig, onApply, onClose]); + }, [ + element, + customTitle, + showHeader, + dataSource, + dataSources, + listConfig, + chartConfig, + customMetricConfig, + onApply, + onClose, + ]); if (!element) return null; @@ -408,48 +430,57 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge {hasDataTab && (
- {/* 데이터 소스 선택 */} -
- + {/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */} + {!["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( + element.subtype, + ) && ( +
+ - handleDataSourceTypeChange(value as "database" | "api")} - className="w-full" - > - - - 데이터베이스 - - - REST API - - + handleDataSourceTypeChange(value as "database" | "api")} + className="w-full" + > + + + 데이터베이스 + + + REST API + + - - - - + + + + - - - - -
+ + + + +
+ )} + + {/* 다중 데이터 소스 설정 */} + {["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( + element.subtype, + ) && } {/* 위젯별 커스텀 섹션 */} {element.subtype === "list-v2" && ( From 21f4f30859f1305d8ad9938a18f1223c96b83284 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 31 Oct 2025 18:27:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/WidgetConfigSidebar.tsx | 22 +- frontend/components/admin/dashboard/types.ts | 23 +- .../widget-sections/CustomMetricSection.tsx | 243 ++++- .../widgets/CustomMetricTestWidget.tsx | 985 ++++-------------- .../dashboard/widgets/CustomMetricWidget.tsx | 482 ++++----- 5 files changed, 641 insertions(+), 1114 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 4fb44de0..710abbe6 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -144,7 +144,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge const [chartConfig, setChartConfig] = useState({}); // 커스텀 메트릭 설정 - const [customMetricConfig, setCustomMetricConfig] = useState({ metrics: [] }); + const [customMetricConfig, setCustomMetricConfig] = useState({}); // 사이드바 열릴 때 초기화 useEffect(() => { @@ -175,7 +175,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setChartConfig(element.chartConfig || {}); // 커스텀 메트릭 설정 초기화 - setCustomMetricConfig(element.customMetricConfig || { metrics: [] }); + setCustomMetricConfig(element.customMetricConfig || {}); } else if (!isOpen) { // 사이드바 닫힐 때 초기화 setCustomTitle(""); @@ -194,7 +194,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge cardColumns: 3, }); setChartConfig({}); - setCustomMetricConfig({ metrics: [] }); + setCustomMetricConfig({}); } }, [isOpen, element]); @@ -336,6 +336,12 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge : {}), }; + console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", { + subtype: element.subtype, + customMetricConfig, + updatedElement, + }); + onApply(updatedElement); onClose(); }, [ @@ -431,9 +437,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
{/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */} - {!["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( - element.subtype, - ) && ( + {!["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
@@ -478,9 +482,9 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge )} {/* 다중 데이터 소스 설정 */} - {["map-summary-v2", "chart", "list-v2", "custom-metric-v2", "risk-alert-v2"].includes( - element.subtype, - ) && } + {["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && ( + + )} {/* 위젯별 커스텀 섹션 */} {element.subtype === "list-v2" && ( diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index fd966d1f..8b8ff2b4 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -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; // 비교값 }>; } diff --git a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx index eca4b018..f7f89e96 100644 --- a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx @@ -4,7 +4,10 @@ import React from "react"; import { CustomMetricConfig, QueryResult } from "../types"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle } from "lucide-react"; +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; @@ -14,36 +17,244 @@ interface CustomMetricSectionProps { /** * 통계 카드 설정 섹션 - * - 메트릭 설정, 아이콘, 색상 - * - * TODO: 상세 설정 UI 추가 필요 + * - 쿼리 결과를 받아서 어떻게 통계를 낼지 설정 + * - 컬럼 선택, 계산 방식(합계/평균/개수 등), 표시 방식 + * - 필터 조건 추가 가능 */ 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 ( -
+
- 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. + 먼저 데이터 소스 탭에서 쿼리를 실행하고 결과를 확인해주세요.
); } + // 필터 추가 + 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 ( -
- - - - - 통계 카드 상세 설정 UI는 추후 추가 예정입니다. - - +
+
+ +

쿼리 결과를 바탕으로 통계를 계산하고 표시합니다

+
+ + {/* 1. 필터 조건 (선택사항) */} +
+
+ + +
+ + {config.filters && config.filters.length > 0 ? ( +
+ {config.filters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 값 입력 */} + updateFilter(index, "value", e.target.value)} + placeholder="값" + className="h-8 flex-1 text-xs" + /> + + {/* 삭제 버튼 */} + +
+ ))} +
+ ) : ( +

필터 없음 (전체 데이터 사용)

+ )} +
+ + {/* 2. 계산할 컬럼 선택 */} +
+ + +
+ + {/* 3. 계산 방식 선택 */} +
+ + +
+ + {/* 4. 카드 제목 */} +
+ + onConfigChange({ title: e.target.value })} + placeholder="예: 총 매출액" + className="h-9 text-xs" + /> +
+ + {/* 5. 표시 단위 (선택사항) */} +
+ + onConfigChange({ unit: e.target.value })} + placeholder="예: 원, 건, %" + className="h-9 text-xs" + /> +
+ + {/* 미리보기 */} + {config.valueColumn && config.aggregation && ( +
+

설정 미리보기

+ + {/* 필터 조건 표시 */} + {config.filters && config.filters.length > 0 && ( +
+

필터:

+ {config.filters.map((filter, idx) => ( +

+ · {filter.column} {filter.operator} "{filter.value}" +

+ ))} +
+ )} + + {/* 계산 표시 */} +

+ {config.title || "통계 제목"}: {config.aggregation?.toUpperCase()}({config.valueColumn}) + {config.unit ? ` ${config.unit}` : ""} +

+
+ )}
); } - diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 74869ef0..8483c82f 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -1,18 +1,46 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Button } from "@/components/ui/button"; -import { Loader2, RefreshCw } from "lucide-react"; -import { applyColumnMapping } from "@/lib/utils/columnMapping"; +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface CustomMetricTestWidgetProps { 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; @@ -38,372 +66,138 @@ 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" }, -}; - -/** - * 통계 카드 위젯 (다중 데이터 소스 지원) - * - 여러 REST API 연결 가능 - * - 여러 Database 연결 가능 - * - REST API + Database 혼합 가능 - * - 데이터 자동 병합 후 집계 - */ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) { - const [metrics, setMetrics] = useState([]); - const [groupedCards, setGroupedCards] = useState>([]); - const [loading, setLoading] = useState(false); + const [value, setValue] = useState(0); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastRefreshTime, setLastRefreshTime] = useState(null); - const [selectedMetric, setSelectedMetric] = useState(null); - const [isDetailOpen, setIsDetailOpen] = useState(false); - // console.log("🧪 CustomMetricTestWidget 렌더링!", element); + const config = element?.customMetricConfig; - const dataSources = useMemo(() => { - return element?.dataSources || element?.chartConfig?.dataSources; - }, [element?.dataSources, element?.chartConfig?.dataSources]); + console.log("📊 [CustomMetricTestWidget] 렌더링:", { + element, + config, + dataSource: element?.dataSource, + }); - // 🆕 그룹별 카드 모드 체크 - const isGroupByMode = element?.customMetricConfig?.groupByMode || false; + useEffect(() => { + loadData(); - // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션 - const metricConfig = useMemo(() => { - return ( - element?.customMetricConfig?.metrics || [ - { - label: "총 개수", - field: "id", - aggregation: "count", - color: "indigo", - }, - ] - ); - }, [element?.customMetricConfig?.metrics]); + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [element]); - // 🆕 그룹별 카드 데이터 로드 (원본에서 복사) - const loadGroupByData = useCallback(async () => { - const groupByDS = element?.customMetricConfig?.groupByDataSource; - if (!groupByDS) return; - - const dataSourceType = groupByDS.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!groupByDS.query) return; - - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(groupByDS.query); - - if (result && result.rows) { - const rows = result.rows; - if (rows.length > 0) { - const columns = result.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; - - // REST API 호출 (백엔드 프록시 사용) - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: groupByDS.endpoint, - method: "GET", - headers: (groupByDS as any).headers || {}, - }), - }); - 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); - } - } - } - }, [element?.customMetricConfig?.groupByDataSource]); - - // 다중 데이터 소스 로딩 - const loadMultipleDataSources = useCallback(async () => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; - - if (!dataSources || dataSources.length === 0) { - // console.log("⚠️ 데이터 소스가 없습니다."); - return; - } - - // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); - setLoading(true); - setError(null); - - try { - // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리) - const results = await Promise.allSettled( - dataSources.map(async (source, sourceIndex) => { - try { - // console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`); - - let rows: any[] = []; - if (source.type === "api") { - rows = await loadRestApiData(source); - } else if (source.type === "database") { - rows = await loadDatabaseData(source); - } - - // console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`); - - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: rows, - }; - } catch (err: any) { - console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: [], - }; - } - }), - ); - - // console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`); - - // 각 데이터 소스별로 메트릭 생성 - const allMetrics: any[] = []; - const colors = ["indigo", "green", "blue", "purple", "orange", "gray"]; - - results.forEach((result) => { - if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) { - return; - } - - const { sourceName, rows } = result.value; - - // 🎯 간단한 쿼리도 잘 작동하도록 개선된 로직 - if (rows.length > 0) { - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 숫자 컬럼 찾기 - const numericColumns = columns.filter((col) => { - const value = firstRow[col]; - return typeof value === "number" || !isNaN(Number(value)); - }); - - // 문자열 컬럼 찾기 - const stringColumns = columns.filter((col) => !numericColumns.includes(col)); - - // 🎯 케이스 0: 1행인데 숫자 컬럼이 여러 개 → 각 컬럼을 별도 카드로 - if (rows.length === 1 && numericColumns.length > 1) { - // 예: SELECT COUNT(*) AS 전체, SUM(...) AS 배송중, ... - numericColumns.forEach((col) => { - allMetrics.push({ - label: col, // 컬럼명이 라벨 - value: Number(firstRow[col]) || 0, - field: col, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [firstRow], - }); - }); - } - // 🎯 케이스 1: 컬럼이 2개 (라벨 + 값) → 가장 간단한 형태 - else if (columns.length === 2) { - const labelCol = columns[0]; - const valueCol = columns[1]; - - rows.forEach((row) => { - allMetrics.push({ - label: String(row[labelCol] || ""), - value: Number(row[valueCol]) || 0, - field: valueCol, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 2: 숫자 컬럼이 1개 이상 있음 → 집계된 데이터 - else if (numericColumns.length > 0) { - rows.forEach((row, index) => { - // 라벨: 첫 번째 문자열 컬럼 (없으면 첫 번째 컬럼) - const labelField = stringColumns[0] || columns[0]; - const label = String(row[labelField] || `항목 ${index + 1}`); - - // 값: 첫 번째 숫자 컬럼 - const valueField = numericColumns[0]; - const value = Number(row[valueField]) || 0; - - allMetrics.push({ - label: label, - value: value, - field: valueField, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 3: 숫자 컬럼이 없음 → 마지막 컬럼 기준으로 카운트 - else { - const aggregateField = columns[columns.length - 1]; - const countMap = new Map(); - - rows.forEach((row) => { - const value = String(row[aggregateField] || "기타"); - countMap.set(value, (countMap.get(value) || 0) + 1); - }); - - countMap.forEach((count, label) => { - allMetrics.push({ - label: label, - value: count, - field: aggregateField, - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows.filter((row) => String(row[aggregateField]) === label), - }); - }); - - // 전체 개수도 추가 - allMetrics.push({ - label: "전체", - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, - }); - } - - } - // 🎯 행이 많을 때도 간단하게 처리 - else if (rows.length > 100) { - // 행이 많으면 총 개수만 표시 - // console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`); - - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 데이터 소스에서 선택된 컬럼 가져오기 - const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find( - (ds) => ds.name === sourceName || (result.status === "fulfilled" && ds.id === result.value?.sourceIndex.toString()), - ); - const selectedColumns = dataSourceConfig?.selectedColumns || []; - - // 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시 - const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns; - - // console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow); - - // 각 컬럼별 고유값 개수 - columnsToShow.forEach((col) => { - // 해당 컬럼이 실제로 존재하는지 확인 - if (!columns.includes(col)) { - console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`); - return; - } - - const uniqueValues = new Set(rows.map((row) => row[col])); - const uniqueCount = uniqueValues.size; - - // console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`); - - allMetrics.push({ - label: `${sourceName} - ${col} (고유값)`, - value: uniqueCount, - field: col, - aggregation: "distinct", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - }); - - // 총 행 개수 - allMetrics.push({ - label: `${sourceName} - 총 개수`, - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - } - }); - - // console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`); - setMetrics(allMetrics); - setLastRefreshTime(new Date()); - } catch (err) { - setError(err instanceof Error ? err.message : "데이터 로딩 실패"); - } finally { - setLoading(false); - } - }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); - - // 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭) - const loadAllData = useCallback(async () => { + const loadData = async () => { try { setLoading(true); setError(null); - // 그룹별 카드 데이터 로드 - if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { - await loadGroupByData(); - } + const dataSourceType = element?.dataSource?.type; - // 일반 메트릭 데이터 로드 - if (dataSources && dataSources.length > 0) { - await loadMultipleDataSources(); + // 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); @@ -411,256 +205,11 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } finally { setLoading(false); } - }, [ - isGroupByMode, - element?.customMetricConfig?.groupByDataSource, - dataSources, - loadGroupByData, - loadMultipleDataSources, - ]); - - // 수동 새로고침 핸들러 - const handleManualRefresh = useCallback(() => { - // console.log("🔄 수동 새로고침 버튼 클릭"); - loadAllData(); - }, [loadAllData]); - - // XML 데이터 파싱 - const parseXmlData = (xmlText: string): any[] => { - // console.log("🔍 XML 파싱 시작"); - try { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlText, "text/xml"); - - const records = xmlDoc.getElementsByTagName("record"); - const result: any[] = []; - - for (let i = 0; i < records.length; i++) { - const record = records[i]; - const obj: any = {}; - - for (let j = 0; j < record.children.length; j++) { - const child = record.children[j]; - obj[child.tagName] = child.textContent || ""; - } - - result.push(obj); - } - - // console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`); - return result; - } catch (error) { - console.error("❌ XML 파싱 실패:", error); - throw new Error("XML 파싱 실패"); - } }; - // 텍스트/CSV 데이터 파싱 - const parseTextData = (text: string): any[] => { - // console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500)); - - // XML 감지 - if (text.trim().startsWith("")) { - // console.log("📄 XML 형식 감지"); - return parseXmlData(text); - } - - // CSV 파싱 - // console.log("📄 CSV 형식으로 파싱 시도"); - const lines = text.trim().split("\n"); - if (lines.length === 0) return []; - - const headers = lines[0].split(",").map((h) => h.trim()); - const result: any[] = []; - - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(","); - const obj: any = {}; - - headers.forEach((header, index) => { - obj[header] = values[index]?.trim() || ""; - }); - - result.push(obj); - } - - // console.log(`✅ CSV 파싱 완료: ${result.length}개 행`); - return result; - }; - - // REST API 데이터 로딩 - const loadRestApiData = async (source: ChartDataSource): Promise => { - if (!source.endpoint) { - throw new Error("API endpoint가 없습니다."); - } - - const params = new URLSearchParams(); - - // queryParams 배열 또는 객체 처리 - if (source.queryParams) { - if (Array.isArray(source.queryParams)) { - source.queryParams.forEach((param: any) => { - if (param.key && param.value) { - params.append(param.key, String(param.value)); - } - }); - } else { - Object.entries(source.queryParams).forEach(([key, value]) => { - if (key && value) { - params.append(key, String(value)); - } - }); - } - } - - // console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params)); - - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: source.endpoint, - method: "GET", - headers: source.headers || {}, - queryParams: Object.fromEntries(params), - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("❌ API 호출 실패:", { - status: response.status, - statusText: response.statusText, - body: errorText.substring(0, 500), - }); - throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`); - } - - const result = await response.json(); - // console.log("✅ API 응답:", result); - - if (!result.success) { - console.error("❌ API 실패:", result); - throw new Error(result.message || result.error || "외부 API 호출 실패"); - } - - let processedData = result.data; - - // 텍스트/XML 데이터 처리 - if (typeof processedData === "string") { - // console.log("📄 텍스트 형식 데이터 감지"); - processedData = parseTextData(processedData); - } else if (processedData && typeof processedData === "object" && processedData.text) { - // console.log("📄 래핑된 텍스트 데이터 감지"); - processedData = parseTextData(processedData.text); - } - - // JSON Path 처리 - if (source.jsonPath) { - const paths = source.jsonPath.split("."); - for (const path of paths) { - if (processedData && typeof processedData === "object" && path in processedData) { - processedData = processedData[path]; - } else { - throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`); - } - } - } else if (!Array.isArray(processedData) && typeof processedData === "object") { - // JSON Path 없으면 자동으로 배열 찾기 - // console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도"); - const arrayKeys = ["data", "items", "result", "records", "rows", "list"]; - - for (const key of arrayKeys) { - if (Array.isArray(processedData[key])) { - // console.log(`✅ 배열 발견: ${key}`); - processedData = processedData[key]; - break; - } - } - } - - const rows = Array.isArray(processedData) ? processedData : [processedData]; - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // Database 데이터 로딩 - const loadDatabaseData = async (source: ChartDataSource): Promise => { - if (!source.query) { - throw new Error("SQL 쿼리가 없습니다."); - } - - let rows: any[] = []; - - if (source.connectionType === "external" && source.externalConnectionId) { - // 외부 DB - const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); - const externalResult = await ExternalDbConnectionAPI.executeQuery( - parseInt(source.externalConnectionId), - source.query, - ); - - if (!externalResult.success || !externalResult.data) { - throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); - } - - const resultData = externalResult.data as unknown as { - rows: Record[]; - }; - - rows = resultData.rows; - } else { - // 현재 DB - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(source.query); - - rows = result.rows; - } - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // 초기 로드 (🆕 loadAllData 사용) - useEffect(() => { - if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) { - loadAllData(); - } - }, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]); - - // 자동 새로고침 (🆕 loadAllData 사용) - useEffect(() => { - if (!dataSources || dataSources.length === 0) return; - - const intervals = dataSources - .map((ds) => ds.refreshInterval) - .filter((interval): interval is number => typeof interval === "number" && interval > 0); - - if (intervals.length === 0) return; - - const minInterval = Math.min(...intervals); - // console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`); - - const intervalId = setInterval(() => { - // console.log("🔄 자동 새로고침 실행"); - loadAllData(); - }, minInterval * 1000); - - return () => { - // console.log("⏹️ 자동 새로고침 정리"); - clearInterval(intervalId); - }; - }, [dataSources, loadAllData]); - - // renderMetricCard 함수 제거 - 인라인으로 렌더링 - - // 로딩 상태 (원본 스타일) if (loading) { return ( -
+

데이터 로딩 중...

@@ -669,14 +218,13 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg ); } - // 에러 상태 (원본 스타일) if (error) { return ( -
+

⚠️ {error}

+ ); + } + + // 소수점 자릿수 (기본: 0) + const decimals = config?.decimals ?? 0; + const formattedValue = value.toFixed(decimals); + + // 통계 카드 렌더링 + return ( +
+
+ {/* 제목 */} +
{config?.title || "통계"}
+ + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
+ + {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
+ 필터: {config.filters.length}개 적용됨 +
+ )} +
); } diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index b284aa14..3f3538b4 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -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([]); - const [groupedCards, setGroupedCards] = useState>([]); + const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
@@ -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 (
-

사용자 커스텀 카드

+

통계 카드

-

📊 맞춤형 지표 위젯

+

📊 단일 통계 위젯

    -
  • • SQL 쿼리로 데이터를 불러옵니다
  • -
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • -
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • -
  • • 사용자 정의 단위 설정 가능
  • -
  • - • 그룹별 카드 생성 모드로 간편하게 사용 가능 -
  • +
  • • 데이터 소스에서 쿼리를 실행합니다
  • +
  • • 필터 조건으로 데이터를 필터링합니다
  • +
  • • 선택한 컬럼에 집계 함수를 적용합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 지원

⚙️ 설정 방법

-

- {isGroupByMode - ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" - : "SQL 쿼리를 입력하고 지표를 추가하세요"} -

- {isGroupByMode &&

💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

} +

1. 데이터 탭에서 쿼리 실행

+

2. 필터 조건 추가 (선택사항)

+

3. 계산 컬럼 및 방식 선택

+

4. 제목 및 단위 입력

); } - // 위젯 높이에 따라 레이아웃 결정 (세로 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 ( -
- {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; +
+
+ {/* 제목 */} +
{config?.title || "통계"}
- return ( -
-
{card.label}
-
{card.value.toLocaleString()}
-
- ); - })} + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
- {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); - - return ( -
-
{metric.label}
-
- {formattedValue} - {metric.unit} -
+ {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
+ 필터: {config.filters.length}개 적용됨
- ); - })} + )} +
); } From 7edd0cc1b0547b71a11f5dd7f193ddf19b1ee507 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 3 Nov 2025 11:55:40 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/CustomMetricTestWidget.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 8483c82f..f7d97779 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -272,26 +272,22 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const decimals = config?.decimals ?? 0; const formattedValue = value.toFixed(decimals); - // 통계 카드 렌더링 + // 통계 카드 렌더링 (전체 크기 꽉 차게) return ( -
-
- {/* 제목 */} -
{config?.title || "통계"}
+
+ {/* 제목 */} +
{config?.title || "통계"}
- {/* 값 */} -
- {formattedValue} - {config?.unit && {config.unit}} -
- - {/* 필터 표시 (디버깅용, 작게) */} - {config?.filters && config.filters.length > 0 && ( -
- 필터: {config.filters.length}개 적용됨 -
- )} + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}}
+ + {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
필터: {config.filters.length}개 적용됨
+ )}
); }