755 lines
29 KiB
TypeScript
755 lines
29 KiB
TypeScript
"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<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);
|
||
|
||
// 컨테이너 높이 측정을 위한 ref
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [containerHeight, setContainerHeight] = useState<number>(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("<?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 생성
|
||
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 <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-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 (
|
||
<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-muted-foreground">데이터 로딩 중...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="text-center text-destructive">
|
||
<p className="text-sm">⚠️ {error}</p>
|
||
<button
|
||
onClick={loadMultipleDataSources}
|
||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||
>
|
||
다시 시도
|
||
</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-foreground">리스크/알림</h3>
|
||
<div className="space-y-1.5 text-xs text-foreground">
|
||
<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-primary/10 p-2 text-[10px] text-primary">
|
||
<p className="font-medium">⚙️ 설정 방법</p>
|
||
<p>데이터 소스를 추가하고 저장하세요</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 통계 계산
|
||
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 (
|
||
<div ref={containerRef} className="h-full w-full overflow-y-auto bg-background p-1.5 space-y-1">
|
||
{filteredAlerts.length === 0 ? (
|
||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||
<p className="text-xs">알림 없음</p>
|
||
</div>
|
||
) : (
|
||
filteredAlerts.map((alert, idx) => (
|
||
<div
|
||
key={`${alert.id}-${idx}`}
|
||
className={`rounded px-2 py-1.5 ${
|
||
alert.severity === "high"
|
||
? "bg-destructive/10 border-l-2 border-destructive"
|
||
: alert.severity === "medium"
|
||
? "bg-warning/10 border-l-2 border-warning"
|
||
: "bg-muted/50 border-l-2 border-muted-foreground"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
{getTypeIcon(alert.type)}
|
||
<span className="text-[11px] font-medium truncate flex-1">{alert.title}</span>
|
||
<Badge
|
||
variant={alert.severity === "high" ? "destructive" : "secondary"}
|
||
className="h-4 text-[9px] px-1 flex-shrink-0"
|
||
>
|
||
{alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
|
||
</Badge>
|
||
</div>
|
||
{alert.location && (
|
||
<p className="text-[10px] text-muted-foreground truncate mt-0.5 pl-5">{alert.location}</p>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 일반 모드 렌더링
|
||
return (
|
||
<div ref={containerRef} className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between border-b bg-background/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-muted-foreground">
|
||
<p className="text-sm">알림이 없습니다</p>
|
||
</div>
|
||
) : (
|
||
filteredAlerts.map((alert, idx) => (
|
||
// key 중복 방지를 위해 인덱스 추가
|
||
<Card key={`${alert.id}-${idx}`} className="p-2">
|
||
<div className="flex items-start gap-2">
|
||
<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"}`}>
|
||
{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-muted-foreground mt-0.5">{alert.location}</p>
|
||
)}
|
||
<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>
|
||
{(() => {
|
||
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");
|
||
})()}
|
||
</span>
|
||
{alert.source && <span>· {alert.source}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|