From e880083600966d9124563e98c8666fa3bc2f5e1c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 29 Oct 2025 09:32:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EA=B5=AC=EB=B2=84=EC=A0=84=EC=8B=A0?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=AA=A8=EB=91=90=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EA=B2=8C=20=EC=B2=98=EB=A6=AC=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/ListWidget.tsx | 370 ++++++++++++++++-- .../dashboard/widgets/MapSummaryWidget.tsx | 259 ++++++++++-- .../dashboard/widgets/RiskAlertWidget.tsx | 327 ++++++++++++++-- 3 files changed, 873 insertions(+), 83 deletions(-) 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 ( +
+
+
⚠️
+
오류 발생
+
{error}
+
+
+ ); + } + + // 데이터 없음 + 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"; -// -// ... (전체 코드 주석 처리됨) -- 2.43.0 From 3198684f275059927901864e03b8baccdc969cfe Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 29 Oct 2025 09:32:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EA=B5=AC=EB=B2=84=EC=A0=84=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=EB=8F=84=20=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/CanvasElement.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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" ? ( // 리스크/알림 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "calendar" ? ( // 달력 위젯 렌더링 @@ -1013,11 +1013,11 @@ export function CanvasElement({ }} />
- // ) : element.type === "widget" && element.subtype === "list" ? ( - // // 리스트 위젯 렌더링 (구버전 - 주석 처리: 2025-10-28, list-v2로 대체) - //
- // - //
+ ) : element.type === "widget" && element.subtype === "list" ? ( + // 리스트 위젯 렌더링 (구버전) +
+ +
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( // 야드 관리 3D 위젯 렌더링
-- 2.43.0