ERP-node/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx

587 lines
21 KiB
TypeScript
Raw Normal View History

2025-10-28 13:40:17 +09:00
"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<Alert[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<AlertType | "all">("all");
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(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("<?xml") || text.trim().startsWith("<result>")) {
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<string, string> = {};
if (source.queryParams && Array.isArray(source.queryParams)) {
source.queryParams.forEach((param) => {
if (param.key && param.value) {
queryParamsObj[param.key] = param.value;
}
});
}
// 헤더 처리
const headersObj: Record<string, string> = {};
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<string, unknown>[] };
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 <AlertTriangle className="h-4 w-4" />;
case "weather": return <Cloud className="h-4 w-4" />;
case "construction": return <Construction className="h-4 w-4" />;
default: return <AlertTriangle className="h-4 w-4" />;
}
};
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 (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-red-500">
<p className="text-sm"> {error}</p>
<button
onClick={loadMultipleDataSources}
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
</button>
</div>
</div>
);
}
if (!dataSources || dataSources.length === 0) {
return (
<div className="flex h-full items-center justify-center p-3">
<div className="max-w-xs space-y-2 text-center">
<div className="text-3xl">🚨</div>
<h3 className="text-sm font-bold text-gray-900">🧪 / </h3>
<div className="space-y-1.5 text-xs text-gray-600">
<p className="font-medium"> </p>
<ul className="space-y-0.5 text-left">
<li> REST API </li>
<li> Database </li>
<li> REST API + Database </li>
<li> </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p> </p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-red-50 to-orange-50">
{/* 헤더 */}
<div className="flex items-center justify-between border-b bg-white/80 p-3">
<div>
<h3 className="text-base font-semibold">
{element?.customTitle || "리스크/알림 테스트"}
</h3>
<p className="text-xs text-muted-foreground">
{dataSources?.length || 0} {alerts.length}
{lastRefreshTime && (
<span className="ml-2">
{lastRefreshTime.toLocaleTimeString("ko-KR")}
</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleManualRefresh}
disabled={loading}
className="h-8 gap-2 text-xs"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-hidden p-2">
<div className="mb-2 flex gap-1 overflow-x-auto">
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
className="h-7 text-xs"
>
({alerts.length})
</Button>
{["accident", "weather", "construction"].map((type) => {
const count = alerts.filter(a => a.type === type).length;
return (
<Button
key={type}
variant={filter === type ? "default" : "outline"}
size="sm"
onClick={() => setFilter(type as AlertType)}
className="h-7 text-xs"
>
{type === "accident" && "사고"}
{type === "weather" && "날씨"}
{type === "construction" && "공사"}
{" "}({count})
</Button>
);
})}
</div>
<div className="flex-1 space-y-1.5 overflow-y-auto">
{filteredAlerts.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-500">
<p className="text-sm"> </p>
</div>
) : (
filteredAlerts.map((alert) => (
<Card key={alert.id} className="border-l-4 p-2" style={{ borderLeftColor: alert.severity === "high" ? "#ef4444" : alert.severity === "medium" ? "#f59e0b" : "#3b82f6" }}>
<div className="flex items-start gap-2">
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-red-100 text-red-600" : alert.severity === "medium" ? "bg-yellow-100 text-yellow-600" : "bg-blue-100 text-blue-600"}`}>
{getTypeIcon(alert.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<h4 className="text-xs font-semibold truncate">{alert.title}</h4>
<Badge variant={alert.severity === "high" ? "destructive" : "secondary"} className="h-4 text-[10px]">
{alert.severity === "high" && "긴급"}
{alert.severity === "medium" && "주의"}
{alert.severity === "low" && "정보"}
</Badge>
</div>
{alert.location && (
<p className="text-[10px] text-gray-500 mt-0.5">📍 {alert.location}</p>
)}
<p className="text-[10px] text-gray-600 mt-0.5 line-clamp-2">{alert.description}</p>
<div className="mt-1 flex items-center gap-2 text-[9px] text-gray-400">
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
{alert.source && <span>· {alert.source}</span>}
</div>
</div>
</div>
</Card>
))
)}
</div>
</div>
</div>
);
}