Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-04 13:55:28 +09:00
commit a90ddac512
2 changed files with 288 additions and 2 deletions

View File

@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker),
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
// 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -78,6 +79,13 @@ interface PolygonData {
opacity?: number; // 투명도 (0.0 ~ 1.0)
}
// 이동경로 타입
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = useState<MarkerData[]>([]);
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [error, setError] = useState<string | null>(null);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
// 이동경로 상태
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
// dataSources를 useMemo로 추출 (circular reference 방지)
const dataSources = useMemo(() => {
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
return heading;
}, []);
// 이동경로 로드 함수
const loadRoute = useCallback(async (userId: string, date?: string) => {
if (!userId) {
console.log("🛣️ 이동경로 조회 불가: userId 없음");
return;
}
setRouteLoading(true);
setSelectedUserId(userId);
try {
// 선택한 날짜 기준으로 이동경로 조회
const targetDate = date || routeDate;
const startOfDay = `${targetDate}T00:00:00.000Z`;
const endOfDay = `${targetDate}T23:59:59.999Z`;
const query = `SELECT latitude, longitude, recorded_at
FROM vehicle_location_history
WHERE user_id = '${userId}'
AND recorded_at >= '${startOfDay}'
AND recorded_at <= '${endOfDay}'
ORDER BY recorded_at ASC`;
console.log("🛣️ 이동경로 쿼리:", query);
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({ query }),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
}
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setRouteLoading(false);
}, [routeDate]);
// 이동경로 숨기기
const clearRoute = useCallback(() => {
setSelectedUserId(null);
setRoutePoints([]);
}, []);
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
if (!dataSources || dataSources.length === 0) {
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
source: sourceName,
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
});
} else {
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</p>
</div>
<div className="flex items-center gap-2">
{/* 이동경로 날짜 선택 */}
{selectedUserId && (
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
<span className="text-xs text-blue-600">🛣</span>
<input
type="date"
value={routeDate}
onChange={(e) => {
setRouteDate(e.target.value);
if (selectedUserId) {
loadRoute(selectedUserId, e.target.value);
}
}}
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
/>
<span className="text-xs text-blue-600">
({routePoints.length})
</span>
<button
onClick={clearRoute}
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
>
</button>
</div>
)}
<Button
variant="outline"
size="sm"
@ -1305,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 트럭 마커
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
const rotation = heading - 90;
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
const normalizedRotation = ((rotation % 360) + 360) % 360;
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
const transformStyle = isFlipped
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
: `translate(-50%, -50%) rotate(${rotation}deg)`;
markerIcon = L.divIcon({
className: "custom-truck-marker",
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%) rotate(${rotation}deg);
transform: ${transformStyle};
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
@ -1528,12 +1640,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
<div className="text-muted-foreground border-t pt-2 text-[10px]">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div>
{/* 이동경로 버튼 */}
{(() => {
try {
const parsed = JSON.parse(marker.description || "{}");
const userId = parsed.user_id;
if (userId) {
return (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(userId)}
disabled={routeLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{routeLoading && selectedUserId === userId
? "로딩 중..."
: "🛣️ 이동경로 보기"}
</button>
</div>
);
}
return null;
} catch {
return null;
}
})()}
</div>
</div>
</Popup>
</Marker>
);
})}
{/* 이동경로 Polyline */}
{routePoints.length > 1 && (
<Polyline
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
pathOptions={{
color: "#3b82f6",
weight: 4,
opacity: 0.8,
dashArray: "10, 5",
}}
/>
)}
</MapContainer>
)}
</div>

View File

@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
// 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -37,6 +38,16 @@ interface Vehicle {
status: "active" | "inactive" | "maintenance" | "warning" | "off";
speed: number;
destination: string;
userId?: string; // 이동경로 조회용
tripId?: string; // 현재 운행 ID
}
// 이동경로 좌표
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
speed?: number;
}
interface VehicleMapOnlyWidgetProps {
@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
// 이동경로 상태
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [isRouteLoading, setIsRouteLoading] = useState(false);
const loadVehicles = async () => {
setIsLoading(true);
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
: "inactive",
speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중",
userId: row.user_id || row.userId || undefined,
tripId: row.trip_id || row.tripId || undefined,
};
})
// 유효한 위도/경도가 있는 차량만 필터링
@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
setIsLoading(false);
};
// 이동경로 로드 함수
const loadRoute = async (vehicle: Vehicle) => {
if (!vehicle.userId && !vehicle.tripId) {
console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음");
return;
}
setIsRouteLoading(true);
setSelectedVehicle(vehicle);
try {
// 오늘 날짜 기준으로 최근 이동경로 조회
const today = new Date();
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
// trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회
let query = "";
if (vehicle.tripId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE trip_id = '${vehicle.tripId}'
ORDER BY recorded_at ASC`;
} else if (vehicle.userId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE user_id = '${vehicle.userId}'
AND recorded_at >= '${startOfDay}'
ORDER BY recorded_at ASC`;
}
console.log("🛣️ 이동경로 쿼리:", query);
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({ query }),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
speed: row.speed ? parseFloat(row.speed) : undefined,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
}
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setIsRouteLoading(false);
};
// 이동경로 숨기기
const clearRoute = () => {
setSelectedVehicle(null);
setRoutePoints([]);
};
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
useEffect(() => {
loadVehicles();
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
keepBuffer={2}
/>
{/* 이동경로 Polyline */}
{routePoints.length > 1 && (
<Polyline
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
pathOptions={{
color: "#3b82f6",
weight: 4,
opacity: 0.8,
dashArray: "10, 5",
}}
/>
)}
{/* 차량 마커 */}
{vehicles.map((vehicle) => (
<React.Fragment key={vehicle.id}>
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div>
<strong>:</strong> {vehicle.destination}
</div>
{/* 이동경로 버튼 */}
{(vehicle.userId || vehicle.tripId) && (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(vehicle)}
disabled={isRouteLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{isRouteLoading && selectedVehicle?.id === vehicle.id
? "로딩 중..."
: "🛣️ 이동경로 보기"}
</button>
</div>
)}
</div>
</Popup>
</Marker>
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div className="text-xs text-foreground"> </div>
)}
</div>
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
{/* {selectedVehicle && routePoints.length > 0 && (
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="text-xs text-white">
<div className="font-semibold">🛣 {selectedVehicle.name} </div>
<div>{routePoints.length} </div>
</div>
<button
onClick={clearRoute}
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
>
</button>
</div>
</div>
)} */}
</div>
</div>
</div>