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

680 lines
26 KiB
TypeScript
Raw Normal View History

2025-10-28 13:40:17 +09:00
"use client";
2025-12-19 13:47:30 +09:00
import React, { useState, useEffect, useCallback, useMemo } from "react";
2025-10-28 13:40:17 +09:00
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";
2025-10-29 11:52:18 +09:00
import { getApiUrl } from "@/lib/utils/apiUrl";
2025-10-28 13:40:17 +09:00
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 형식 데이터 감지");
2025-10-28 13:40:17 +09:00
return parseXmlData(text);
}
// CSV 형식 (기상청 특보)
// console.log("📄 CSV 형식 데이터 감지");
2025-10-28 13:40:17 +09:00
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}개 레코드`);
2025-10-28 13:40:17 +09:00
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) {
2025-10-28 13:40:17 +09:00
throw new Error("API endpoint가 없습니다.");
}
// 쿼리 파라미터 처리
const queryParamsObj: Record<string, string> = {};
if (actualQueryParams && Array.isArray(actualQueryParams)) {
actualQueryParams.forEach((param) => {
2025-10-28 13:40:17 +09:00
if (param.key && param.value) {
queryParamsObj[param.key] = param.value;
}
});
}
// 헤더 처리
const headersObj: Record<string, string> = {};
if (actualHeaders && Array.isArray(actualHeaders)) {
actualHeaders.forEach((header) => {
2025-10-28 13:40:17 +09:00
if (header.key && header.value) {
headersObj[header.key] = header.value;
}
});
}
// console.log("🌐 API 호출 준비:", {
// endpoint: actualEndpoint,
// queryParams: queryParamsObj,
// headers: headersObj,
// });
2025-10-28 13:40:17 +09:00
2025-10-29 11:52:18 +09:00
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
2025-10-28 13:40:17 +09:00
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
2025-10-28 13:40:17 +09:00
body: JSON.stringify({
url: actualEndpoint,
2025-10-28 13:40:17 +09:00
method: "GET",
headers: headersObj,
queryParams: queryParamsObj,
}),
});
// console.log("🌐 API 응답 상태:", response.status);
2025-10-28 13:40:17 +09:00
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));
2025-10-28 13:40:17 +09:00
// 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리
if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") {
// console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
2025-10-28 13:40:17 +09:00
apiData = parseTextData(apiData.text);
// console.log("✅ 파싱 성공:", apiData.length, "개 행");
2025-10-28 13:40:17 +09:00
} else if (typeof apiData === "string") {
// console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
2025-10-28 13:40:17 +09:00
apiData = parseTextData(apiData);
// console.log("✅ 파싱 성공:", apiData.length, "개 행");
2025-10-28 13:40:17 +09:00
} else if (Array.isArray(apiData)) {
// console.log("✅ 이미 배열 형태의 데이터입니다.");
2025-10-28 13:40:17 +09:00
} else {
// console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
2025-10-28 13:40:17 +09:00
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, "개 행");
2025-10-28 13:40:17 +09:00
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 = {
2025-11-20 16:25:26 +09:00
// 중복 방지를 위해 소스명과 인덱스를 포함하여 고유 ID 생성
id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || Date.now()}`,
2025-10-28 13:40:17 +09:00
type,
severity,
title,
location,
description,
timestamp,
source: sourceName,
};
// console.log(` ✅ Alert ${index}:`, alert);
2025-10-28 13:40:17 +09:00
return alert;
});
}, []);
const loadMultipleDataSources = useCallback(async () => {
if (!dataSources || dataSources.length === 0) {
return;
}
setLoading(true);
setError(null);
// console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
2025-10-28 13:40:17 +09:00
try {
const results = await Promise.allSettled(
dataSources.map(async (source, index) => {
// console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
2025-10-28 13:40:17 +09:00
if (source.type === "api") {
const alerts = await loadRestApiData(source);
// console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
2025-10-28 13:40:17 +09:00
return alerts;
} else {
const alerts = await loadDatabaseData(source);
// console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
2025-10-28 13:40:17 +09:00
return alerts;
}
})
);
const allAlerts: Alert[] = [];
results.forEach((result, index) => {
if (result.status === "fulfilled") {
// console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
2025-10-28 13:40:17 +09:00
allAlerts.push(...result.value);
} else {
console.error(`❌ 결과 ${index + 1} 실패:`, result.reason);
}
});
// console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
2025-10-28 13:40:17 +09:00
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("🔄 수동 새로고침 버튼 클릭");
2025-10-28 13:40:17 +09:00
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}초마다`);
2025-10-28 13:40:17 +09:00
const intervalId = setInterval(() => {
// console.log("🔄 자동 새로고침 실행");
2025-10-28 13:40:17 +09:00
loadMultipleDataSources();
}, minInterval * 1000);
return () => {
// console.log("⏹️ 자동 새로고침 정리");
2025-10-28 13:40:17 +09:00
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) {
2025-10-29 17:53:03 +09:00
case "high": return "bg-destructive";
case "medium": return "bg-warning/100";
case "low": return "bg-primary";
2025-10-28 13:40:17 +09:00
}
};
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" />
2025-10-29 17:53:03 +09:00
<p className="mt-2 text-sm text-muted-foreground"> ...</p>
2025-10-28 13:40:17 +09:00
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
2025-10-29 17:53:03 +09:00
<div className="text-center text-destructive">
2025-10-28 13:40:17 +09:00
<p className="text-sm"> {error}</p>
<button
onClick={loadMultipleDataSources}
2025-10-29 17:53:03 +09:00
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
2025-10-28 13:40:17 +09:00
>
</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>
2025-10-29 17:53:03 +09:00
<h3 className="text-sm font-bold text-foreground">/</h3>
<div className="space-y-1.5 text-xs text-foreground">
2025-10-28 13:40:17 +09:00
<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>
2025-10-29 17:53:03 +09:00
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
2025-10-28 13:40:17 +09:00
<p className="font-medium"> </p>
<p> </p>
</div>
</div>
</div>
);
}
return (
2025-12-19 13:47:30 +09:00
<div className="flex h-full w-full flex-col overflow-hidden bg-background">
2025-10-28 13:40:17 +09:00
{/* 헤더 */}
2025-10-29 17:53:03 +09:00
<div className="flex items-center justify-between border-b bg-background/80 p-3">
2025-10-28 13:40:17 +09:00
<div>
<h3 className="text-base font-semibold">
{element?.customTitle || "리스크/알림"}
2025-10-28 13:40:17 +09:00
</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">
2025-10-28 13:40:17 +09:00
<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">
2025-10-28 13:40:17 +09:00
{filteredAlerts.length === 0 ? (
2025-10-29 17:53:03 +09:00
<div className="flex h-full items-center justify-center text-muted-foreground">
2025-10-28 13:40:17 +09:00
<p className="text-sm"> </p>
</div>
) : (
2025-11-20 16:25:26 +09:00
filteredAlerts.map((alert, idx) => (
// key 중복 방지를 위해 인덱스 추가
<Card key={`${alert.id}-${idx}`} className="p-2">
2025-10-28 13:40:17 +09:00
<div className="flex items-start gap-2">
2025-10-29 17:53:03 +09:00
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}>
2025-10-28 13:40:17 +09:00
{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 && (
2025-12-19 13:47:30 +09:00
<p className="text-[10px] text-muted-foreground mt-0.5">📍 {alert.location}</p>
2025-10-28 13:40:17 +09:00
)}
2025-10-29 17:53:03 +09:00
<p className="text-[10px] text-foreground mt-0.5 line-clamp-2">{alert.description}</p>
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
<span>
{(() => {
2025-11-14 13:47:55 +09:00
const original = String(alert.timestamp);
const ts = original.replace(/\s+/g, ""); // 공백 제거
2025-11-14 13:47:55 +09:00
// 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`);
2025-11-14 13:47:55 +09: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 형식 또는 일반 날짜 형식
2025-11-14 13:47:55 +09:00
const date = new Date(original);
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
})()}
</span>
2025-10-28 13:40:17 +09:00
{alert.source && <span>· {alert.source}</span>}
</div>
</div>
</div>
</Card>
))
)}
</div>
</div>
</div>
);
}