feat: модуль GPS-мониторинга (карта Яндекс.Карт, Traccar, геозоны, маршруты, группы)
This commit is contained in:
155
client/src/pages/gps/MapTab.tsx
Normal file
155
client/src/pages/gps/MapTab.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { X, Route } from 'lucide-react';
|
||||
import { YandexMap, type LngLatBounds } from '@/components/gps/YandexMap';
|
||||
import { GpsAssetMarker, GpsGeozonePolygon } from '@/components/gps/map-features';
|
||||
import { useGpsAssets, useGpsConfig, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
|
||||
import type { GpsPosition } from './types';
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
/**
|
||||
* Таб «Карта»: текущие позиции всех активных объектов, геозоны,
|
||||
* фильтр по группе, попап объекта с переходом к маршруту за сегодня.
|
||||
*/
|
||||
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
|
||||
const { data: configData } = useGpsConfig();
|
||||
const { data: assetsData } = useGpsAssets();
|
||||
const { data: positionsData } = useGpsPositions();
|
||||
const { data: geozonesData } = useGpsGeozones();
|
||||
const { data: groupsData } = useGpsGroups();
|
||||
useGpsLiveUpdates();
|
||||
|
||||
const [groupFilter, setGroupFilter] = useState<string>('all');
|
||||
const [selected, setSelected] = useState<GpsPosition | null>(null);
|
||||
|
||||
const assets = assetsData?.assets || [];
|
||||
const positions = (positionsData?.positions || []).filter((p) => p.lat != null && p.lng != null);
|
||||
const geozones = (geozonesData?.geozones || []).filter((g) => g.isActive !== false);
|
||||
const groups = groupsData?.groups || [];
|
||||
|
||||
// Фильтр по группе: принадлежность объекта группам берём из списка assets
|
||||
const groupAssetIds = useMemo(() => {
|
||||
if (groupFilter === 'all') return null;
|
||||
const group = groups.find((g) => String(g.id) === groupFilter);
|
||||
return group ? new Set(group.assetIds) : new Set<number>();
|
||||
}, [groupFilter, groups]);
|
||||
|
||||
const visiblePositions = useMemo(
|
||||
() => (groupAssetIds ? positions.filter((p) => groupAssetIds.has(p.assetId)) : positions),
|
||||
[positions, groupAssetIds]
|
||||
);
|
||||
|
||||
// Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам)
|
||||
const [bounds, setBounds] = useState<LngLatBounds | null>(null);
|
||||
useEffect(() => {
|
||||
if (bounds || positions.length === 0) return;
|
||||
const lats = positions.map((p) => p.lat as number);
|
||||
const lngs = positions.map((p) => p.lng as number);
|
||||
const pad = 0.01;
|
||||
setBounds([
|
||||
[Math.min(...lngs) - pad, Math.min(...lats) - pad],
|
||||
[Math.max(...lngs) + pad, Math.max(...lats) + pad],
|
||||
]);
|
||||
}, [positions, bounds]);
|
||||
|
||||
// Если выбранный объект пропал из позиций (стал неактивным) — закрываем панель
|
||||
useEffect(() => {
|
||||
if (selected && !positions.some((p) => p.assetId === selected.assetId)) {
|
||||
setSelected(null);
|
||||
}
|
||||
}, [positions, selected]);
|
||||
|
||||
const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null;
|
||||
const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={groupFilter} onValueChange={setGroupFilter}>
|
||||
<SelectTrigger className="w-56 h-8 text-xs">
|
||||
<SelectValue placeholder="Все группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все группы</SelectItem>
|
||||
{groups.map((g) => (
|
||||
<SelectItem key={g.id} value={String(g.id)}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Объектов на карте: {visiblePositions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<YandexMap
|
||||
apiKey={configData?.yandexMapsKey}
|
||||
bounds={bounds ?? undefined}
|
||||
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
|
||||
>
|
||||
{geozones.map((g) => (
|
||||
<GpsGeozonePolygon key={`gz-${g.id}`} polygon={g.polygon} color={g.color} />
|
||||
))}
|
||||
{visiblePositions.map((p) => (
|
||||
<GpsAssetMarker
|
||||
key={p.assetId}
|
||||
coordinates={[p.lng as number, p.lat as number]}
|
||||
name={p.name}
|
||||
type={p.type}
|
||||
color={p.color}
|
||||
isOnline={p.isOnline}
|
||||
onClick={() => setSelected(p)}
|
||||
/>
|
||||
))}
|
||||
</YandexMap>
|
||||
|
||||
{/* Панель выбранного объекта поверх карты */}
|
||||
{selectedLive && (
|
||||
<Card className="absolute top-2 right-2 w-72 p-3 space-y-2 shadow-lg z-10">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">{selectedLive.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{selectedLive.type === 'person' ? 'Человек' : 'Транспорт'}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: selectedLive.isOnline ? '#22c55e' : '#9ca3af' }}
|
||||
/>
|
||||
{selectedLive.isOnline ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setSelected(null)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<div>
|
||||
Скорость:{' '}
|
||||
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
|
||||
</div>
|
||||
<div>Последняя точка: {formatDateTime(selectedLive.recordedAt)}</div>
|
||||
{selectedAsset?.externalId && <div>Трекер: {selectedAsset.externalId}</div>}
|
||||
</div>
|
||||
<Button size="sm" className="w-full h-8" onClick={() => onShowRoute(selectedLive.assetId)}>
|
||||
<Route className="w-3.5 h-3.5 mr-1" />
|
||||
Маршрут за сегодня
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user