diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index b776e963..9706e06b 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -991,7 +991,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "risk-alert" ? (
// 리스크/알림 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
index 9e40b54e..ab721c4b 100644
--- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
@@ -1,25 +1,347 @@
-/*
- * ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다.
- *
- * 이 파일은 2025-10-28에 주석 처리되었습니다.
- * 새로운 버전: ListTestWidget.tsx (subtype: list-v2)
- *
- * 변경 이유:
- * - 다중 데이터 소스 지원 (REST API + Database 혼합)
- * - 컬럼 매핑 기능 추가
- * - 자동 새로고침 간격 설정 가능
- * - 테이블/카드 뷰 전환
- * - 페이지네이션 개선
- *
- * 이 파일은 복구를 위해 보관 중이며,
- * 향후 문제 발생 시 참고용으로 사용될 수 있습니다.
- *
- * 롤백 방법:
- * 1. 이 파일의 주석 제거
- * 2. types.ts에서 "list" 활성화
- * 3. "list-v2" 주석 처리
- */
+"use client";
-// "use client";
-//
-// ... (전체 코드 주석 처리됨)
+import React, { useState, useEffect } from "react";
+import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
+import { Button } from "@/components/ui/button";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Card } from "@/components/ui/card";
+
+interface ListWidgetProps {
+ element: DashboardElement;
+ onConfigUpdate?: (config: Partial
) => void;
+}
+
+/**
+ * 리스트 위젯 컴포넌트
+ * - DB 쿼리 또는 REST API로 데이터 가져오기
+ * - 테이블 형태로 데이터 표시
+ * - 페이지네이션, 정렬, 검색 기능
+ */
+export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+
+ const config = element.listConfig || {
+ columnMode: "auto",
+ viewMode: "table",
+ columns: [],
+ pageSize: 10,
+ enablePagination: true,
+ showHeader: true,
+ stripedRows: true,
+ compactMode: false,
+ cardColumns: 3,
+ };
+
+ // 데이터 로드
+ useEffect(() => {
+ const loadData = async () => {
+ if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ let queryResult: QueryResult;
+
+ // REST API vs Database 분기
+ if (element.dataSource.type === "api" && element.dataSource.endpoint) {
+ // REST API - 백엔드 프록시를 통한 호출
+ const params = new URLSearchParams();
+ if (element.dataSource.queryParams) {
+ Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
+ if (key && value) {
+ params.append(key, value);
+ }
+ });
+ }
+
+ const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ url: element.dataSource.endpoint,
+ method: "GET",
+ headers: element.dataSource.headers || {},
+ queryParams: Object.fromEntries(params),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || "외부 API 호출 실패");
+ }
+
+ const apiData = result.data;
+
+ // JSON Path 처리
+ let processedData = apiData;
+ if (element.dataSource.jsonPath) {
+ const paths = element.dataSource.jsonPath.split(".");
+ for (const path of paths) {
+ if (processedData && typeof processedData === "object" && path in processedData) {
+ processedData = processedData[path];
+ } else {
+ throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
+ }
+ }
+ }
+
+ const rows = Array.isArray(processedData) ? processedData : [processedData];
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
+
+ queryResult = {
+ columns,
+ rows,
+ totalRows: rows.length,
+ executionTime: 0,
+ };
+ } else if (element.dataSource.query) {
+ // Database (현재 DB 또는 외부 DB)
+ if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
+ // 외부 DB
+ const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
+ const externalResult = await ExternalDbConnectionAPI.executeQuery(
+ parseInt(element.dataSource.externalConnectionId),
+ element.dataSource.query,
+ );
+ if (!externalResult.success) {
+ throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
+ }
+ queryResult = {
+ columns: externalResult.data.columns,
+ rows: externalResult.data.rows,
+ totalRows: externalResult.data.rowCount,
+ executionTime: 0,
+ };
+ } else {
+ // 현재 DB
+ const { dashboardApi } = await import("@/lib/api/dashboard");
+ const result = await dashboardApi.executeQuery(element.dataSource.query);
+ queryResult = {
+ columns: result.columns,
+ rows: result.rows,
+ totalRows: result.rowCount,
+ executionTime: 0,
+ };
+ }
+ } else {
+ throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
+ }
+
+ setData(queryResult);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "데이터 로딩 실패");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadData();
+
+ // 자동 새로고침 설정
+ const refreshInterval = element.dataSource?.refreshInterval;
+ if (refreshInterval && refreshInterval > 0) {
+ const interval = setInterval(loadData, refreshInterval);
+ return () => clearInterval(interval);
+ }
+ }, [
+ element.dataSource?.query,
+ element.dataSource?.connectionType,
+ element.dataSource?.externalConnectionId,
+ element.dataSource?.endpoint,
+ element.dataSource?.refreshInterval,
+ ]);
+
+ // 로딩 중
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // 에러
+ if (error) {
+ return (
+
+ );
+ }
+
+ // 데이터 없음
+ if (!data) {
+ return (
+
+
+
📋
+
리스트를 설정하세요
+
⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요
+
+
+ );
+ }
+
+ // 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
+ const displayColumns =
+ config.columns.length > 0
+ ? config.columns
+ : data.columns.map((col) => ({
+ id: col,
+ name: col,
+ dataKey: col,
+ visible: true,
+ }));
+
+ // 페이지네이션
+ const totalPages = Math.ceil(data.rows.length / config.pageSize);
+ const startIdx = (currentPage - 1) * config.pageSize;
+ const endIdx = startIdx + config.pageSize;
+ const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
+
+ return (
+
+ {/* 제목 - 항상 표시 */}
+
+
{element.customTitle || element.title}
+
+
+ {/* 테이블 뷰 */}
+ {config.viewMode === "table" && (
+
+
+ {config.showHeader && (
+
+
+ {displayColumns
+ .filter((col) => col.visible)
+ .map((col) => (
+
+ {col.label || col.name}
+
+ ))}
+
+
+ )}
+
+ {paginatedRows.length === 0 ? (
+
+ col.visible).length}
+ className="text-center text-gray-500"
+ >
+ 데이터가 없습니다
+
+
+ ) : (
+ paginatedRows.map((row, idx) => (
+
+ {displayColumns
+ .filter((col) => col.visible)
+ .map((col) => (
+
+ {String(row[col.dataKey || col.field] ?? "")}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ )}
+
+ {/* 카드 뷰 */}
+ {config.viewMode === "card" && (
+
+ {paginatedRows.length === 0 ? (
+
데이터가 없습니다
+ ) : (
+
+ {paginatedRows.map((row, idx) => (
+
+
+ {displayColumns
+ .filter((col) => col.visible)
+ .map((col) => (
+
+
{col.label || col.name}
+
+ {String(row[col.dataKey || col.field] ?? "")}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* 페이지네이션 */}
+ {config.enablePagination && totalPages > 1 && (
+
+
+ {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
+
+
+
+
+ {currentPage}
+ /
+ {totalPages}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
index 7c8a8436..3ad09305 100644
--- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
+++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx
@@ -1,34 +1,227 @@
-/*
- * ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다.
- *
- * 이 파일은 2025-10-28에 주석 처리되었습니다.
- * 새로운 버전: MapTestWidgetV2.tsx (subtype: map-summary-v2)
- *
- * 변경 이유:
- * - 다중 데이터 소스 지원 (REST API + Database 혼합)
- * - 컬럼 매핑 기능 추가
- * - 자동 새로고침 간격 설정 가능
- * - 데이터 소스별 색상 설정
- * - XML/CSV 데이터 파싱 지원
- *
- * 이 파일은 복구를 위해 보관 중이며,
- * 향후 문제 발생 시 참고용으로 사용될 수 있습니다.
- *
- * 롤백 방법:
- * 1. 이 파일의 주석 제거
- * 2. types.ts에서 "map-summary" 활성화
- * 3. "map-summary-v2" 주석 처리
- */
+"use client";
-// "use client";
-//
-// import React, { useEffect, useState } from "react";
-// import dynamic from "next/dynamic";
-// import { DashboardElement } from "@/components/admin/dashboard/types";
-// import { getWeather, WeatherData, getWeatherAlerts, WeatherAlert } from "@/lib/api/openApi";
-// import { Cloud, CloudRain, CloudSnow, Sun, Wind, AlertTriangle } from "lucide-react";
-// import turfUnion from "@turf/union";
-// import { polygon } from "@turf/helpers";
-// import { getApiUrl } from "@/lib/utils/apiUrl";
-//
-// ... (전체 코드 주석 처리됨)
+import React, { useEffect, useState } from "react";
+import dynamic from "next/dynamic";
+import { DashboardElement } from "@/components/admin/dashboard/types";
+import "leaflet/dist/leaflet.css";
+
+// Leaflet 아이콘 경로 설정 (엑박 방지)
+if (typeof window !== "undefined") {
+ const L = require("leaflet");
+ delete (L.Icon.Default.prototype as any)._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
+ iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
+ shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
+ });
+}
+
+// Leaflet 동적 import (SSR 방지)
+const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
+const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
+const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
+const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
+
+// 브이월드 API 키
+const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
+
+interface MapSummaryWidgetProps {
+ element: DashboardElement;
+}
+
+interface MarkerData {
+ lat: number;
+ lng: number;
+ name: string;
+ info: any;
+}
+
+// 테이블명 한글 번역
+const translateTableName = (name: string): string => {
+ const tableTranslations: { [key: string]: string } = {
+ "vehicle_locations": "차량",
+ "vehicles": "차량",
+ "warehouses": "창고",
+ "warehouse": "창고",
+ "customers": "고객",
+ "customer": "고객",
+ "deliveries": "배송",
+ "delivery": "배송",
+ "drivers": "기사",
+ "driver": "기사",
+ "stores": "매장",
+ "store": "매장",
+ };
+
+ return tableTranslations[name.toLowerCase()] ||
+ tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
+ name;
+};
+
+/**
+ * 범용 지도 위젯 (커스텀 지도 카드)
+ * - 위도/경도가 있는 모든 데이터를 지도에 표시
+ * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원
+ * - Leaflet + 브이월드 지도 사용
+ */
+export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
+ const [markers, setMarkers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [tableName, setTableName] = useState(null);
+
+ useEffect(() => {
+ if (element?.dataSource?.query) {
+ loadMapData();
+ }
+
+ // 자동 새로고침 (30초마다)
+ const interval = setInterval(() => {
+ if (element?.dataSource?.query) {
+ loadMapData();
+ }
+ }, 30000);
+
+ return () => clearInterval(interval);
+ }, [element]);
+
+ const loadMapData = async () => {
+ if (!element?.dataSource?.query) {
+ return;
+ }
+
+ // 쿼리에서 테이블 이름 추출
+ const extractTableName = (query: string): string | null => {
+ const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i);
+ if (fromMatch) {
+ return fromMatch[1];
+ }
+ return null;
+ };
+
+ try {
+ setLoading(true);
+ const extractedTableName = extractTableName(element.dataSource.query);
+ setTableName(extractedTableName);
+
+ const token = localStorage.getItem("authToken");
+ const response = await fetch("/api/dashboards/execute-query", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ query: element.dataSource.query,
+ connectionType: element.dataSource.connectionType || "current",
+ connectionId: element.dataSource.connectionId,
+ }),
+ });
+
+ if (!response.ok) throw new Error("데이터 로딩 실패");
+
+ const result = await response.json();
+
+ if (result.success && result.data?.rows) {
+ const rows = result.data.rows;
+
+ // 위도/경도 컬럼 찾기
+ const latCol = element.chartConfig?.latitudeColumn || "latitude";
+ const lngCol = element.chartConfig?.longitudeColumn || "longitude";
+
+ // 유효한 좌표 필터링 및 마커 데이터 생성
+ const markerData = rows
+ .filter((row: any) => row[latCol] && row[lngCol])
+ .map((row: any) => ({
+ lat: parseFloat(row[latCol]),
+ lng: parseFloat(row[lngCol]),
+ name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
+ info: row,
+ }));
+
+ setMarkers(markerData);
+ }
+
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "데이터 로딩 실패");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
+ const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도");
+
+ return (
+
+ {/* 헤더 */}
+
+
+
{displayTitle}
+ {element?.dataSource?.query ? (
+
총 {markers.length.toLocaleString()}개 마커
+ ) : (
+
⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요
+ )}
+
+
+
+
+ {/* 에러 메시지 (지도 위에 오버레이) */}
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* 지도 (항상 표시) */}
+
+
+ {/* 브이월드 타일맵 */}
+
+
+ {/* 마커 표시 */}
+ {markers.map((marker, idx) => (
+
+
+
+
{marker.name}
+ {Object.entries(marker.info)
+ .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
+ .map(([key, value]) => (
+
+ {key}: {String(value)}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx
index 29e0d9a1..de6b2af8 100644
--- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx
+++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx
@@ -1,27 +1,302 @@
-/*
- * ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다.
- *
- * 이 파일은 2025-10-28에 주석 처리되었습니다.
- * 새로운 버전: RiskAlertTestWidget.tsx (subtype: risk-alert-v2)
- *
- * 변경 이유:
- * - 다중 데이터 소스 지원 (REST API + Database 혼합)
- * - 컬럼 매핑 기능 추가
- * - 자동 새로고침 간격 설정 가능
- * - XML/CSV 데이터 파싱 지원
- *
- * 참고:
- * - 새 알림 애니메이션 기능은 사용자 요청으로 제외되었습니다.
- *
- * 이 파일은 복구를 위해 보관 중이며,
- * 향후 문제 발생 시 참고용으로 사용될 수 있습니다.
- *
- * 롤백 방법:
- * 1. 이 파일의 주석 제거
- * 2. types.ts에서 "risk-alert" 활성화
- * 3. "risk-alert-v2" 주석 처리
- */
+"use client";
+
+import React, { useState, useEffect } 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 } from "lucide-react";
+import { apiClient } from "@/lib/api/client";
+import { DashboardElement } from "@/components/admin/dashboard/types";
+
+// 알림 타입
+type AlertType = "accident" | "weather" | "construction";
+
+// 알림 인터페이스
+interface Alert {
+ id: string;
+ type: AlertType;
+ severity: "high" | "medium" | "low";
+ title: string;
+ location: string;
+ description: string;
+ timestamp: string;
+}
+
+interface RiskAlertWidgetProps {
+ element?: DashboardElement;
+}
+
+export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
+ const [alerts, setAlerts] = useState([]);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [filter, setFilter] = useState("all");
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [newAlertIds, setNewAlertIds] = useState>(new Set());
+
+ // 데이터 로드 (백엔드 캐시 조회)
+ const loadData = async () => {
+ setIsRefreshing(true);
+ try {
+ // 백엔드 API 호출 (캐시된 데이터)
+ const response = await apiClient.get<{
+ success: boolean;
+ data: Alert[];
+ count: number;
+ lastUpdated?: string;
+ cached?: boolean;
+ }>("/risk-alerts");
+
+ if (response.data.success && response.data.data) {
+ const newData = response.data.data;
+
+ // 새로운 알림 감지
+ const oldIds = new Set(alerts.map(a => a.id));
+ const newIds = new Set();
+ newData.forEach(alert => {
+ if (!oldIds.has(alert.id)) {
+ newIds.add(alert.id);
+ }
+ });
+
+ setAlerts(newData);
+ setNewAlertIds(newIds);
+ setLastUpdated(new Date());
+
+ // 3초 후 새 알림 애니메이션 제거
+ if (newIds.size > 0) {
+ setTimeout(() => setNewAlertIds(new Set()), 3000);
+ }
+ } else {
+ console.error("❌ 리스크 알림 데이터 로드 실패");
+ setAlerts([]);
+ }
+ } catch (error: any) {
+ console.error("❌ 리스크 알림 API 오류:", error.message);
+ // API 오류 시 빈 배열 유지
+ setAlerts([]);
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ // 강제 새로고침 (실시간 API 호출)
+ const forceRefresh = async () => {
+ setIsRefreshing(true);
+ try {
+ // 강제 갱신 API 호출 (실시간 데이터)
+ const response = await apiClient.post<{
+ success: boolean;
+ data: Alert[];
+ count: number;
+ message?: string;
+ }>("/risk-alerts/refresh", {});
+
+ if (response.data.success && response.data.data) {
+ const newData = response.data.data;
+
+ // 새로운 알림 감지
+ const oldIds = new Set(alerts.map(a => a.id));
+ const newIds = new Set();
+ newData.forEach(alert => {
+ if (!oldIds.has(alert.id)) {
+ newIds.add(alert.id);
+ }
+ });
+
+ setAlerts(newData);
+ setNewAlertIds(newIds);
+ setLastUpdated(new Date());
+
+ // 3초 후 새 알림 애니메이션 제거
+ if (newIds.size > 0) {
+ setTimeout(() => setNewAlertIds(new Set()), 3000);
+ }
+ } else {
+ console.error("❌ 리스크 알림 강제 갱신 실패");
+ }
+ } catch (error: any) {
+ console.error("❌ 리스크 알림 강제 갱신 오류:", error.message);
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ // 1분마다 자동 새로고침 (60000ms)
+ const interval = setInterval(loadData, 60000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // 필터링된 알림
+ const filteredAlerts = filter === "all" ? alerts : alerts.filter((alert) => alert.type === filter);
+
+ // 알림 타입별 아이콘
+ const getAlertIcon = (type: AlertType) => {
+ switch (type) {
+ case "accident":
+ return ;
+ case "weather":
+ return ;
+ case "construction":
+ return ;
+ }
+ };
+
+ // 알림 타입별 한글명
+ const getAlertTypeName = (type: AlertType) => {
+ switch (type) {
+ case "accident":
+ return "교통사고";
+ case "weather":
+ return "날씨특보";
+ case "construction":
+ return "도로공사";
+ }
+ };
+
+ // 시간 포맷
+ const formatTime = (isoString: string) => {
+ const date = new Date(isoString);
+ const now = new Date();
+ const diffMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
+
+ if (diffMinutes < 1) return "방금 전";
+ if (diffMinutes < 60) return `${diffMinutes}분 전`;
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours}시간 전`;
+ return `${Math.floor(diffHours / 24)}일 전`;
+ };
+
+ // 통계 계산
+ 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,
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
{element?.customTitle || "리스크 / 알림"}
+ {stats.high > 0 && (
+
긴급 {stats.high}건
+ )}
+
+
+ {lastUpdated && newAlertIds.size > 0 && (
+
+ 새 알림 {newAlertIds.size}건
+
+ )}
+ {lastUpdated && (
+
+ {lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
+
+ )}
+
+
+
+
+ {/* 통계 카드 */}
+
+
setFilter(filter === "accident" ? "all" : "accident")}
+ >
+ 교통사고
+ {stats.accident}건
+
+
setFilter(filter === "weather" ? "all" : "weather")}
+ >
+ 날씨특보
+ {stats.weather}건
+
+
setFilter(filter === "construction" ? "all" : "construction")}
+ >
+ 도로공사
+ {stats.construction}건
+
+
+
+ {/* 필터 상태 표시 */}
+ {filter !== "all" && (
+
+
+ {getAlertTypeName(filter)} 필터 적용 중
+
+
+
+ )}
+
+ {/* 알림 목록 */}
+
+ {filteredAlerts.length === 0 ? (
+
+ 알림이 없습니다
+
+ ) : (
+ filteredAlerts.map((alert) => (
+
+
+
+ {getAlertIcon(alert.type)}
+
+
+
{alert.title}
+ {newAlertIds.has(alert.id) && (
+
+ NEW
+
+ )}
+
+ {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
+
+
+
{alert.location}
+
{alert.description}
+
+
+
+ {formatTime(alert.timestamp)}
+
+ ))
+ )}
+
+
+ {/* 안내 메시지 */}
+
+ 💡 1분마다 자동으로 업데이트됩니다
+
+
+ );
+}
-// "use client";
-//
-// ... (전체 코드 주석 처리됨)