구버전신버전 모두 보이게 처리완료
This commit is contained in:
parent
50aeacd9ea
commit
e880083600
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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='© <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
//
|
||||
// ... (전체 코드 주석 처리됨)
|
||||
|
|
|
|||
Loading…
Reference in New Issue