"use client"; import React, { useState, useEffect, useCallback, useMemo, useRef } 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"; import { getApiUrl } from "@/lib/utils/apiUrl"; // 컴팩트 모드 임계값 (픽셀) const COMPACT_HEIGHT_THRESHOLD = 180; 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); // 컨테이너 높이 측정을 위한 ref const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(300); // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) const isCompact = element?.size?.height ? element.size.height < COMPACT_HEIGHT_THRESHOLD : containerHeight < COMPACT_HEIGHT_THRESHOLD; // 컨테이너 높이 측정 useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setContainerHeight(entry.contentRect.height); } }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); 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) => { // 🆕 외부 연결 ID가 있으면 먼저 외부 연결 정보를 가져옴 let actualEndpoint = source.endpoint; let actualQueryParams = source.queryParams; let actualHeaders = source.headers; if (source.externalConnectionId) { // console.log("🔗 외부 연결 ID 감지:", source.externalConnectionId); try { const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(source.externalConnectionId)); if (connection) { // console.log("✅ 외부 연결 정보 가져옴:", connection); // 전체 엔드포인트 URL 생성 actualEndpoint = connection.endpoint_path ? `${connection.base_url}${connection.endpoint_path}` : connection.base_url; // console.log("📍 실제 엔드포인트:", actualEndpoint); // 기본 헤더 적용 const headers: any[] = []; if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { Object.entries(connection.default_headers).forEach(([key, value]) => { headers.push({ key, value }); }); } // 인증 정보 적용 const queryParams: any[] = []; if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) { const authConfig = connection.auth_config; if (connection.auth_type === "api-key") { if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) { headers.push({ key: authConfig.keyName, value: authConfig.keyValue }); // console.log("🔑 API Key 헤더 추가:", authConfig.keyName); } else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) { const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName; queryParams.push({ key: actualKeyName, value: authConfig.keyValue }); // console.log("🔑 API Key 쿼리 파라미터 추가:", actualKeyName); } } else if (connection.auth_type === "bearer" && authConfig.token) { headers.push({ key: "Authorization", value: `Bearer ${authConfig.token}` }); // console.log("🔑 Bearer Token 헤더 추가"); } else if (connection.auth_type === "basic" && authConfig.username && authConfig.password) { const credentials = btoa(`${authConfig.username}:${authConfig.password}`); headers.push({ key: "Authorization", value: `Basic ${credentials}` }); // console.log("🔑 Basic Auth 헤더 추가"); } } actualHeaders = headers; actualQueryParams = queryParams; // console.log("✅ 최종 헤더:", actualHeaders); // console.log("✅ 최종 쿼리 파라미터:", actualQueryParams); } } catch (err) { console.error("❌ 외부 연결 정보 가져오기 실패:", err); } } if (!actualEndpoint) { throw new Error("API endpoint가 없습니다."); } // 쿼리 파라미터 처리 const queryParamsObj: Record = {}; if (actualQueryParams && Array.isArray(actualQueryParams)) { actualQueryParams.forEach((param) => { if (param.key && param.value) { queryParamsObj[param.key] = param.value; } }); } // 헤더 처리 const headersObj: Record = {}; if (actualHeaders && Array.isArray(actualHeaders)) { actualHeaders.forEach((header) => { if (header.key && header.value) { headersObj[header.key] = header.value; } }); } // console.log("🌐 API 호출 준비:", { // endpoint: actualEndpoint, // queryParams: queryParamsObj, // headers: headersObj, // }); const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ url: actualEndpoint, 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 생성 id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || 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-destructive"; case "medium": return "bg-warning/100"; case "low": return "bg-primary"; } }; 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 혼합 가능
  • • 알림 타입별 필터링

⚙️ 설정 방법

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

); } // 통계 계산 const stats = { accident: alerts.filter((a) => a.type === "accident").length, weather: alerts.filter((a) => a.type === "weather").length, construction: alerts.filter((a) => a.type === "construction").length, high: alerts.filter((a) => a.severity === "high").length, }; // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 if (isCompact) { return (
{filteredAlerts.length === 0 ? (

알림 없음

) : ( filteredAlerts.map((alert, idx) => (
{getTypeIcon(alert.type)} {alert.title} {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
{alert.location && (

{alert.location}

)}
)) )}
); } // 일반 모드 렌더링 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, idx) => ( // key 중복 방지를 위해 인덱스 추가
{getTypeIcon(alert.type)}

{alert.title}

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

{alert.location}

)}

{alert.description}

{(() => { const original = String(alert.timestamp); const ts = original.replace(/\s+/g, ""); // 공백 제거 // yyyyMMddHHmm 형식 감지 (12자리 숫자) if (/^\d{12}$/.test(ts)) { const year = ts.substring(0, 4); const month = ts.substring(4, 6); const day = ts.substring(6, 8); const hour = ts.substring(8, 10); const minute = ts.substring(10, 12); const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`); return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); } // "2025년 11월 14일 13시 20분" 형식 const koreanMatch = original.match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2})시\s*(\d{1,2})분/); if (koreanMatch) { const [, year, month, day, hour, minute] = koreanMatch; const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:00`); return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); } // ISO 형식 또는 일반 날짜 형식 const date = new Date(original); return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); })()} {alert.source && · {alert.source}}
)) )}
); }