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

651 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
import { getApiUrl } from "@/lib/utils/apiUrl";
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) => {
// 🆕 외부 연결 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<string, string> = {};
if (actualQueryParams && Array.isArray(actualQueryParams)) {
actualQueryParams.forEach((param) => {
if (param.key && param.value) {
queryParamsObj[param.key] = param.value;
}
});
}
// 헤더 처리
const headersObj: Record<string, string> = {};
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<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 flex-1 flex-col 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 pr-1">
{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>
);
}