226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
"use client";
|
||
|
||
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="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs 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 z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
|
||
<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>
|
||
);
|
||
}
|