651 lines
24 KiB
TypeScript
651 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|