From 36aec28708b92c9ef86650530a3b34abd2d6bf4f Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 15 Oct 2025 10:29:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=A8=EB=9F=89=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EA=B8=B0=EC=A1=B4=EA=BA=BC=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/mapDataController.ts | 137 +++++ backend-node/src/routes/mapDataRoutes.ts | 18 + backend-node/src/services/mapDataService.ts | 229 ++++++++ .../admin/dashboard/CanvasElement.tsx | 26 +- .../admin/dashboard/DashboardSidebar.tsx | 18 +- .../admin/dashboard/ElementConfigModal.tsx | 87 ++- .../admin/dashboard/QueryEditor.tsx | 17 +- .../admin/dashboard/VehicleMapConfigPanel.tsx | 162 ++++++ frontend/components/admin/dashboard/types.ts | 8 + .../widgets/DeliveryStatusWidget.tsx | 186 ++++-- .../dashboard/widgets/VehicleListWidget.tsx | 204 +++++++ .../widgets/VehicleMapOnlyWidget.tsx | 252 ++++++++ .../dashboard/widgets/VehicleMapWidget.tsx | 546 ------------------ .../dashboard/widgets/VehicleStatusWidget.tsx | 202 +++++++ frontend/lib/registry/components/index.ts | 1 + .../registry/components/map/MapComponent.tsx | 285 +++++++++ .../components/map/MapConfigPanel.tsx | 439 ++++++++++++++ .../components/map/MapPreviewComponent.tsx | 31 + .../registry/components/map/MapRenderer.tsx | 77 +++ frontend/lib/registry/components/map/index.ts | 59 ++ 21 files changed, 2346 insertions(+), 640 deletions(-) create mode 100644 backend-node/src/controllers/mapDataController.ts create mode 100644 backend-node/src/routes/mapDataRoutes.ts create mode 100644 backend-node/src/services/mapDataService.ts create mode 100644 frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx create mode 100644 frontend/components/dashboard/widgets/VehicleListWidget.tsx create mode 100644 frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx delete mode 100644 frontend/components/dashboard/widgets/VehicleMapWidget.tsx create mode 100644 frontend/components/dashboard/widgets/VehicleStatusWidget.tsx create mode 100644 frontend/lib/registry/components/map/MapComponent.tsx create mode 100644 frontend/lib/registry/components/map/MapConfigPanel.tsx create mode 100644 frontend/lib/registry/components/map/MapPreviewComponent.tsx create mode 100644 frontend/lib/registry/components/map/MapRenderer.tsx create mode 100644 frontend/lib/registry/components/map/index.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index c771f9a3..ae10a6fe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -54,6 +54,7 @@ import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 +import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -202,6 +203,7 @@ app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 +app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/mapDataController.ts b/backend-node/src/controllers/mapDataController.ts new file mode 100644 index 00000000..c5354a24 --- /dev/null +++ b/backend-node/src/controllers/mapDataController.ts @@ -0,0 +1,137 @@ +import { Request, Response } from "express"; +import { MapDataService } from "../services/mapDataService"; +import { logger } from "../utils/logger"; + +/** + * 지도 데이터 조회 컨트롤러 + * 외부 DB 연결에서 위도/경도 데이터를 가져와 지도에 표시할 수 있도록 변환 + */ +export class MapDataController { + private mapDataService: MapDataService; + + constructor() { + this.mapDataService = new MapDataService(); + } + + /** + * 외부 DB에서 지도 데이터 조회 + */ + getMapData = async (req: Request, res: Response): Promise => { + try { + const { connectionId } = req.params; + const { + tableName, + latColumn, + lngColumn, + labelColumn, + statusColumn, + additionalColumns, + whereClause, + } = req.query; + + logger.info("🗺️ 지도 데이터 조회 요청:", { + connectionId, + tableName, + latColumn, + lngColumn, + }); + + // 필수 파라미터 검증 + if (!tableName || !latColumn || !lngColumn) { + res.status(400).json({ + success: false, + message: "tableName, latColumn, lngColumn은 필수입니다.", + }); + return; + } + + const markers = await this.mapDataService.getMapData({ + connectionId: parseInt(connectionId as string), + tableName: tableName as string, + latColumn: latColumn as string, + lngColumn: lngColumn as string, + labelColumn: labelColumn as string, + statusColumn: statusColumn as string, + additionalColumns: additionalColumns + ? (additionalColumns as string).split(",") + : [], + whereClause: whereClause as string, + }); + + res.json({ + success: true, + data: { + markers, + count: markers.length, + }, + }); + } catch (error: any) { + logger.error("❌ 지도 데이터 조회 오류:", error); + res.status(500).json({ + success: false, + message: "지도 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + }; + + /** + * 내부 DB에서 지도 데이터 조회 + */ + getInternalMapData = async (req: Request, res: Response): Promise => { + try { + const { + tableName, + latColumn, + lngColumn, + labelColumn, + statusColumn, + additionalColumns, + whereClause, + } = req.query; + + logger.info("🗺️ 내부 DB 지도 데이터 조회 요청:", { + tableName, + latColumn, + lngColumn, + }); + + // 필수 파라미터 검증 + if (!tableName || !latColumn || !lngColumn) { + res.status(400).json({ + success: false, + message: "tableName, latColumn, lngColumn은 필수입니다.", + }); + return; + } + + const markers = await this.mapDataService.getInternalMapData({ + tableName: tableName as string, + latColumn: latColumn as string, + lngColumn: lngColumn as string, + labelColumn: labelColumn as string, + statusColumn: statusColumn as string, + additionalColumns: additionalColumns + ? (additionalColumns as string).split(",") + : [], + whereClause: whereClause as string, + }); + + res.json({ + success: true, + data: { + markers, + count: markers.length, + }, + }); + } catch (error: any) { + logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error); + res.status(500).json({ + success: false, + message: "지도 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + }; +} + diff --git a/backend-node/src/routes/mapDataRoutes.ts b/backend-node/src/routes/mapDataRoutes.ts new file mode 100644 index 00000000..43767311 --- /dev/null +++ b/backend-node/src/routes/mapDataRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { MapDataController } from "../controllers/mapDataController"; + +const router = Router(); +const mapDataController = new MapDataController(); + +/** + * 지도 데이터 라우트 + */ + +// 외부 DB 지도 데이터 조회 +router.get("/external/:connectionId", mapDataController.getMapData); + +// 내부 DB 지도 데이터 조회 +router.get("/internal", mapDataController.getInternalMapData); + +export default router; + diff --git a/backend-node/src/services/mapDataService.ts b/backend-node/src/services/mapDataService.ts new file mode 100644 index 00000000..2fb6c2af --- /dev/null +++ b/backend-node/src/services/mapDataService.ts @@ -0,0 +1,229 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; +import { ExternalDbConnectionService } from "./externalDbConnectionService"; + +interface MapDataQuery { + connectionId?: number; + tableName: string; + latColumn: string; + lngColumn: string; + labelColumn?: string; + statusColumn?: string; + additionalColumns?: string[]; + whereClause?: string; +} + +export interface MapMarker { + id: string | number; + latitude: number; + longitude: number; + label?: string; + status?: string; + additionalInfo?: Record; +} + +/** + * 지도 데이터 서비스 + * 외부/내부 DB에서 위도/경도 데이터를 조회하여 지도 마커로 변환 + */ +export class MapDataService { + constructor() { + // ExternalDbConnectionService는 static 메서드를 사용 + } + + /** + * 외부 DB에서 지도 데이터 조회 + */ + async getMapData(params: MapDataQuery): Promise { + try { + logger.info("🗺️ 외부 DB 지도 데이터 조회 시작:", params); + + // SELECT할 컬럼 목록 구성 + const selectColumns = [ + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + ...(params.additionalColumns || []), + ].filter(Boolean); + + // 중복 제거 + const uniqueColumns = Array.from(new Set(selectColumns)); + + // SQL 쿼리 구성 + let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`; + + if (params.whereClause) { + sql += ` WHERE ${params.whereClause}`; + } + + logger.info("📝 실행할 SQL:", sql); + + // 외부 DB 쿼리 실행 (static 메서드 사용) + const result = await ExternalDbConnectionService.executeQuery( + params.connectionId!, + sql + ); + + if (!result.success || !result.data) { + throw new Error("외부 DB 쿼리 실패"); + } + + // 데이터를 MapMarker 형식으로 변환 + const markers = this.convertToMarkers( + result.data, + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + params.additionalColumns + ); + + logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`); + + return markers; + } catch (error) { + logger.error("❌ 외부 DB 지도 데이터 조회 오류:", error); + throw error; + } + } + + /** + * 내부 DB에서 지도 데이터 조회 + */ + async getInternalMapData( + params: Omit + ): Promise { + try { + logger.info("🗺️ 내부 DB 지도 데이터 조회 시작:", params); + + // SELECT할 컬럼 목록 구성 + const selectColumns = [ + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + ...(params.additionalColumns || []), + ].filter(Boolean); + + // 중복 제거 + const uniqueColumns = Array.from(new Set(selectColumns)); + + // SQL 쿼리 구성 + let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`; + + if (params.whereClause) { + sql += ` WHERE ${params.whereClause}`; + } + + logger.info("📝 실행할 SQL:", sql); + + // 내부 DB 쿼리 실행 + const rows = await query(sql); + + // 데이터를 MapMarker 형식으로 변환 + const markers = this.convertToMarkers( + rows, + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + params.additionalColumns + ); + + logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`); + + return markers; + } catch (error) { + logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error); + throw error; + } + } + + /** + * DB 결과를 MapMarker 배열로 변환 + */ + private convertToMarkers( + data: any[], + latColumn: string, + lngColumn: string, + labelColumn?: string, + statusColumn?: string, + additionalColumns?: string[] + ): MapMarker[] { + const markers: MapMarker[] = []; + + for (let i = 0; i < data.length; i++) { + const row = data[i]; + + // 위도/경도 추출 (다양한 컬럼명 지원) + const lat = this.extractCoordinate(row, latColumn); + const lng = this.extractCoordinate(row, lngColumn); + + // 유효한 좌표인지 확인 + if (lat === null || lng === null || isNaN(lat) || isNaN(lng)) { + logger.warn(`⚠️ 유효하지 않은 좌표 스킵: row ${i}`, { lat, lng }); + continue; + } + + // 위도 범위 체크 (-90 ~ 90) + if (lat < -90 || lat > 90) { + logger.warn(`⚠️ 위도 범위 초과: ${lat}`); + continue; + } + + // 경도 범위 체크 (-180 ~ 180) + if (lng < -180 || lng > 180) { + logger.warn(`⚠️ 경도 범위 초과: ${lng}`); + continue; + } + + // 추가 정보 수집 + const additionalInfo: Record = {}; + if (additionalColumns) { + for (const col of additionalColumns) { + if (col && row[col] !== undefined) { + additionalInfo[col] = row[col]; + } + } + } + + // 마커 생성 + markers.push({ + id: row.id || row.ID || `marker-${i}`, + latitude: lat, + longitude: lng, + label: labelColumn ? row[labelColumn] : undefined, + status: statusColumn ? row[statusColumn] : undefined, + additionalInfo: Object.keys(additionalInfo).length > 0 ? additionalInfo : undefined, + }); + } + + return markers; + } + + /** + * 다양한 형식의 좌표 추출 + */ + private extractCoordinate(row: any, columnName: string): number | null { + const value = row[columnName]; + + if (value === null || value === undefined) { + return null; + } + + // 이미 숫자인 경우 + if (typeof value === "number") { + return value; + } + + // 문자열인 경우 파싱 + if (typeof value === "string") { + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + } + + return null; + } +} + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6c1e5a66..9bb917f3 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -22,7 +22,17 @@ const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/Ca loading: () =>
로딩 중...
, }); -const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapWidget"), { +const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), { ssr: false, loading: () =>
로딩 중...
, }); @@ -448,15 +458,25 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "vehicle-status" ? ( + // 차량 상태 현황 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "vehicle-list" ? ( + // 차량 목록 위젯 렌더링 +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( // 차량 위치 지도 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "delivery-status" ? ( // 배송/화물 현황 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "risk-alert" ? ( // 리스크/알림 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 119ac87f..e417f428 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -120,7 +120,23 @@ export function DashboardSidebar() { className="border-l-4 border-teal-500" /> + + (null); const [activeTab, setActiveTab] = useState<"query" | "chart">("query"); + // 차트 설정이 필요 없는 위젯 (쿼리만 필요) + const isQueryOnlyWidget = + element.subtype === "vehicle-status" || + element.subtype === "vehicle-list" || + element.subtype === "delivery-status"; + // 데이터 소스 변경 처리 const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => { setDataSource(newDataSource); @@ -40,11 +47,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); - // 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동 - if (result.rows.length > 0) { + // 쿼리만 필요한 위젯은 자동 이동 안 함 + if (result.rows.length > 0 && !isQueryOnlyWidget) { setActiveTab("chart"); } - }, []); + }, [isQueryOnlyWidget]); // 저장 처리 const handleSave = useCallback(() => { @@ -98,10 +105,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element } return ( -
-
+
+
{/* 모달 헤더 */} -
+

{element.title} 설정

데이터 소스와 차트 설정을 구성하세요

@@ -112,7 +119,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{/* 탭 네비게이션 */} -
+
- + {!isQueryOnlyWidget && ( + + )}
- {/* 탭 내용 */} -
+ {/* 탭 내용 - 스크롤 가능하도록 수정 */} +
{activeTab === "query" && ( - +
+ +
)} {activeTab === "chart" && ( - +
+ {element.subtype === "vehicle-map" ? ( + + ) : ( + + )} +
)}
@@ -174,7 +191,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element +
-
+ +
-
+ +
-
+ +
+
@@ -322,20 +392,24 @@ export default function DeliveryStatusWidget({ refreshInterval = 60000 }: Delive
- {/* 지연 중인 화물 리스트 */} + {/* 필터링된 화물 리스트 */}

- - 지연 중인 화물 ({delayedDeliveries.length}) + + {selectedStatus === "all" && `전체 화물 (${filteredDeliveries.length})`} + {selectedStatus === "in_transit" && `배송 중인 화물 (${filteredDeliveries.length})`} + {selectedStatus === "delivered" && `배송 완료 (${filteredDeliveries.length})`} + {selectedStatus === "delayed" && `지연 중인 화물 (${filteredDeliveries.length})`} + {selectedStatus === "pickup_waiting" && `픽업 대기 (${filteredDeliveries.length})`}

- {delayedDeliveries.length === 0 ? ( + {filteredDeliveries.length === 0 ? (
- 지연 중인 화물이 없습니다 + {selectedStatus === "all" ? "화물이 없습니다" : "해당 상태의 화물이 없습니다"}
) : (
- {delayedDeliveries.map((delivery) => ( + {filteredDeliveries.map((delivery) => (
([]); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(new Date()); + const [selectedStatus, setSelectedStatus] = useState("all"); + + const loadVehicles = async () => { + setIsLoading(true); + + // 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함) + if (!element?.dataSource?.query) { + setIsLoading(false); + return; + } + + const query = element.dataSource.query; + + try { + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + setVehicles(result.data.rows); + setLastUpdate(new Date()); + } + } + } catch (error) { + console.error("차량 목록 로드 실패:", error); + } + + setIsLoading(false); + }; + + // 데이터 로드 및 자동 새로고침 + useEffect(() => { + loadVehicles(); + const interval = setInterval(loadVehicles, refreshInterval); + return () => clearInterval(interval); + }, [element?.dataSource?.query, refreshInterval]); + + // 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거) + + const getStatusColor = (status: string) => { + const s = status?.toLowerCase() || ""; + if (s === "active" || s === "running") return "bg-green-500"; + if (s === "inactive" || s === "idle") return "bg-yellow-500"; + if (s === "maintenance") return "bg-orange-500"; + if (s === "warning" || s === "breakdown") return "bg-red-500"; + return "bg-gray-500"; + }; + + const getStatusText = (status: string) => { + const s = status?.toLowerCase() || ""; + if (s === "active" || s === "running") return "운행 중"; + if (s === "inactive" || s === "idle") return "대기"; + if (s === "maintenance") return "정비"; + if (s === "warning" || s === "breakdown") return "고장"; + return "알 수 없음"; + }; + + const filteredVehicles = + selectedStatus === "all" ? vehicles : vehicles.filter((v) => v.status?.toLowerCase() === selectedStatus); + + return ( +
+ {/* 헤더 */} +
+
+

📋 차량 목록

+

마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}

+
+ +
+ + {/* 필터 버튼 */} +
+ + + + + +
+ + {/* 차량 목록 */} +
+ {filteredVehicles.length === 0 ? ( +
+
+ +

차량이 없습니다

+
+
+ ) : ( +
+ {filteredVehicles.map((vehicle) => ( +
+
+
+ + {vehicle.vehicle_name} +
+ + {getStatusText(vehicle.status)} + +
+ +
+
+ 차량번호 + {vehicle.vehicle_number} +
+
+ 기사 + {vehicle.driver_name || "미배정"} +
+
+ + {vehicle.destination || "대기 중"} +
+
+ + {vehicle.speed || 0} km/h +
+
+
+ ))} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx new file mode 100644 index 00000000..6cd18a4b --- /dev/null +++ b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx @@ -0,0 +1,252 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import "leaflet/dist/leaflet.css"; + +// Leaflet 아이콘 경로 설정 (엑박 방지) +if (typeof window !== "undefined") { + const L = require("leaflet"); + delete (L.Icon.Default.prototype as any)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); +} + +// Leaflet 동적 import (SSR 방지) +const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false }); +const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false }); +const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false }); +const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); +const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false }); + +// 브이월드 API 키 +const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; + +interface Vehicle { + id: string; + name: string; + driver: string; + lat: number; + lng: number; + status: "active" | "inactive" | "maintenance" | "warning"; + speed: number; + destination: string; +} + +interface VehicleMapOnlyWidgetProps { + element?: any; // 대시보드 요소 (dataSource, chartConfig 포함) + refreshInterval?: number; +} + +export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 }: VehicleMapOnlyWidgetProps) { + const [vehicles, setVehicles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(new Date()); + + const loadVehicles = async () => { + setIsLoading(true); + + // 설정된 쿼리가 없으면 로딩 중단 + if (!element?.dataSource?.query) { + setIsLoading(false); + setVehicles([]); + return; + } + + // 설정된 컬럼 매핑 확인 + if (!element?.chartConfig?.latitudeColumn || !element?.chartConfig?.longitudeColumn) { + setIsLoading(false); + setVehicles([]); + return; + } + + try { + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + // 설정된 컬럼 매핑 가져오기 + const latCol = element.chartConfig.latitudeColumn; + const lngCol = element.chartConfig.longitudeColumn; + const labelCol = element.chartConfig.labelColumn || "name"; + const statusCol = element.chartConfig.statusColumn || "status"; + + // DB 데이터를 Vehicle 형식으로 변환 + const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({ + id: row.id || row.vehicle_number || `V${index + 1}`, + name: row[labelCol] || `차량 ${index + 1}`, + driver: row.driver_name || row.driver || "미배정", + lat: parseFloat(row[latCol]), + lng: parseFloat(row[lngCol]), + status: + row[statusCol] === "warning" + ? "warning" + : row[statusCol] === "active" + ? "active" + : row[statusCol] === "maintenance" + ? "maintenance" + : "inactive", + speed: parseFloat(row.speed) || 0, + destination: row.destination || "대기 중", + })); + + setVehicles(vehiclesFromDB); + setLastUpdate(new Date()); + setIsLoading(false); + return; + } + } + } catch (error) { + console.error("차량 데이터 로드 실패:", error); + } + + setIsLoading(false); + }; + + // useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치) + useEffect(() => { + loadVehicles(); + const interval = setInterval(loadVehicles, refreshInterval); + return () => clearInterval(interval); + }, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]); + + // 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거) + + const getStatusColor = (status: Vehicle["status"]) => { + switch (status) { + case "active": + return "#22c55e"; // 운행 중 - 초록 + case "inactive": + return "#eab308"; // 대기 - 노랑 + case "maintenance": + return "#f97316"; // 정비 - 주황 + case "warning": + return "#ef4444"; // 고장 - 빨강 + default: + return "#6b7280"; // 기타 - 회색 + } + }; + + const getStatusText = (status: Vehicle["status"]) => { + switch (status) { + case "active": + return "운행 중"; + case "inactive": + return "대기"; + case "maintenance": + return "정비"; + case "warning": + return "고장"; + default: + return "알 수 없음"; + } + }; + + return ( +
+ {/* 헤더 */} +
+
+

🗺️ 차량 위치 지도

+

마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}

+
+ +
+ + {/* 지도 영역 - 브이월드 타일맵 */} +
+
+ + {/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */} + + + {/* 차량 마커 */} + {vehicles.map((vehicle) => ( + + + + +
+
{vehicle.name}
+
+ 기사: {vehicle.driver} +
+
+ 상태: {getStatusText(vehicle.status)} +
+
+ 속도: {vehicle.speed} km/h +
+
+ 목적지: {vehicle.destination} +
+
+
+
+
+ ))} +
+ + {/* 지도 정보 */} +
+
+
🗺️ 브이월드 (VWorld)
+
국토교통부 공식 지도
+
+
+ + {/* 차량 수 표시 또는 설정 안내 */} +
+ {vehicles.length > 0 ? ( +
총 {vehicles.length}대 모니터링 중
+ ) : ( +
+ ⚙️ 톱니바퀴 클릭하여 데이터 연결 +
+ )} +
+
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/VehicleMapWidget.tsx b/frontend/components/dashboard/widgets/VehicleMapWidget.tsx deleted file mode 100644 index 4e5cf7f6..00000000 --- a/frontend/components/dashboard/widgets/VehicleMapWidget.tsx +++ /dev/null @@ -1,546 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import dynamic from "next/dynamic"; -import { Button } from "@/components/ui/button"; -import { RefreshCw, Truck, Navigation } from "lucide-react"; -import "leaflet/dist/leaflet.css"; - -// Leaflet 아이콘 경로 설정 (엑박 방지) -if (typeof window !== "undefined") { - const L = require("leaflet"); - delete (L.Icon.Default.prototype as any)._getIconUrl; - L.Icon.Default.mergeOptions({ - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", - }); -} - -// Leaflet 동적 import (SSR 방지) -const MapContainer = dynamic( - () => import("react-leaflet").then((mod) => mod.MapContainer), - { ssr: false } -); -const TileLayer = dynamic( - () => import("react-leaflet").then((mod) => mod.TileLayer), - { ssr: false } -); -const Marker = dynamic( - () => import("react-leaflet").then((mod) => mod.Marker), - { ssr: false } -); -const Popup = dynamic( - () => import("react-leaflet").then((mod) => mod.Popup), - { ssr: false } -); -const Circle = dynamic( - () => import("react-leaflet").then((mod) => mod.Circle), - { ssr: false } -); - -// 브이월드 API 키 -const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; - -interface Vehicle { - id: string; - name: string; - driver: string; - lat: number; - lng: number; - status: "running" | "idle" | "maintenance" | "breakdown"; - speed: number; - destination: string; - distance: number; - fuel: number; - avgSpeed: number; - temperature?: number; - isRefrigerated: boolean; -} - -interface VehicleMapWidgetProps { - refreshInterval?: number; -} - -export default function VehicleMapWidget({ refreshInterval = 30000 }: VehicleMapWidgetProps) { - const [vehicles, setVehicles] = useState([]); - const [selectedVehicle, setSelectedVehicle] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [lastUpdate, setLastUpdate] = useState(new Date()); - - const loadVehicles = async () => { - setIsLoading(true); - - const dummyVehicles: Vehicle[] = [ - { - id: "V001", - name: "냉동차 1호", - driver: "김기사", - lat: 35.1796 + (Math.random() - 0.5) * 0.05, // 부산 - lng: 129.0756 + (Math.random() - 0.5) * 0.05, - status: "running", - speed: 55 + Math.floor(Math.random() * 20), - destination: "부산 → 울산", - distance: 45 + Math.floor(Math.random() * 20), - fuel: 85 + Math.floor(Math.random() * 15), - avgSpeed: 62, - temperature: -18 + Math.floor(Math.random() * 3), - isRefrigerated: true, - }, - { - id: "V002", - name: "일반 화물차 2호", - driver: "이기사", - lat: 37.4563, // 인천 - lng: 126.7052, - status: "idle", - speed: 0, - destination: "대기 중", - distance: 0, - fuel: 5, - avgSpeed: 0, - isRefrigerated: false, - }, - { - id: "V003", - name: "냉장차 3호", - driver: "박기사", - lat: 36.3504 + (Math.random() - 0.5) * 0.05, // 대전 - lng: 127.3845 + (Math.random() - 0.5) * 0.05, - status: "running", - speed: 40 + Math.floor(Math.random() * 15), - destination: "대전 → 세종", - distance: 22 + Math.floor(Math.random() * 10), - fuel: 42 + Math.floor(Math.random() * 10), - avgSpeed: 58, - temperature: 2 + Math.floor(Math.random() * 4), - isRefrigerated: true, - }, - { - id: "V004", - name: "일반 화물차 4호", - driver: "최기사", - lat: 35.8714, // 대구 - lng: 128.6014, - status: "maintenance", - speed: 0, - destination: "정비소", - distance: 0, - fuel: 0, - avgSpeed: 0, - isRefrigerated: false, - }, - { - id: "V005", - name: "냉동차 5호", - driver: "정기사", - lat: 33.4996 + (Math.random() - 0.5) * 0.05, // 제주 - lng: 126.5312 + (Math.random() - 0.5) * 0.05, - status: "running", - speed: 45 + Math.floor(Math.random() * 15), - destination: "제주 → 서귀포", - distance: 28 + Math.floor(Math.random() * 10), - fuel: 52 + Math.floor(Math.random() * 10), - avgSpeed: 54, - temperature: -20 + Math.floor(Math.random() * 2), - isRefrigerated: true, - }, - { - id: "V006", - name: "일반 화물차 6호", - driver: "강기사", - lat: 35.1595, // 광주 - lng: 126.8526, - status: "breakdown", - speed: 0, - destination: "고장 (견인 대기)", - distance: 65, - fuel: 18, - avgSpeed: 0, - isRefrigerated: false, - }, - { - id: "V007", - name: "냉장차 7호", - driver: "윤기사", - lat: 37.5665 + (Math.random() - 0.5) * 0.05, // 서울 - lng: 126.9780 + (Math.random() - 0.5) * 0.05, - status: "running", - speed: 60 + Math.floor(Math.random() * 15), - destination: "서울 → 수원", - distance: 35 + Math.floor(Math.random() * 10), - fuel: 68 + Math.floor(Math.random() * 10), - avgSpeed: 65, - temperature: 3 + Math.floor(Math.random() * 3), - isRefrigerated: true, - }, - { - id: "V008", - name: "일반 화물차 8호", - driver: "한기사", - lat: 37.8813 + (Math.random() - 0.5) * 0.05, // 춘천 - lng: 127.7300 + (Math.random() - 0.5) * 0.05, - status: "running", - speed: 50 + Math.floor(Math.random() * 15), - destination: "춘천 → 강릉", - distance: 95 + Math.floor(Math.random() * 20), - fuel: 75 + Math.floor(Math.random() * 15), - avgSpeed: 58, - isRefrigerated: false, - }, - ]; - - setTimeout(() => { - setVehicles(dummyVehicles); - setLastUpdate(new Date()); - setIsLoading(false); - }, 500); - }; - - useEffect(() => { - loadVehicles(); - const interval = setInterval(loadVehicles, refreshInterval); - return () => clearInterval(interval); - }, [refreshInterval]); - - const getStatusColor = (status: Vehicle["status"]) => { - switch (status) { - case "running": - return "#22c55e"; - case "idle": - return "#eab308"; - case "maintenance": - return "#f97316"; - case "breakdown": - return "#ef4444"; - default: - return "#6b7280"; - } - }; - - const getStatusText = (status: Vehicle["status"]) => { - switch (status) { - case "running": - return "운행 중"; - case "idle": - return "대기"; - case "maintenance": - return "정비"; - case "breakdown": - return "고장"; - default: - return "알 수 없음"; - } - }; - - const statusStats = { - running: vehicles.filter((v) => v.status === "running").length, - idle: vehicles.filter((v) => v.status === "idle").length, - maintenance: vehicles.filter((v) => v.status === "maintenance").length, - breakdown: vehicles.filter((v) => v.status === "breakdown").length, - }; - - return ( -
- {/* 헤더 */} -
-
-

🚚 실시간 차량 위치

-

- 마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")} -

-
- -
- - {/* 차량 상태 요약 */} -
-
-
운행 중
-
{statusStats.running}대
-
-
-
대기
-
{statusStats.idle}대
-
-
-
정비
-
{statusStats.maintenance}대
-
-
-
고장
-
{statusStats.breakdown}대
-
-
- -
- {/* 지도 영역 - 브이월드 타일맵 */} -
-
- {typeof window !== "undefined" && ( - - {/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */} - - - {/* 차량 마커 */} - {vehicles.map((vehicle) => ( - - - setSelectedVehicle(vehicle), - }} - > - -
-
{vehicle.name}
-
기사: {vehicle.driver}
-
상태: {getStatusText(vehicle.status)}
-
속도: {vehicle.speed} km/h
-
거리: {vehicle.distance} km
-
연료: {vehicle.fuel} L
- {vehicle.isRefrigerated && vehicle.temperature !== undefined && ( -
온도: {vehicle.temperature}°C
- )} -
-
-
-
- ))} -
- )} - - {/* 지도 정보 */} -
-
-
🗺️ 브이월드 (VWorld)
-
국토교통부 공식 지도
-
-
- - {/* 차량 수 표시 */} -
-
- 총 {vehicles.length}대 모니터링 중 -
-
-
-
- - {/* 우측 사이드 패널 */} -
- {/* 차량 목록 */} -
-
-

- - 차량 목록 ({vehicles.length}대) -

-
- -
- {vehicles.length === 0 ? ( -
- 차량이 없습니다 -
- ) : ( -
- {vehicles.map((vehicle) => ( -
setSelectedVehicle(vehicle)} - className={`cursor-pointer rounded-lg border p-2 transition-all hover:shadow-sm ${ - selectedVehicle?.id === vehicle.id - ? "border-gray-900 bg-gray-50 ring-1 ring-gray-900" - : "border-gray-200 bg-white hover:border-gray-300" - }`} - > -
- - {vehicle.name} - - - {getStatusText(vehicle.status)} - -
-
- - {vehicle.destination} -
-
- ))} -
- )} -
-
- - {/* 선택된 차량 상세 정보 */} - {selectedVehicle ? ( -
- {/* 헤더 */} -
-
-

- - {selectedVehicle.name} -

- -
-
- - {getStatusText(selectedVehicle.status)} - - {selectedVehicle.id} -
-
- - {/* 기사 정보 */} -
-
👤 기사 정보
-
-
- 이름 - {selectedVehicle.driver} -
-
- GPS 좌표 - - {selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)} - -
-
-
- - {/* 운행 정보 */} -
-
📍 운행 정보
-
-
- 목적지 - {selectedVehicle.destination} -
-
-
- - {/* 실시간 데이터 */} -
-
📊 실시간 데이터
-
-
-
현재 속도
-
{selectedVehicle.speed}
-
km/h
-
-
-
평균 속도
-
{selectedVehicle.avgSpeed}
-
km/h
-
-
-
운행 거리
-
{selectedVehicle.distance}
-
km
-
-
-
소모 연료
-
{selectedVehicle.fuel}
-
L
-
-
-
- - {/* 냉동/냉장 상태 */} - {selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && ( -
-
❄️ 냉동/냉장 상태
-
-
- 현재 온도 - - {selectedVehicle.temperature}°C - -
-
-
- 타입 - - {selectedVehicle.temperature < -10 ? "냉동" : "냉장"} - -
-
- 적정 범위 - - {selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"} - -
-
- 상태 - - {Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5 - ? "✓ 정상" - : "⚠ 주의"} - -
-
-
-
- )} -
- ) : ( -
- -

차량을 선택하면

-

상세 정보가 표시됩니다

-
- )} -
-
-
- ); -} diff --git a/frontend/components/dashboard/widgets/VehicleStatusWidget.tsx b/frontend/components/dashboard/widgets/VehicleStatusWidget.tsx new file mode 100644 index 00000000..3eaefdc6 --- /dev/null +++ b/frontend/components/dashboard/widgets/VehicleStatusWidget.tsx @@ -0,0 +1,202 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { RefreshCw, TrendingUp, TrendingDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface VehicleStatusWidgetProps { + element?: any; // 대시보드 요소 (dataSource 포함) + refreshInterval?: number; +} + +interface StatusData { + active: number; // 운행 중 + inactive: number; // 대기 + maintenance: number; // 정비 + warning: number; // 고장 + total: number; +} + +export default function VehicleStatusWidget({ element, refreshInterval = 30000 }: VehicleStatusWidgetProps) { + const [statusData, setStatusData] = useState({ + active: 0, + inactive: 0, + maintenance: 0, + warning: 0, + total: 0, + }); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(new Date()); + + const loadStatusData = async () => { + setIsLoading(true); + + // 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함) + if (!element?.dataSource?.query) { + setIsLoading(false); + return; + } + + const query = element.dataSource.query; + + try { + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const newStatus: StatusData = { + active: 0, + inactive: 0, + maintenance: 0, + warning: 0, + total: 0, + }; + + // 쿼리 결과가 GROUP BY 형식인지 확인 + const isGroupedData = result.data.rows[0].count !== undefined; + + if (isGroupedData) { + // GROUP BY 형식: SELECT status, COUNT(*) as count + result.data.rows.forEach((row: any) => { + const count = parseInt(row.count) || 0; + const status = row.status?.toLowerCase() || ""; + + if (status === "active" || status === "running") { + newStatus.active = count; + } else if (status === "inactive" || status === "idle") { + newStatus.inactive = count; + } else if (status === "maintenance") { + newStatus.maintenance = count; + } else if (status === "warning" || status === "breakdown") { + newStatus.warning = count; + } + + newStatus.total += count; + }); + } else { + // SELECT * 형식: 전체 데이터를 가져와서 카운트 + result.data.rows.forEach((row: any) => { + const status = row.status?.toLowerCase() || ""; + + if (status === "active" || status === "running") { + newStatus.active++; + } else if (status === "inactive" || status === "idle") { + newStatus.inactive++; + } else if (status === "maintenance") { + newStatus.maintenance++; + } else if (status === "warning" || status === "breakdown") { + newStatus.warning++; + } + + newStatus.total++; + }); + } + + setStatusData(newStatus); + setLastUpdate(new Date()); + } + } + } catch (error) { + console.error("차량 상태 데이터 로드 실패:", error); + } + + setIsLoading(false); + }; + + // 데이터 로드 및 자동 새로고침 + useEffect(() => { + loadStatusData(); + const interval = setInterval(loadStatusData, refreshInterval); + return () => clearInterval(interval); + }, [element?.dataSource?.query, refreshInterval]); + + // 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거) + + const activeRate = statusData.total > 0 ? ((statusData.active / statusData.total) * 100).toFixed(1) : "0"; + + return ( +
+ {/* 헤더 */} +
+
+

📊 차량 상태 현황

+ {statusData.total > 0 ? ( +

{lastUpdate.toLocaleTimeString("ko-KR")}

+ ) : ( +

⚙️ 데이터 연결 필요

+ )} +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {/* 총 차량 수 */} +
+
+
+
총 차량
+
{statusData.total}대
+
+
+
가동률
+
+ {activeRate}% +
+
+
+
+ + {/* 상태별 카드 */} +
+ {/* 운행 중 */} +
+
+
+
운행
+
+
{statusData.active}
+
+ + {/* 대기 */} +
+
+
+
대기
+
+
{statusData.inactive}
+
+ + {/* 정비 */} +
+
+
+
정비
+
+
{statusData.maintenance}
+
+ + {/* 고장 */} +
+
+
+
고장
+
+
{statusData.warning}
+
+
+
+
+ ); +} + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 80f690f3..c4326970 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -37,6 +37,7 @@ import "./divider-line/DividerLineRenderer"; import "./accordion-basic/AccordionBasicRenderer"; import "./table-list/TableListRenderer"; import "./card-display/CardDisplayRenderer"; +import "./map/MapRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/map/MapComponent.tsx b/frontend/lib/registry/components/map/MapComponent.tsx new file mode 100644 index 00000000..652f17d6 --- /dev/null +++ b/frontend/lib/registry/components/map/MapComponent.tsx @@ -0,0 +1,285 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import dynamic from "next/dynamic"; +import { RefreshCw, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import "leaflet/dist/leaflet.css"; + +// Leaflet 아이콘 경로 설정 (엑박 방지) +if (typeof window !== "undefined") { + const L = require("leaflet"); + delete (L.Icon.Default.prototype as any)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); +} + +// Leaflet 동적 import (SSR 방지) +const MapContainer = dynamic( + () => import("react-leaflet").then((mod) => mod.MapContainer), + { ssr: false } +); +const TileLayer = dynamic( + () => import("react-leaflet").then((mod) => mod.TileLayer), + { ssr: false } +); +const Marker = dynamic( + () => import("react-leaflet").then((mod) => mod.Marker), + { ssr: false } +); +const Popup = dynamic( + () => import("react-leaflet").then((mod) => mod.Popup), + { ssr: false } +); + +interface MapMarker { + id: string | number; + latitude: number; + longitude: number; + label?: string; + status?: string; + additionalInfo?: Record; +} + +interface MapComponentProps { + component: { + id: string; + config?: { + dataSource?: { + type?: "internal" | "external"; + connectionId?: number | null; + tableName?: string; + latColumn?: string; + lngColumn?: string; + labelColumn?: string; + statusColumn?: string; + additionalColumns?: string[]; + whereClause?: string; + }; + mapConfig?: { + center?: { lat: number; lng: number }; + zoom?: number; + minZoom?: number; + maxZoom?: number; + }; + markerConfig?: { + showLabel?: boolean; + showPopup?: boolean; + statusColors?: Record; + }; + refreshInterval?: number; + }; + }; +} + +export default function MapComponent({ component }: MapComponentProps) { + const [markers, setMarkers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const dataSource = component.config?.dataSource; + const mapConfig = component.config?.mapConfig; + const markerConfig = component.config?.markerConfig; + const refreshInterval = component.config?.refreshInterval || 0; + + // 데이터 로드 + const loadMapData = async () => { + if (!dataSource?.tableName || !dataSource?.latColumn || !dataSource?.lngColumn) { + setError("테이블명, 위도 컬럼, 경도 컬럼을 설정해주세요."); + return; + } + + setIsLoading(true); + setError(null); + + try { + // API URL 구성 + const isExternal = dataSource.type === "external" && dataSource.connectionId; + const baseUrl = isExternal + ? `/api/map-data/external/${dataSource.connectionId}` + : `/api/map-data/internal`; + + const params = new URLSearchParams({ + tableName: dataSource.tableName, + latColumn: dataSource.latColumn, + lngColumn: dataSource.lngColumn, + }); + + if (dataSource.labelColumn) { + params.append("labelColumn", dataSource.labelColumn); + } + if (dataSource.statusColumn) { + params.append("statusColumn", dataSource.statusColumn); + } + if (dataSource.additionalColumns && dataSource.additionalColumns.length > 0) { + params.append("additionalColumns", dataSource.additionalColumns.join(",")); + } + if (dataSource.whereClause) { + params.append("whereClause", dataSource.whereClause); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "데이터 조회 실패"); + } + + setMarkers(result.data.markers || []); + setLastUpdate(new Date()); + } catch (err: any) { + console.error("지도 데이터 로드 오류:", err); + setError(err.message || "데이터를 불러올 수 없습니다."); + } finally { + setIsLoading(false); + } + }; + + // 초기 로드 및 자동 새로고침 + useEffect(() => { + loadMapData(); + + if (refreshInterval > 0) { + const interval = setInterval(loadMapData, refreshInterval); + return () => clearInterval(interval); + } + }, [ + dataSource?.type, + dataSource?.connectionId, + dataSource?.tableName, + dataSource?.latColumn, + dataSource?.lngColumn, + dataSource?.whereClause, + refreshInterval, + ]); + + // 마커 색상 가져오기 + const getMarkerColor = (status?: string): string => { + if (!status || !markerConfig?.statusColors) { + return markerConfig?.statusColors?.default || "#3b82f6"; + } + return markerConfig.statusColors[status] || markerConfig.statusColors.default || "#3b82f6"; + }; + + // 커스텀 마커 아이콘 생성 + const createMarkerIcon = (status?: string) => { + if (typeof window === "undefined") return undefined; + + const L = require("leaflet"); + const color = getMarkerColor(status); + + return new L.Icon({ + iconUrl: `data:image/svg+xml;base64,${btoa(` + + + + + `)}`, + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [0, -41], + }); + }; + + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + return ( +
+ {/* 지도 */} + {typeof window !== "undefined" && ( + + + + {/* 마커 렌더링 */} + {markers.map((marker) => ( + + {markerConfig?.showPopup !== false && ( + +
+ {marker.label && ( +
{marker.label}
+ )} +
+
+ 위도: {marker.latitude.toFixed(6)} +
+
+ 경도: {marker.longitude.toFixed(6)} +
+ {marker.status && ( +
+ 상태: {marker.status} +
+ )} + {marker.additionalInfo && + Object.entries(marker.additionalInfo).map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+
+
+ )} +
+ ))} +
+ )} + + {/* 상단 정보 바 */} +
+ + 마커: {markers.length}개 + + {lastUpdate && ( + + {lastUpdate.toLocaleTimeString()} + + )} + +
+
+ ); +} + diff --git a/frontend/lib/registry/components/map/MapConfigPanel.tsx b/frontend/lib/registry/components/map/MapConfigPanel.tsx new file mode 100644 index 00000000..62489274 --- /dev/null +++ b/frontend/lib/registry/components/map/MapConfigPanel.tsx @@ -0,0 +1,439 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; +import { RefreshCw } from "lucide-react"; + +interface MapConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +interface DbConnection { + id: number; + name: string; + db_type: string; +} + +interface TableInfo { + table_name: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; +} + +export default function MapConfigPanel({ config, onChange }: MapConfigPanelProps) { + const [connections, setConnections] = useState([]); + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [isLoadingConnections, setIsLoadingConnections] = useState(false); + const [isLoadingTables, setIsLoadingTables] = useState(false); + const [isLoadingColumns, setIsLoadingColumns] = useState(false); + + // DB 연결 목록 로드 + useEffect(() => { + loadConnections(); + }, []); + + // 테이블 목록 로드 + useEffect(() => { + if (config.dataSource?.type === "external" && config.dataSource?.connectionId) { + loadTables(config.dataSource.connectionId); + } else if (config.dataSource?.type === "internal") { + loadInternalTables(); + } + }, [config.dataSource?.type, config.dataSource?.connectionId]); + + // 컬럼 목록 로드 + useEffect(() => { + if (config.dataSource?.tableName) { + if (config.dataSource.type === "external" && config.dataSource.connectionId) { + loadColumns(config.dataSource.connectionId, config.dataSource.tableName); + } else if (config.dataSource.type === "internal") { + loadInternalColumns(config.dataSource.tableName); + } + } + }, [config.dataSource?.tableName]); + + const loadConnections = async () => { + setIsLoadingConnections(true); + try { + const response = await fetch("/api/external-db-connections"); + const data = await response.json(); + if (data.success) { + setConnections(data.data || []); + } + } catch (error) { + console.error("DB 연결 목록 로드 실패:", error); + } finally { + setIsLoadingConnections(false); + } + }; + + const loadTables = async (connectionId: number) => { + setIsLoadingTables(true); + try { + const response = await fetch(`/api/external-db-connections/${connectionId}/tables`); + const data = await response.json(); + if (data.success) { + setTables(data.data || []); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setIsLoadingTables(false); + } + }; + + const loadInternalTables = async () => { + setIsLoadingTables(true); + try { + const response = await fetch("/api/table-management/tables"); + const data = await response.json(); + if (data.success) { + setTables(data.data.map((t: any) => ({ table_name: t.tableName })) || []); + } + } catch (error) { + console.error("내부 테이블 목록 로드 실패:", error); + } finally { + setIsLoadingTables(false); + } + }; + + const loadColumns = async (connectionId: number, tableName: string) => { + setIsLoadingColumns(true); + try { + const response = await fetch( + `/api/external-db-connections/${connectionId}/tables/${encodeURIComponent(tableName)}/columns` + ); + const data = await response.json(); + if (data.success) { + setColumns(data.data || []); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + } finally { + setIsLoadingColumns(false); + } + }; + + const loadInternalColumns = async (tableName: string) => { + setIsLoadingColumns(true); + try { + const response = await fetch(`/api/table-management/tables/${encodeURIComponent(tableName)}/columns`); + const data = await response.json(); + if (data.success) { + setColumns(data.data.map((c: any) => ({ column_name: c.columnName, data_type: c.dataType })) || []); + } + } catch (error) { + console.error("내부 컬럼 목록 로드 실패:", error); + } finally { + setIsLoadingColumns(false); + } + }; + + const updateConfig = (path: string, value: any) => { + const keys = path.split("."); + const newConfig = { ...config }; + let current: any = newConfig; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + + current[keys[keys.length - 1]] = value; + onChange(newConfig); + }; + + return ( +
+
+

📊 데이터 소스

+ + {/* DB 타입 선택 */} +
+ + +
+ + {/* 외부 DB 연결 선택 */} + {config.dataSource?.type === "external" && ( +
+ +
+ + +
+
+ )} + + {/* 테이블 선택 */} +
+ +
+ + +
+
+ + {/* 위도 컬럼 */} +
+ + +
+ + {/* 경도 컬럼 */} +
+ + +
+ + {/* 라벨 컬럼 (선택) */} +
+ + +
+ + {/* 상태 컬럼 (선택) */} +
+ + +
+ + {/* WHERE 조건 (선택) */} +
+ +