구버전신버전 모두 보이게 처리완료

This commit is contained in:
leeheejin
2025-10-29 09:32:03 +09:00
parent 50aeacd9ea
commit e880083600
3 changed files with 873 additions and 83 deletions

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";
//
// ... (전체 코드 주석 처리됨)