From 1291f9287c4ed655b42316e4f882b0cfd5c72562 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 28 Oct 2025 13:40:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=ED=9D=AC=EC=A7=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=82=AC=ED=95=AD=20=EC=A4=91=EA=B0=84=EC=84=B8?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 17 +- .../admin/dashboard/DashboardTopMenu.tsx | 16 +- .../admin/dashboard/ElementConfigSidebar.tsx | 21 +- .../dashboard/data-sources/MultiApiConfig.tsx | 227 ++++++- .../data-sources/MultiDataSourceConfig.tsx | 62 +- .../data-sources/MultiDatabaseConfig.tsx | 278 ++++++++- frontend/components/admin/dashboard/types.ts | 5 + .../components/dashboard/DashboardViewer.tsx | 8 +- .../dashboard/widgets/ChartTestWidget.tsx | 66 +- .../widgets/CustomMetricTestWidget.tsx | 405 ++++++++++-- .../dashboard/widgets/ListTestWidget.tsx | 74 ++- .../dashboard/widgets/MapTestWidgetV2.tsx | 200 +++++- .../dashboard/widgets/RiskAlertTestWidget.tsx | 586 ++++++++++++++++++ frontend/next.config.mjs | 2 +- 14 files changed, 1842 insertions(+), 125 deletions(-) create mode 100644 frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 363e2cba..840b5ea8 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -78,12 +78,20 @@ const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/Cha loading: () =>
로딩 중...
, }); -const ListTestWidget = dynamic(() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { +const ListTestWidget = dynamic( + () => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), + { + ssr: false, + loading: () =>
로딩 중...
, + }, +); + +const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { ssr: false, loading: () =>
로딩 중...
, }); -const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { +const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), { ssr: false, loading: () =>
로딩 중...
, }); @@ -904,6 +912,11 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "risk-alert-test" ? ( + // 🧪 테스트용 리스크/알림 위젯 (다중 데이터 소스) +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index cc2668cb..53fcbe0b 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,13 +181,15 @@ export function DashboardTopMenu({ - - 🧪 테스트 위젯 (다중 데이터 소스) - 🧪 지도 테스트 V2 - 🧪 차트 테스트 - 🧪 리스트 테스트 - 🧪 커스텀 메트릭 테스트 - + + 🧪 테스트 위젯 (다중 데이터 소스) + 🧪 지도 테스트 V2 + 🧪 차트 테스트 + 🧪 리스트 테스트 + 🧪 커스텀 메트릭 테스트 + 🧪 상태 요약 테스트 + 🧪 리스크/알림 테스트 + 데이터 위젯 리스트 위젯 diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx index 1abe263d..02417e92 100644 --- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx @@ -123,11 +123,16 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem if (!element) return; console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource); - console.log("🔧 적용 버튼 클릭 - dataSources:", element.dataSources); + console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources); console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig); // 다중 데이터 소스 위젯 체크 - const isMultiDS = element.subtype === "map-test-v2" || element.subtype === "chart-test"; + const isMultiDS = + element.subtype === "map-test-v2" || + element.subtype === "chart-test" || + element.subtype === "list-test" || + element.subtype === "custom-metric-test" || + element.subtype === "risk-alert-test"; const updatedElement: DashboardElement = { ...element, @@ -222,11 +227,13 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); // 다중 데이터 소스 테스트 위젯 - const isMultiDataSourceWidget = - element.subtype === "map-test-v2" || - element.subtype === "chart-test" || - element.subtype === "list-test" || - element.subtype === "custom-metric-test"; + const isMultiDataSourceWidget = + element.subtype === "map-test-v2" || + element.subtype === "chart-test" || + element.subtype === "list-test" || + element.subtype === "custom-metric-test" || + element.subtype === "status-summary-test" || + element.subtype === "risk-alert-test"; // 저장 가능 여부 확인 const isPieChart = element.subtype === "pie" || element.subtype === "donut"; diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index b5a56b9c..ec149a08 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -20,6 +20,10 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [apiConnections, setApiConnections] = useState([]); const [selectedConnectionId, setSelectedConnectionId] = useState(""); + const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록 + const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보 + const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) + const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어 console.log("🔧 MultiApiConfig - dataSource:", dataSource); @@ -88,12 +92,14 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M }); console.log("API Key 헤더 추가:", authConfig.keyName); } else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) { + // UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환 + const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName; queryParams.push({ id: `auth_query_${Date.now()}`, - key: authConfig.keyName, + key: actualKeyName, value: authConfig.keyValue, }); - console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName); + console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")"); } break; @@ -299,6 +305,41 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const rows = Array.isArray(data) ? data : [data]; + // 컬럼 목록 및 타입 추출 + if (rows.length > 0) { + const columns = Object.keys(rows[0]); + setAvailableColumns(columns); + + // 컬럼 타입 분석 (첫 번째 행 기준) + const types: Record = {}; + columns.forEach(col => { + const value = rows[0][col]; + if (value === null || value === undefined) { + types[col] = "unknown"; + } else if (typeof value === "number") { + types[col] = "number"; + } else if (typeof value === "boolean") { + types[col] = "boolean"; + } else if (typeof value === "string") { + // 날짜 형식 체크 + if (/^\d{4}-\d{2}-\d{2}/.test(value)) { + types[col] = "date"; + } else { + types[col] = "string"; + } + } else { + types[col] = "object"; + } + }); + setColumnTypes(types); + + // 샘플 데이터 저장 (최대 3개) + setSampleData(rows.slice(0, 3)); + + console.log("📊 발견된 컬럼:", columns); + console.log("📊 컬럼 타입:", types); + } + // 위도/경도 또는 coordinates 필드 또는 지역 코드 체크 const hasLocationData = rows.some((row) => { const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude); @@ -488,6 +529,34 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M ))}
+ {/* 자동 새로고침 설정 */} +
+ + +

+ 설정한 간격마다 자동으로 데이터를 다시 불러옵니다 +

+
+ {/* 테스트 버튼 */}
+ + {/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */} + {availableColumns.length > 0 && ( +
+
+
+ +

+ {dataSource.selectedColumns && dataSource.selectedColumns.length > 0 + ? `${dataSource.selectedColumns.length}개 컬럼 선택됨` + : "모든 컬럼 표시"} +

+
+
+ + +
+
+ + {/* 검색 */} + {availableColumns.length > 5 && ( + setColumnSearchTerm(e.target.value)} + className="h-8 text-xs" + /> + )} + + {/* 컬럼 카드 그리드 */} +
+ {availableColumns + .filter(col => + !columnSearchTerm || + col.toLowerCase().includes(columnSearchTerm.toLowerCase()) + ) + .map((col) => { + const isSelected = + !dataSource.selectedColumns || + dataSource.selectedColumns.length === 0 || + dataSource.selectedColumns.includes(col); + + const type = columnTypes[col] || "unknown"; + const typeIcon = { + number: "🔢", + string: "📝", + date: "📅", + boolean: "✓", + object: "📦", + unknown: "❓" + }[type]; + + const typeColor = { + number: "text-blue-600 bg-blue-50", + string: "text-gray-600 bg-gray-50", + date: "text-purple-600 bg-purple-50", + boolean: "text-green-600 bg-green-50", + object: "text-orange-600 bg-orange-50", + unknown: "text-gray-400 bg-gray-50" + }[type]; + + return ( +
{ + const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0 + ? dataSource.selectedColumns + : availableColumns; + + const newSelected = isSelected + ? currentSelected.filter(c => c !== col) + : [...currentSelected, col]; + + onChange({ selectedColumns: newSelected }); + }} + className={` + relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all + ${isSelected + ? "border-primary bg-primary/5 shadow-sm" + : "border-border bg-card hover:border-primary/50 hover:bg-muted/50" + } + `} + > + {/* 체크박스 */} +
+
+ {isSelected && ( + + + + )} +
+
+ + {/* 컬럼 정보 */} +
+
+ {col} + + {typeIcon} {type} + +
+ + {/* 샘플 데이터 */} + {sampleData.length > 0 && ( +
+ 예시:{" "} + {sampleData.slice(0, 2).map((row, i) => ( + + {String(row[col]).substring(0, 20)} + {String(row[col]).length > 20 && "..."} + {i < Math.min(sampleData.length - 1, 1) && ", "} + + ))} +
+ )} +
+
+ ); + })} +
+ + {/* 검색 결과 없음 */} + {columnSearchTerm && availableColumns.filter(col => + col.toLowerCase().includes(columnSearchTerm.toLowerCase()) + ).length === 0 && ( +
+ "{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다 +
+ )} +
+ )} ); } diff --git a/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx index e0b39220..e24dc42a 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx @@ -78,15 +78,28 @@ export default function MultiDataSourceConfig({ 여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다

- + + + + + + handleAddDataSource("api")}> + + REST API 추가 + + handleAddDataSource("database")}> + + Database 추가 + + + {/* 데이터 소스가 없는 경우 */} @@ -95,15 +108,28 @@ export default function MultiDataSourceConfig({

연결된 데이터 소스가 없습니다

- + + + + + + handleAddDataSource("api")}> + + REST API 추가 + + handleAddDataSource("database")}> + + Database 추가 + + + ) : ( /* 탭 UI */ diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index cf1efaa5..62a38701 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import { ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; @@ -25,6 +26,10 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null); const [externalConnections, setExternalConnections] = useState([]); const [loadingConnections, setLoadingConnections] = useState(false); + const [availableColumns, setAvailableColumns] = useState([]); // 쿼리 테스트 후 발견된 컬럼 목록 + const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보 + const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) + const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어 // 외부 DB 커넥션 목록 로드 useEffect(() => { @@ -36,19 +41,19 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab const loadExternalConnections = async () => { setLoadingConnections(true); try { - const response = await fetch("/api/admin/reports/external-connections", { - credentials: "include", - }); + // ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함) + const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); + const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); - if (response.ok) { - const result = await response.json(); - if (result.success && result.data) { - const connections = Array.isArray(result.data) ? result.data : result.data.data || []; - setExternalConnections(connections); - } - } + console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개"); + setExternalConnections(connections.map((conn: any) => ({ + id: String(conn.id), + name: conn.connection_name, + type: conn.db_type, + }))); } catch (error) { - console.error("외부 DB 커넥션 로드 실패:", error); + console.error("❌ 외부 DB 커넥션 로드 실패:", error); + setExternalConnections([]); } finally { setLoadingConnections(false); } @@ -77,7 +82,41 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab ); if (result.success && result.data) { - const rowCount = Array.isArray(result.data.rows) ? result.data.rows.length : 0; + const rows = Array.isArray(result.data.rows) ? result.data.rows : []; + const rowCount = rows.length; + + // 컬럼 목록 및 타입 추출 + if (rows.length > 0) { + const columns = Object.keys(rows[0]); + setAvailableColumns(columns); + + // 컬럼 타입 분석 + const types: Record = {}; + columns.forEach(col => { + const value = rows[0][col]; + if (value === null || value === undefined) { + types[col] = "unknown"; + } else if (typeof value === "number") { + types[col] = "number"; + } else if (typeof value === "boolean") { + types[col] = "boolean"; + } else if (typeof value === "string") { + if (/^\d{4}-\d{2}-\d{2}/.test(value)) { + types[col] = "date"; + } else { + types[col] = "string"; + } + } else { + types[col] = "object"; + } + }); + setColumnTypes(types); + setSampleData(rows.slice(0, 3)); + + console.log("📊 발견된 컬럼:", columns); + console.log("📊 컬럼 타입:", types); + } + setTestResult({ success: true, message: "쿼리 실행 성공", @@ -89,6 +128,39 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab } else { // 현재 DB const result = await dashboardApi.executeQuery(dataSource.query); + + // 컬럼 목록 및 타입 추출 + if (result.rows && result.rows.length > 0) { + const columns = Object.keys(result.rows[0]); + setAvailableColumns(columns); + + // 컬럼 타입 분석 + const types: Record = {}; + columns.forEach(col => { + const value = result.rows[0][col]; + if (value === null || value === undefined) { + types[col] = "unknown"; + } else if (typeof value === "number") { + types[col] = "number"; + } else if (typeof value === "boolean") { + types[col] = "boolean"; + } else if (typeof value === "string") { + if (/^\d{4}-\d{2}-\d{2}/.test(value)) { + types[col] = "date"; + } else { + types[col] = "string"; + } + } else { + types[col] = "object"; + } + }); + setColumnTypes(types); + setSampleData(result.rows.slice(0, 3)); + + console.log("📊 발견된 컬럼:", columns); + console.log("📊 컬럼 타입:", types); + } + setTestResult({ success: true, message: "쿼리 실행 성공", @@ -183,6 +255,34 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab

+ {/* 자동 새로고침 설정 */} +
+ + +

+ 설정한 간격마다 자동으로 데이터를 다시 불러옵니다 +

+
+ {/* 테스트 버튼 */}
+ + {/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */} + {availableColumns.length > 0 && ( +
+
+
+ +

+ {dataSource.selectedColumns && dataSource.selectedColumns.length > 0 + ? `${dataSource.selectedColumns.length}개 컬럼 선택됨` + : "모든 컬럼 표시"} +

+
+
+ + +
+
+ + {/* 검색 */} + {availableColumns.length > 5 && ( + setColumnSearchTerm(e.target.value)} + className="h-8 text-xs" + /> + )} + + {/* 컬럼 카드 그리드 */} +
+ {availableColumns + .filter(col => + !columnSearchTerm || + col.toLowerCase().includes(columnSearchTerm.toLowerCase()) + ) + .map((col) => { + const isSelected = + !dataSource.selectedColumns || + dataSource.selectedColumns.length === 0 || + dataSource.selectedColumns.includes(col); + + const type = columnTypes[col] || "unknown"; + const typeIcon = { + number: "🔢", + string: "📝", + date: "📅", + boolean: "✓", + object: "📦", + unknown: "❓" + }[type]; + + const typeColor = { + number: "text-blue-600 bg-blue-50", + string: "text-gray-600 bg-gray-50", + date: "text-purple-600 bg-purple-50", + boolean: "text-green-600 bg-green-50", + object: "text-orange-600 bg-orange-50", + unknown: "text-gray-400 bg-gray-50" + }[type]; + + return ( +
{ + const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0 + ? dataSource.selectedColumns + : availableColumns; + + const newSelected = isSelected + ? currentSelected.filter(c => c !== col) + : [...currentSelected, col]; + + onChange({ selectedColumns: newSelected }); + }} + className={` + relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all + ${isSelected + ? "border-primary bg-primary/5 shadow-sm" + : "border-border bg-card hover:border-primary/50 hover:bg-muted/50" + } + `} + > + {/* 체크박스 */} +
+
+ {isSelected && ( + + + + )} +
+
+ + {/* 컬럼 정보 */} +
+
+ {col} + + {typeIcon} {type} + +
+ + {/* 샘플 데이터 */} + {sampleData.length > 0 && ( +
+ 예시:{" "} + {sampleData.slice(0, 2).map((row, i) => ( + + {String(row[col]).substring(0, 20)} + {String(row[col]).length > 20 && "..."} + {i < Math.min(sampleData.length - 1, 1) && ", "} + + ))} +
+ )} +
+
+ ); + })} +
+ + {/* 검색 결과 없음 */} + {columnSearchTerm && availableColumns.filter(col => + col.toLowerCase().includes(columnSearchTerm.toLowerCase()) + ).length === 0 && ( +
+ "{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다 +
+ )} +
+ )} ); } diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index ed615762..15236d42 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -28,6 +28,8 @@ export type ElementSubtype = | "chart-test" // 🧪 차트 테스트 (다중 데이터 소스) | "list-test" // 🧪 리스트 테스트 (다중 데이터 소스) | "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스) + | "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스) + | "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스) | "delivery-status" | "status-summary" // 범용 상태 카드 (통합) // | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석) @@ -152,6 +154,9 @@ export interface ChartDataSource { lastExecuted?: string; // 마지막 실행 시간 lastError?: string; // 마지막 오류 메시지 mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역) + + // 메트릭 설정 (CustomMetricTestWidget용) + selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시) } export interface ChartConfig { diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 062a1b1f..b24f9219 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -12,8 +12,12 @@ const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { s const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false }); const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false }); const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false }); -const ListTestWidget = dynamic(() => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false }); +const ListTestWidget = dynamic( + () => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), + { ssr: false }, +); const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false }); +const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false }); const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false }); const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false }); const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false }); @@ -91,6 +95,8 @@ function renderWidget(element: DashboardElement) { return ; case "custom-metric-test": return ; + case "risk-alert-test": + return ; case "risk-alert": return ; case "calendar": diff --git a/frontend/components/dashboard/widgets/ChartTestWidget.tsx b/frontend/components/dashboard/widgets/ChartTestWidget.tsx index b445c48e..f4b21f43 100644 --- a/frontend/components/dashboard/widgets/ChartTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ChartTestWidget.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Loader2, RefreshCw } from "lucide-react"; import { LineChart, Line, @@ -29,9 +30,14 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [lastRefreshTime, setLastRefreshTime] = useState(null); console.log("🧪 ChartTestWidget 렌더링!", element); + const dataSources = useMemo(() => { + return element?.dataSources || element?.chartConfig?.dataSources; + }, [element?.dataSources, element?.chartConfig?.dataSources]); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 @@ -81,6 +87,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`); setData(allData); + setLastRefreshTime(new Date()); } catch (err: any) { console.error("❌ 데이터 로딩 중 오류:", err); setError(err.message); @@ -89,6 +96,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { } }, [element?.dataSources]); + // 수동 새로고침 핸들러 + const handleManualRefresh = useCallback(() => { + console.log("🔄 수동 새로고침 버튼 클릭"); + loadMultipleDataSources(); + }, [loadMultipleDataSources]); + // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise => { if (!source.endpoint) { @@ -174,12 +187,36 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { return result.data || []; }; + // 초기 로드 useEffect(() => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; if (dataSources && dataSources.length > 0) { loadMultipleDataSources(); } - }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]); + }, [dataSources, loadMultipleDataSources]); + + // 자동 새로고침 + 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("🔄 자동 새로고침 실행"); + loadMultipleDataSources(); + }, minInterval * 1000); + + return () => { + console.log("⏹️ 자동 새로고침 정리"); + clearInterval(intervalId); + }; + }, [dataSources, loadMultipleDataSources]); const chartType = element?.subtype || "line"; const chartConfig = element?.chartConfig || {}; @@ -267,10 +304,27 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { {element?.customTitle || "차트 테스트 (다중 데이터 소스)"}

- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨 + {dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )}

- {loading && } +
+ + {loading && } +
diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index b0f9122c..8c58fe4f 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Loader2, RefreshCw } from "lucide-react"; interface CustomMetricTestWidgetProps { element: DashboardElement; @@ -54,10 +55,25 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const [metrics, setMetrics] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [lastRefreshTime, setLastRefreshTime] = useState(null); console.log("🧪 CustomMetricTestWidget 렌더링!", element); - const metricConfig = element?.customMetricConfig?.metrics || []; + const dataSources = useMemo(() => { + return element?.dataSources || element?.chartConfig?.dataSources; + }, [element?.dataSources, element?.chartConfig?.dataSources]); + + // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션 + const metricConfig = useMemo(() => { + return element?.customMetricConfig?.metrics || [ + { + label: "총 개수", + field: "id", + aggregation: "count", + color: "indigo", + }, + ]; + }, [element?.customMetricConfig?.metrics]); // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { @@ -73,43 +89,203 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg setError(null); try { - // 모든 데이터 소스를 병렬로 로딩 + // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리) const results = await Promise.allSettled( - dataSources.map(async (source) => { + dataSources.map(async (source, sourceIndex) => { try { - console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); + console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`); + let rows: any[] = []; if (source.type === "api") { - return await loadRestApiData(source); + rows = await loadRestApiData(source); } else if (source.type === "database") { - return await loadDatabaseData(source); + rows = await loadDatabaseData(source); } - return []; + 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 []; + return { + sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, + sourceIndex: sourceIndex, + rows: [], + }; } }) ); - // 성공한 데이터만 병합 - const allRows: any[] = []; + console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`); + + // 각 데이터 소스별로 메트릭 생성 + const allMetrics: any[] = []; + const colors = ["indigo", "green", "blue", "purple", "orange", "gray"]; + results.forEach((result) => { - if (result.status === "fulfilled" && Array.isArray(result.value)) { - allRows.push(...result.value); + if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) { + return; + } + + const { sourceName, rows } = result.value; + + // 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면) + const hasAggregatedData = rows.length > 0 && rows.length <= 100; + + if (hasAggregatedData && 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 => { + const value = firstRow[col]; + return typeof value === 'string' || !numericColumns.includes(col); + }); + + console.log(`📊 [${sourceName}] 컬럼 분석:`, { + 전체: columns, + 숫자: numericColumns, + 문자열: stringColumns + }); + + // 숫자 컬럼이 있으면 집계된 데이터로 판단 + if (numericColumns.length > 0) { + console.log(`✅ [${sourceName}] 집계된 데이터, 각 행을 메트릭으로 변환`); + + rows.forEach((row, index) => { + // 라벨: 첫 번째 문자열 컬럼 + const labelField = stringColumns[0] || columns[0]; + const label = String(row[labelField] || `항목 ${index + 1}`); + + // 값: 첫 번째 숫자 컬럼 + const valueField = numericColumns[0] || columns[1] || columns[0]; + const value = Number(row[valueField]) || 0; + + console.log(` [${sourceName}] 메트릭: ${label} = ${value}`); + + allMetrics.push({ + label: `${sourceName} - ${label}`, + value: value, + field: valueField, + aggregation: "custom", + color: colors[allMetrics.length % colors.length], + sourceName: sourceName, + }); + }); + } else { + // 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시 + console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`); + + // 데이터 소스에서 선택된 컬럼 가져오기 + const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find( + ds => ds.name === sourceName || 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, + }); + }); + + // 총 행 개수도 추가 + allMetrics.push({ + label: `${sourceName} - 총 개수`, + value: rows.length, + field: "count", + aggregation: "count", + color: colors[allMetrics.length % colors.length], + sourceName: sourceName, + }); + } + } else { + // 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시 + console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`); + + const firstRow = rows[0]; + const columns = Object.keys(firstRow); + + // 데이터 소스에서 선택된 컬럼 가져오기 + const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find( + ds => ds.name === sourceName || 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, + }); + }); + + // 총 행 개수 + allMetrics.push({ + label: `${sourceName} - 총 개수`, + value: rows.length, + field: "count", + aggregation: "count", + color: colors[allMetrics.length % colors.length], + sourceName: sourceName, + }); } }); - console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`); - - // 메트릭 계산 - const calculatedMetrics = metricConfig.map((metric) => ({ - ...metric, - value: calculateMetric(allRows, metric.field, metric.aggregation), - })); - - setMetrics(calculatedMetrics); + console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`); + setMetrics(allMetrics); + setLastRefreshTime(new Date()); } catch (err) { setError(err instanceof Error ? err.message : "데이터 로딩 실패"); } finally { @@ -117,6 +293,75 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); + // 수동 새로고침 핸들러 + const handleManualRefresh = useCallback(() => { + console.log("🔄 수동 새로고침 버튼 클릭"); + loadMultipleDataSources(); + }, [loadMultipleDataSources]); + + // 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) { @@ -124,14 +369,26 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } const params = new URLSearchParams(); + + // queryParams 배열 또는 객체 처리 if (source.queryParams) { - Object.entries(source.queryParams).forEach(([key, value]) => { - if (key && value) { - params.append(key, String(value)); - } - }); + 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("http://localhost:8080/api/dashboards/fetch-external-api", { method: "POST", headers: { @@ -146,17 +403,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg }); if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + 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) { - throw new Error(result.message || "외부 API 호출 실패"); + 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("."); @@ -167,6 +441,18 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg 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; + } + } } return Array.isArray(processedData) ? processedData : [processedData]; @@ -206,11 +492,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg // 초기 로드 useEffect(() => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; if (dataSources && dataSources.length > 0 && metricConfig.length > 0) { loadMultipleDataSources(); } - }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources, metricConfig]); + }, [dataSources, loadMultipleDataSources, metricConfig]); + + // 자동 새로고침 + 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("🔄 자동 새로고침 실행"); + loadMultipleDataSources(); + }, minInterval * 1000); + + return () => { + console.log("⏹️ 자동 새로고침 정리"); + clearInterval(intervalId); + }; + }, [dataSources, loadMultipleDataSources]); // 메트릭 카드 렌더링 const renderMetricCard = (metric: any, index: number) => { @@ -238,6 +547,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg ); }; + // 메트릭 개수에 따라 그리드 컬럼 동적 결정 + const getGridCols = () => { + const count = metrics.length; + if (count === 0) return "grid-cols-1"; + if (count === 1) return "grid-cols-1"; + if (count <= 4) return "grid-cols-1 sm:grid-cols-2"; + return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"; + }; + return (
{/* 헤더 */} @@ -247,10 +565,27 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg {element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}

- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨 + {dataSources?.length || 0}개 데이터 소스 • {metrics.length}개 메트릭 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )}

- {loading && } +
+ + {loading && } +
{/* 컨텐츠 */} @@ -272,7 +607,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg

) : ( -
+
{metrics.map((metric, index) => renderMetricCard(metric, index))}
)} diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index b5dceead..23911ecf 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; -import { Loader2 } from "lucide-react"; +import { Loader2, RefreshCw } from "lucide-react"; interface ListTestWidgetProps { element: DashboardElement; @@ -30,9 +30,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + const [lastRefreshTime, setLastRefreshTime] = useState(null); console.log("🧪 ListTestWidget 렌더링!", element); + const dataSources = useMemo(() => { + return element?.dataSources || element?.chartConfig?.dataSources; + }, [element?.dataSources, element?.chartConfig?.dataSources]); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -114,6 +119,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { totalRows: allRows.length, executionTime: 0, }); + setLastRefreshTime(new Date()); console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`); } catch (err) { @@ -123,6 +129,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { } }, [element?.dataSources, element?.chartConfig?.dataSources]); + // 수동 새로고침 핸들러 + const handleManualRefresh = useCallback(() => { + console.log("🔄 수동 새로고침 버튼 클릭"); + loadMultipleDataSources(); + }, [loadMultipleDataSources]); + // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => { if (!source.endpoint) { @@ -152,13 +164,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { }); 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}: ${response.statusText}`); } const result = await response.json(); + console.log("✅ API 응답:", result); if (!result.success) { - throw new Error(result.message || "외부 API 호출 실패"); + console.error("❌ API 실패:", result); + throw new Error(result.message || result.error || "외부 API 호출 실패"); } let processedData = result.data; @@ -222,11 +242,34 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { // 초기 로드 useEffect(() => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; if (dataSources && dataSources.length > 0) { loadMultipleDataSources(); } - }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]); + }, [dataSources, loadMultipleDataSources]); + + // 자동 새로고침 + 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("🔄 자동 새로고침 실행"); + loadMultipleDataSources(); + }, minInterval * 1000); + + return () => { + console.log("⏹️ 자동 새로고침 정리"); + clearInterval(intervalId); + }; + }, [dataSources, loadMultipleDataSources]); // 페이지네이션 const pageSize = config.pageSize || 10; @@ -290,10 +333,27 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { {element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}

- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨 + {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )}

- {isLoading && } +
+ + {isLoading && } +
{/* 컨텐츠 */} diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index e684f9e1..349cb9f3 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -3,7 +3,8 @@ import React, { useEffect, useState, useCallback, useMemo } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Loader2, RefreshCw } from "lucide-react"; import "leaflet/dist/leaflet.css"; // Leaflet 아이콘 경로 설정 (엑박 방지) @@ -60,6 +61,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); + const [lastRefreshTime, setLastRefreshTime] = useState(null); console.log("🧪 MapTestWidgetV2 렌더링!", element); console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length); @@ -136,6 +138,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setMarkers(allMarkers); setPolygons(allPolygons); + setLastRefreshTime(new Date()); } catch (err: any) { console.error("❌ 데이터 로딩 중 오류:", err); setError(err.message); @@ -144,6 +147,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }, [dataSources]); + // 수동 새로고침 핸들러 + const handleManualRefresh = useCallback(() => { + console.log("🔄 수동 새로고침 버튼 클릭"); + loadMultipleDataSources(); + }, [loadMultipleDataSources]); + // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); @@ -263,11 +272,47 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType); }; + // XML 데이터 파싱 (UTIC API 등) + const parseXmlData = (xmlText: string): any[] => { + try { + console.log(" 📄 XML 파싱 시작"); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlText, "text/xml"); + + const records = xmlDoc.getElementsByTagName("record"); + const results: 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 || ""; + } + + results.push(obj); + } + + console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`); + return results; + } catch (error) { + console.error(" ❌ XML 파싱 실패:", error); + return []; + } + }; + // 텍스트 데이터 파싱 (CSV, 기상청 형식 등) const parseTextData = (text: string): any[] => { try { console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500)); + // XML 형식 감지 + if (text.trim().startsWith("")) { + console.log(" 📄 XML 형식 데이터 감지"); + return parseXmlData(text); + } + const lines = text.split('\n').filter(line => { const trimmed = line.trim(); return trimmed && @@ -382,8 +427,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } // 마커 데이터 처리 (위도/경도가 있는 경우) - let lat = row.lat || row.latitude || row.y; - let lng = row.lng || row.longitude || row.x; + let lat = row.lat || row.latitude || row.y || row.locationDataY; + let lng = row.lng || row.longitude || row.x || row.locationDataX; // 위도/경도가 없으면 지역 코드/지역명으로 변환 시도 if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) { @@ -715,6 +760,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }, [dataSources, loadMultipleDataSources]); + // 자동 새로고침 + useEffect(() => { + if (!dataSources || dataSources.length === 0) return; + + // 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기 + 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("🔄 자동 새로고침 실행"); + loadMultipleDataSources(); + }, minInterval * 1000); + + return () => { + console.log("⏹️ 자동 새로고침 정리"); + clearInterval(intervalId); + }; + }, [dataSources, loadMultipleDataSources]); + // 타일맵 URL (chartConfig에서 가져오기) const tileMapUrl = element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; @@ -737,9 +807,26 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

{element?.dataSources?.length || 0}개 데이터 소스 연결됨 + {lastRefreshTime && ( + + • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )}

- {loading && } +
+ + {loading && } +
{/* 지도 */} @@ -769,19 +856,22 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} - {geoJsonData && polygons.length > 0 && ( + {(() => { + console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, { + geoJsonData: !!geoJsonData, + polygonsLength: polygons.length, + polygonNames: polygons.map(p => p.name), + }); + return null; + })()} + {geoJsonData && polygons.length > 0 ? ( p.id))} // 폴리곤 변경 시 재렌더링 data={geoJsonData} style={(feature: any) => { const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도) const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군) - // 🔍 디버그: GeoJSON 속성 확인 - if (ctpName === "경상북도" || sigName?.includes("군위") || sigName?.includes("영천")) { - console.log(`🔍 GeoJSON 속성:`, { ctpName, sigName, properties: feature?.properties }); - console.log(`🔍 매칭 시도할 폴리곤:`, polygons.map(p => p.name)); - } - // 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명) const matchingPolygon = polygons.find(p => { if (!p.name) return false; @@ -859,6 +949,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }} /> + ) : ( + <>{console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`)} )} {/* 폴리곤 렌더링 (해상 구역만) */} @@ -902,21 +994,79 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { key={marker.id} position={[marker.lat, marker.lng]} > - -
-
{marker.name}
- {marker.source && ( -
- 출처: {marker.source} + +
+ {/* 제목 */} +
+
{marker.name}
+ {marker.source && ( +
+ 📡 {marker.source} +
+ )} +
+ + {/* 상세 정보 */} +
+ {marker.description && ( +
+
상세 정보
+
+ {(() => { + try { + const parsed = JSON.parse(marker.description); + return ( +
+ {parsed.incidenteTypeCd === "1" && ( +
🚨 교통사고
+ )} + {parsed.incidenteTypeCd === "2" && ( +
🚧 도로공사
+ )} + {parsed.addressJibun && ( +
📍 {parsed.addressJibun}
+ )} + {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && ( +
📍 {parsed.addressNew}
+ )} + {parsed.roadName && ( +
🛣️ {parsed.roadName}
+ )} + {parsed.linkName && ( +
🔗 {parsed.linkName}
+ )} + {parsed.incidentMsg && ( +
💬 {parsed.incidentMsg}
+ )} + {parsed.eventContent && ( +
📝 {parsed.eventContent}
+ )} + {parsed.startDate && ( +
🕐 {parsed.startDate}
+ )} + {parsed.endDate && ( +
🕐 종료: {parsed.endDate}
+ )} +
+ ); + } catch { + return marker.description; + } + })()} +
+
+ )} + + {marker.status && ( +
+ 상태: {marker.status} +
+ )} + + {/* 좌표 */} +
+ 📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
- )} - {marker.status && ( -
- 상태: {marker.status} -
- )} -
- {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx new file mode 100644 index 00000000..0a39a8b1 --- /dev/null +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -0,0 +1,586 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon } from "lucide-react"; +import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; + +type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other"; + +interface Alert { + id: string; + type: AlertType; + severity: "high" | "medium" | "low"; + title: string; + location?: string; + description: string; + timestamp: string; + source?: string; +} + +interface RiskAlertTestWidgetProps { + element: DashboardElement; +} + +export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProps) { + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("all"); + const [lastRefreshTime, setLastRefreshTime] = useState(null); + + const dataSources = useMemo(() => { + return element?.dataSources || element?.chartConfig?.dataSources; + }, [element?.dataSources, element?.chartConfig?.dataSources]); + + const parseTextData = (text: string): any[] => { + // XML 형식 감지 + if (text.trim().startsWith("")) { + console.log("📄 XML 형식 데이터 감지"); + return parseXmlData(text); + } + + // CSV 형식 (기상청 특보) + console.log("📄 CSV 형식 데이터 감지"); + const lines = text.split("\n").filter((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#") && trimmed !== "="; + }); + + return lines.map((line) => { + const values = line.split(","); + const obj: any = {}; + + if (values.length >= 11) { + obj.code = values[0]; + obj.region = values[1]; + obj.subCode = values[2]; + obj.subRegion = values[3]; + obj.tmFc = values[4]; + obj.tmEf = values[5]; + obj.warning = values[6]; + obj.level = values[7]; + obj.status = values[8]; + obj.period = values[9]; + obj.name = obj.subRegion || obj.region || obj.code; + } else { + values.forEach((value, index) => { + obj[`field_${index}`] = value; + }); + } + + return obj; + }); + }; + + const parseXmlData = (xmlText: string): any[] => { + try { + // 간단한 XML 파싱 (DOMParser 사용) + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlText, "text/xml"); + + const records = xmlDoc.getElementsByTagName("record"); + const results: 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 || ""; + } + + results.push(obj); + } + + console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`); + return results; + } catch (error) { + console.error("❌ XML 파싱 실패:", error); + return []; + } + }; + + const loadRestApiData = useCallback(async (source: ChartDataSource) => { + if (!source.endpoint) { + throw new Error("API endpoint가 없습니다."); + } + + // 쿼리 파라미터 처리 + const queryParamsObj: Record = {}; + if (source.queryParams && Array.isArray(source.queryParams)) { + source.queryParams.forEach((param) => { + if (param.key && param.value) { + queryParamsObj[param.key] = param.value; + } + }); + } + + // 헤더 처리 + const headersObj: Record = {}; + if (source.headers && Array.isArray(source.headers)) { + source.headers.forEach((header) => { + if (header.key && header.value) { + headersObj[header.key] = header.value; + } + }); + } + + console.log("🌐 API 호출 준비:", { + endpoint: source.endpoint, + queryParams: queryParamsObj, + headers: headersObj, + }); + console.log("🔍 원본 source.queryParams:", source.queryParams); + console.log("🔍 원본 source.headers:", source.headers); + + const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: source.endpoint, + method: "GET", + headers: headersObj, + queryParams: queryParamsObj, + }), + }); + + console.log("🌐 API 응답 상태:", response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.message || "API 호출 실패"); + } + + let apiData = result.data; + + console.log("🔍 API 응답 데이터 타입:", typeof apiData); + console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500)); + + // 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리 + if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") { + console.log("📦 백엔드가 text 필드로 감싼 데이터 감지"); + apiData = parseTextData(apiData.text); + console.log("✅ 파싱 성공:", apiData.length, "개 행"); + } else if (typeof apiData === "string") { + console.log("📄 텍스트 형식 데이터 감지, 파싱 시도"); + apiData = parseTextData(apiData); + console.log("✅ 파싱 성공:", apiData.length, "개 행"); + } else if (Array.isArray(apiData)) { + console.log("✅ 이미 배열 형태의 데이터입니다."); + } else { + console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도."); + apiData = [apiData]; + } + + // JSON Path 적용 + if (source.jsonPath && typeof apiData === "object" && !Array.isArray(apiData)) { + const paths = source.jsonPath.split("."); + for (const path of paths) { + if (apiData && typeof apiData === "object" && path in apiData) { + apiData = apiData[path]; + } + } + } + + const rows = Array.isArray(apiData) ? apiData : [apiData]; + return convertToAlerts(rows, source.name || source.id || "API"); + }, []); + + const loadDatabaseData = useCallback(async (source: ChartDataSource) => { + if (!source.query) { + throw new Error("SQL 쿼리가 없습니다."); + } + + if (source.connectionType === "external" && source.externalConnectionId) { + 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[] }; + return convertToAlerts(resultData.rows, source.name || source.id || "Database"); + } else { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(source.query); + return convertToAlerts(result.rows, source.name || source.id || "Database"); + } + }, []); + + const convertToAlerts = useCallback((rows: any[], sourceName: string): Alert[] => { + console.log("🔄 convertToAlerts 호출:", rows.length, "개 행"); + + return rows.map((row: any, index: number) => { + // 타입 결정 (UTIC XML 기준) + let type: AlertType = "other"; + + // incidenteTypeCd: 1=사고, 2=공사, 3=행사, 4=기타 + if (row.incidenteTypeCd) { + const typeCode = String(row.incidenteTypeCd); + if (typeCode === "1") { + type = "accident"; + } else if (typeCode === "2") { + type = "construction"; + } + } + // 기상 특보 데이터 (warning 필드가 있으면 무조건 날씨) + else if (row.warning) { + type = "weather"; + } + // 일반 데이터 + else if (row.type || row.타입 || row.alert_type) { + type = (row.type || row.타입 || row.alert_type) as AlertType; + } + + // 심각도 결정 + let severity: "high" | "medium" | "low" = "medium"; + + if (type === "accident") { + severity = "high"; // 사고는 항상 높음 + } else if (type === "construction") { + severity = "medium"; // 공사는 중간 + } else if (row.level === "경보") { + severity = "high"; + } else if (row.level === "주의" || row.level === "주의보") { + severity = "medium"; + } else if (row.severity || row.심각도 || row.priority) { + severity = (row.severity || row.심각도 || row.priority) as "high" | "medium" | "low"; + } + + // 제목 생성 (UTIC XML 기준) + let title = ""; + + if (type === "accident") { + // incidenteSubTypeCd: 1=추돌, 2=접촉, 3=전복, 4=추락, 5=화재, 6=침수, 7=기타 + const subType = row.incidenteSubTypeCd; + const subTypeMap: { [key: string]: string } = { + "1": "추돌사고", "2": "접촉사고", "3": "전복사고", + "4": "추락사고", "5": "화재사고", "6": "침수사고", "7": "기타사고" + }; + title = subTypeMap[String(subType)] || "교통사고"; + } else if (type === "construction") { + title = "도로공사"; + } else if (type === "weather" && row.warning && row.level) { + // 날씨 특보: 공백 제거 + const warning = String(row.warning).trim(); + const level = String(row.level).trim(); + title = `${warning} ${level}`; + } else { + title = row.title || row.제목 || row.name || "알림"; + } + + // 위치 정보 (UTIC XML 기준) - 공백 제거 + let location = row.addressJibun || row.addressNew || + row.roadName || row.linkName || + row.subRegion || row.region || + row.location || row.위치 || undefined; + + if (location && typeof location === "string") { + location = location.trim(); + } + + // 설명 생성 (간결하게) + let description = ""; + + if (row.incidentMsg) { + description = row.incidentMsg; + } else if (row.eventContent) { + description = row.eventContent; + } else if (row.period) { + description = `발효 기간: ${row.period}`; + } else if (row.description || row.설명 || row.content) { + description = row.description || row.설명 || row.content; + } else { + // 설명이 없으면 위치 정보만 표시 + description = location || "상세 정보 없음"; + } + + // 타임스탬프 + const timestamp = row.startDate || row.eventDate || + row.tmFc || row.tmEf || + row.timestamp || row.created_at || + new Date().toISOString(); + + const alert: Alert = { + id: row.id || row.alert_id || row.incidentId || row.eventId || + row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`, + type, + severity, + title, + location, + description, + timestamp, + source: sourceName, + }; + + console.log(` ✅ Alert ${index}:`, alert); + return alert; + }); + }, []); + + const loadMultipleDataSources = useCallback(async () => { + if (!dataSources || dataSources.length === 0) { + return; + } + + setLoading(true); + setError(null); + + console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스"); + + try { + const results = await Promise.allSettled( + dataSources.map(async (source, index) => { + console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type); + if (source.type === "api") { + const alerts = await loadRestApiData(source); + console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림"); + return alerts; + } else { + const alerts = await loadDatabaseData(source); + console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림"); + return alerts; + } + }) + ); + + const allAlerts: Alert[] = []; + results.forEach((result, index) => { + if (result.status === "fulfilled") { + console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림"); + allAlerts.push(...result.value); + } else { + console.error(`❌ 결과 ${index + 1} 실패:`, result.reason); + } + }); + + console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료"); + allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + setAlerts(allAlerts); + setLastRefreshTime(new Date()); + } catch (err: any) { + console.error("❌ 데이터 로딩 실패:", err); + setError(err.message || "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }, [dataSources, loadRestApiData, loadDatabaseData]); + + // 수동 새로고침 핸들러 + const handleManualRefresh = useCallback(() => { + console.log("🔄 수동 새로고침 버튼 클릭"); + loadMultipleDataSources(); + }, [loadMultipleDataSources]); + + // 초기 로드 + useEffect(() => { + if (dataSources && dataSources.length > 0) { + loadMultipleDataSources(); + } + }, [dataSources, loadMultipleDataSources]); + + // 자동 새로고침 + useEffect(() => { + if (!dataSources || dataSources.length === 0) return; + + // 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기 + 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("🔄 자동 새로고침 실행"); + loadMultipleDataSources(); + }, minInterval * 1000); + + return () => { + console.log("⏹️ 자동 새로고침 정리"); + clearInterval(intervalId); + }; + }, [dataSources, loadMultipleDataSources]); + + const getTypeIcon = (type: AlertType) => { + switch (type) { + case "accident": return ; + case "weather": return ; + case "construction": return ; + default: return ; + } + }; + + const getSeverityColor = (severity: "high" | "medium" | "low") => { + switch (severity) { + case "high": return "bg-red-500"; + case "medium": return "bg-yellow-500"; + case "low": return "bg-blue-500"; + } + }; + + const filteredAlerts = filter === "all" ? alerts : alerts.filter(a => a.type === filter); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!dataSources || dataSources.length === 0) { + return ( +
+
+
🚨
+

🧪 리스크/알림 테스트 위젯

+
+

다중 데이터 소스 지원

+
    +
  • • 여러 REST API 동시 연결
  • +
  • • 여러 Database 동시 연결
  • +
  • • REST API + Database 혼합 가능
  • +
  • • 알림 타입별 필터링
  • +
+
+
+

⚙️ 설정 방법

+

데이터 소스를 추가하고 저장하세요

+
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

+ {element?.customTitle || "리스크/알림 테스트"} +

+

+ {dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )} +

+
+ +
+ + {/* 컨텐츠 */} +
+
+ + {["accident", "weather", "construction"].map((type) => { + const count = alerts.filter(a => a.type === type).length; + return ( + + ); + })} +
+ +
+ {filteredAlerts.length === 0 ? ( +
+

알림이 없습니다

+
+ ) : ( + filteredAlerts.map((alert) => ( + +
+
+ {getTypeIcon(alert.type)} +
+
+
+

{alert.title}

+ + {alert.severity === "high" && "긴급"} + {alert.severity === "medium" && "주의"} + {alert.severity === "low" && "정보"} + +
+ {alert.location && ( +

📍 {alert.location}

+ )} +

{alert.description}

+
+ {new Date(alert.timestamp).toLocaleString("ko-KR")} + {alert.source && · {alert.source}} +
+
+
+
+ )) + )} +
+
+
+ ); +} + diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 5fde5ccb..ca804adc 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -23,7 +23,7 @@ const nextConfig = { return [ { source: "/api/:path*", - destination: "http://host.docker.internal:8080/api/:path*", + destination: "http://localhost:8080/api/:path*", }, ]; },