lhj #159

Merged
hjlee merged 2 commits from lhj into main 2025-10-29 09:33:24 +09:00
4 changed files with 879 additions and 89 deletions

View File

@ -991,7 +991,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "risk-alert" ? (
// 리스크/알림 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<RiskAlertWidget />
<RiskAlertWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "calendar" ? (
// 달력 위젯 렌더링
@ -1013,11 +1013,11 @@ export function CanvasElement({
}}
/>
</div>
// ) : element.type === "widget" && element.subtype === "list" ? (
// // 리스트 위젯 렌더링 (구버전 - 주석 처리: 2025-10-28, list-v2로 대체)
// <div className="h-full w-full">
// <ListWidget element={element} />
// </div>
) : element.type === "widget" && element.subtype === "list" ? (
// 리스트 위젯 렌더링 (구버전)
<div className="h-full w-full">
<ListWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
<div className="widget-interactive-area h-full w-full">

View File

@ -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<DashboardElement>) => void;
}
/**
*
* - DB REST API로
* -
* - , ,
*/
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
<div className="text-sm text-gray-600"> ...</div>
</div>
</div>
);
}
// 에러
if (error) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-2xl"></div>
<div className="text-sm font-medium text-red-600"> </div>
<div className="mt-1 text-xs text-gray-500">{error}</div>
</div>
</div>
);
}
// 데이터 없음
if (!data) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
<div className="text-center">
<div className="mb-2 text-4xl">📋</div>
<div className="text-sm font-medium text-gray-700"> </div>
<div className="mt-1 text-xs text-gray-500"> </div>
</div>
</div>
);
}
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
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 (
<div className="flex h-full w-full flex-col p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
</div>
{/* 테이블 뷰 */}
{config.viewMode === "table" && (
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
<Table>
{config.showHeader && (
<TableHeader>
<TableRow>
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<TableHead
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
style={{ width: col.width ? `${col.width}px` : undefined }}
>
{col.label || col.name}
</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{paginatedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.filter((col) => col.visible).length}
className="text-center text-gray-500"
>
</TableCell>
</TableRow>
) : (
paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<TableCell
key={col.id}
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
>
{String(row[col.dataKey || col.field] ?? "")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
{/* 카드 뷰 */}
{config.viewMode === "card" && (
<div className="flex-1 overflow-auto">
{paginatedRows.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-500"> </div>
) : (
<div
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
style={{
gridTemplateColumns: `repeat(${config.cardColumns || 3}, minmax(0, 1fr))`,
}}
>
{paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
<div className="space-y-2">
{displayColumns
.filter((col) => col.visible)
.map((col) => (
<div key={col.id}>
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
<div
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
>
{String(row[col.dataKey || col.field] ?? "")}
</div>
</div>
))}
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* 페이지네이션 */}
{config.enablePagination && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm">
<div className="text-gray-600">
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center gap-1 px-2">
<span className="text-gray-700">{currentPage}</span>
<span className="text-gray-400">/</span>
<span className="text-gray-500">{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -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<MarkerData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tableName, setTableName] = useState<string | null>(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 (
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (
<p className="text-xs text-orange-500"> </p>
)}
</div>
<button
onClick={loadMapData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
disabled={loading || !element?.dataSource?.query}
>
{loading ? "⏳" : "🔄"}
</button>
</div>
{/* 에러 메시지 (지도 위에 오버레이) */}
{error && (
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
{error}
</div>
)}
{/* 지도 (항상 표시) */}
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
<MapContainer
key={`map-${element.id}`}
center={[36.5, 127.5]}
zoom={7}
style={{ height: "100%", width: "100%", zIndex: 0 }}
zoomControl={true}
preferCanvas={true}
className="z-0"
>
{/* 브이월드 타일맵 */}
<TileLayer
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
attribution='&copy; <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
maxZoom={19}
minZoom={7}
updateWhenIdle={true}
updateWhenZooming={false}
keepBuffer={2}
/>
{/* 마커 표시 */}
{markers.map((marker, idx) => (
<Marker key={idx} position={[marker.lat, marker.lng]}>
<Popup>
<div className="text-xs">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key}>
<strong>{key}:</strong> {String(value)}
</div>
))}
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
</div>
);
}

View File

@ -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<Alert[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [filter, setFilter] = useState<AlertType | "all">("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(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<string>();
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<string>();
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 <AlertTriangle className="h-5 w-5 text-red-600" />;
case "weather":
return <Cloud className="h-5 w-5 text-blue-600" />;
case "construction":
return <Construction className="h-5 w-5 text-yellow-600" />;
}
};
// 알림 타입별 한글명
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 (
<div className="flex h-full w-full flex-col gap-4 overflow-hidden bg-background p-4">
{/* 헤더 */}
<div className="flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<h3 className="text-lg font-semibold">{element?.customTitle || "리스크 / 알림"}</h3>
{stats.high > 0 && (
<Badge variant="destructive"> {stats.high}</Badge>
)}
</div>
<div className="flex items-center gap-2">
{lastUpdated && newAlertIds.size > 0 && (
<Badge variant="secondary" className="animate-pulse">
{newAlertIds.size}
</Badge>
)}
{lastUpdated && (
<span className="text-xs text-muted-foreground">
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<Button variant="ghost" size="sm" onClick={forceRefresh} disabled={isRefreshing} title="실시간 데이터 갱신">
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-3">
<Card
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
filter === "accident" ? "bg-red-50" : ""
}`}
onClick={() => setFilter(filter === "accident" ? "all" : "accident")}
>
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-red-600">{stats.accident}</div>
</Card>
<Card
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
filter === "weather" ? "bg-blue-50" : ""
}`}
onClick={() => setFilter(filter === "weather" ? "all" : "weather")}
>
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-blue-600">{stats.weather}</div>
</Card>
<Card
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
filter === "construction" ? "bg-yellow-50" : ""
}`}
onClick={() => setFilter(filter === "construction" ? "all" : "construction")}
>
<div className="text-xs text-muted-foreground"></div>
<div className="text-2xl font-bold text-yellow-600">{stats.construction}</div>
</Card>
</div>
{/* 필터 상태 표시 */}
{filter !== "all" && (
<div className="flex items-center gap-2">
<Badge variant="outline">
{getAlertTypeName(filter)}
</Badge>
<Button
variant="link"
size="sm"
onClick={() => setFilter("all")}
className="h-auto p-0 text-xs"
>
</Button>
</div>
)}
{/* 알림 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
{filteredAlerts.length === 0 ? (
<Card className="p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
</Card>
) : (
filteredAlerts.map((alert) => (
<Card
key={alert.id}
className={`p-3 transition-all duration-300 ${
newAlertIds.has(alert.id) ? 'bg-accent ring-1 ring-primary' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
{getAlertIcon(alert.type)}
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold">{alert.title}</h4>
{newAlertIds.has(alert.id) && (
<Badge variant="secondary">
NEW
</Badge>
)}
<Badge variant={alert.severity === "high" ? "destructive" : alert.severity === "medium" ? "default" : "secondary"}>
{alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
</Badge>
</div>
<p className="mt-1 text-xs font-medium text-foreground">{alert.location}</p>
<p className="mt-1 text-xs text-muted-foreground">{alert.description}</p>
</div>
</div>
</div>
<div className="mt-2 text-right text-xs text-muted-foreground">{formatTime(alert.timestamp)}</div>
</Card>
))
)}
</div>
{/* 안내 메시지 */}
<div className="border-t pt-3 text-center text-xs text-muted-foreground">
💡 1
</div>
</div>
);
}
// "use client";
//
// ... (전체 코드 주석 처리됨)