feat(gps): last_seen по времени приёма, фильтр джиттера (настройки), линейка, трек по скорости; fix: карта не перекрывает диалоги (isolation)
This commit is contained in:
@@ -218,7 +218,9 @@ export function GpsMap({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
// isolate создаёт stacking context: z-index'ы Leaflet-панов (до 1000)
|
||||
// не выныривают поверх диалогов shadcn (z-50) снаружи карты
|
||||
<div className={cn('relative isolate', className)}>
|
||||
{provider === 'yandex' ? (
|
||||
<Yandex21Map
|
||||
apiKey={apiKey!}
|
||||
|
||||
@@ -176,7 +176,16 @@ export function GpsAssetsTab() {
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{formatDateTime(asset.lastSeenAt)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div>{formatDateTime(asset.lastSeenAt)}</div>
|
||||
{/* Время точки по устройству, если сильно отстаёт от приёма сервером */}
|
||||
{asset.lastSeenAt &&
|
||||
asset.lastRecordedAt &&
|
||||
Math.abs(new Date(asset.lastSeenAt).getTime() - new Date(asset.lastRecordedAt).getTime()) >
|
||||
3_600_000 && (
|
||||
<div className="text-[10px]">Точка от: {formatDateTime(asset.lastRecordedAt)}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(asset.groups || []).map((g) => (
|
||||
|
||||
@@ -2,18 +2,34 @@ 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 { X, Route } from 'lucide-react';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap';
|
||||
import { X, Route, Ruler } from 'lucide-react';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
||||
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
|
||||
import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { GpsAsset, GpsPosition } from './types';
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
/** Расстояние между двумя точками в км (формула гаверсинуса) */
|
||||
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
/** Форматирование расстояния линейки: «1.24 км» / «850 м» */
|
||||
function formatDistance(km: number): string {
|
||||
return km >= 1 ? `${km.toFixed(2)} км` : `${Math.round(km * 1000)} м`;
|
||||
}
|
||||
|
||||
/** Вид карты: либо автоподгонка по bounds, либо центрирование на точке */
|
||||
interface MapView {
|
||||
bounds?: [[number, number], [number, number]];
|
||||
@@ -23,7 +39,7 @@ interface MapView {
|
||||
|
||||
/**
|
||||
* Таб «Карта»: слева панель-дерево объектов (группы/поиск/фильтр),
|
||||
* справа — текущие позиции на карте, геозоны, попап объекта.
|
||||
* справа — текущие позиции на карте, геозоны, попап объекта, линейка.
|
||||
*/
|
||||
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
|
||||
const { toast } = useToast();
|
||||
@@ -76,6 +92,38 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
}
|
||||
}, [positions, selected]);
|
||||
|
||||
// === Линейка: измерение расстояния кликами по карте ===
|
||||
const [rulerMode, setRulerMode] = useState(false);
|
||||
const [rulerPoints, setRulerPoints] = useState<Array<[number, number]>>([]);
|
||||
|
||||
const toggleRuler = () => {
|
||||
setRulerMode((m) => !m);
|
||||
setRulerPoints([]);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
// Выход из режима линейки по Esc
|
||||
useEffect(() => {
|
||||
if (!rulerMode) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setRulerMode(false);
|
||||
setRulerPoints([]);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [rulerMode]);
|
||||
|
||||
// Суммарное расстояние по точкам линейки
|
||||
const rulerDistanceKm = useMemo(() => {
|
||||
let total = 0;
|
||||
for (let i = 1; i < rulerPoints.length; i++) {
|
||||
total += haversineKm(rulerPoints[i - 1][0], rulerPoints[i - 1][1], rulerPoints[i][0], rulerPoints[i][1]);
|
||||
}
|
||||
return total;
|
||||
}, [rulerPoints]);
|
||||
|
||||
// Клик по объекту в дереве: центрируем карту и открываем попап;
|
||||
// объект без координат — только подсказка
|
||||
const handleSelectAsset = (asset: GpsAsset) => {
|
||||
@@ -91,17 +139,33 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null;
|
||||
const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null;
|
||||
|
||||
// Маркеры объектов и полигоны геозон для провайдер-агностичной карты
|
||||
const mapMarkers: GpsMapMarker[] = visiblePositions.map((p) => ({
|
||||
id: p.assetId,
|
||||
lat: p.lat as number,
|
||||
lng: p.lng as number,
|
||||
color: p.color,
|
||||
kind: p.type,
|
||||
label: p.name,
|
||||
offline: !p.isOnline,
|
||||
onClick: () => setSelected(p),
|
||||
}));
|
||||
// Маркеры объектов, полигоны геозон и элементы линейки для карты.
|
||||
// В режиме линейки клики по маркерам объектов отключены — клики идут в линейку.
|
||||
const mapMarkers: GpsMapMarker[] = [
|
||||
...visiblePositions.map((p) => ({
|
||||
id: p.assetId,
|
||||
lat: p.lat as number,
|
||||
lng: p.lng as number,
|
||||
color: p.color,
|
||||
kind: p.type,
|
||||
label: p.name,
|
||||
offline: !p.isOnline,
|
||||
onClick: rulerMode ? undefined : () => setSelected(p),
|
||||
})),
|
||||
...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({
|
||||
id: `ruler-${idx}`,
|
||||
lat,
|
||||
lng,
|
||||
kind: 'dot',
|
||||
color: '#3b82f6',
|
||||
noCluster: true,
|
||||
zIndex: 15,
|
||||
// У последней точки линейки — суммарное расстояние
|
||||
...(idx === rulerPoints.length - 1 && rulerPoints.length > 1
|
||||
? { label: formatDistance(rulerDistanceKm) }
|
||||
: {}),
|
||||
})),
|
||||
];
|
||||
|
||||
const mapPolygons: GpsMapPolygon[] = geozones.map((g) => ({
|
||||
id: `gz-${g.id}`,
|
||||
@@ -109,6 +173,16 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
color: g.color,
|
||||
}));
|
||||
|
||||
const mapPolylines: GpsMapPolyline[] =
|
||||
rulerPoints.length >= 2 ? [{ id: 'ruler', points: rulerPoints, color: '#3b82f6' }] : [];
|
||||
|
||||
// Время в попапе: «Обновлено» — приём точки сервером (реальная активность);
|
||||
// «Точка от» — время по устройству, если отстаёт больше чем на час
|
||||
const seenAt = selectedLive ? selectedLive.lastSeenAt ?? selectedLive.recordedAt : null;
|
||||
const recordedAt = selectedLive?.lastRecordedAt ?? null;
|
||||
const recordedStale =
|
||||
seenAt && recordedAt && Math.abs(new Date(seenAt).getTime() - new Date(recordedAt).getTime()) > 3_600_000;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-4">
|
||||
<AssetTreePanel
|
||||
@@ -121,8 +195,25 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Объектов на карте: {visiblePositions.length}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rulerMode ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
onClick={toggleRuler}
|
||||
>
|
||||
<Ruler className="w-3.5 h-3.5 mr-1" />
|
||||
Линейка
|
||||
</Button>
|
||||
{rulerMode ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Объектов на карте: {visiblePositions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<GpsMap
|
||||
@@ -131,6 +222,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
zoom={view.zoom ?? 10}
|
||||
markers={mapMarkers}
|
||||
polygons={mapPolygons}
|
||||
polylines={mapPolylines}
|
||||
onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined}
|
||||
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
|
||||
/>
|
||||
|
||||
@@ -162,7 +255,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
Скорость:{' '}
|
||||
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
|
||||
</div>
|
||||
<div>Последняя точка: {formatDateTime(selectedLive.recordedAt)}</div>
|
||||
<div>Обновлено: {formatDateTime(seenAt)}</div>
|
||||
{recordedStale && <div>Точка от: {formatDateTime(recordedAt)}</div>}
|
||||
{selectedAsset?.externalId && <div>Трекер: {selectedAsset.externalId}</div>}
|
||||
</div>
|
||||
<Button size="sm" className="w-full h-8" onClick={() => onShowRoute(selectedLive.assetId)}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
||||
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
|
||||
@@ -35,6 +36,23 @@ function todayRange(): { from: string; to: string } {
|
||||
|
||||
const PLAY_SPEEDS = [1, 5, 20] as const;
|
||||
|
||||
/** Пороги скорости (км/ч) → цвет сегмента трека */
|
||||
const SPEED_STOPS: Array<{ max: number; color: string; label: string }> = [
|
||||
{ max: 30, color: '#22c55e', label: '<30' },
|
||||
{ max: 60, color: '#84cc16', label: '30–60' },
|
||||
{ max: 90, color: '#eab308', label: '60–90' },
|
||||
{ max: 110, color: '#f97316', label: '90–110' },
|
||||
{ max: 130, color: '#ef4444', label: '110–130' },
|
||||
{ max: Infinity, color: '#b91c1c', label: '>130' },
|
||||
];
|
||||
const SPEED_UNKNOWN_COLOR = '#94a3b8';
|
||||
|
||||
/** Цвет сегмента трека по скорости в точке (км/ч); null — серый */
|
||||
function speedColor(speed: number | null): string {
|
||||
if (speed == null) return SPEED_UNKNOWN_COLOR;
|
||||
return SPEED_STOPS.find((s) => speed < s.max)!.color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таб «Маршруты»: трек объекта за период — полилиния на карте,
|
||||
* статистика (пробег, время, скорость) и анимация воспроизведения.
|
||||
@@ -181,10 +199,46 @@ export function GpsRoutesTab() {
|
||||
return `<b>${time}</b><br/>Скорость: ${speed}${course}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`;
|
||||
};
|
||||
|
||||
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи
|
||||
// Раскраска трека по скорости (включена по умолчанию)
|
||||
const [colorize, setColorize] = useState(true);
|
||||
|
||||
// Сегменты трека, окрашенные по скорости в начальной точке сегмента.
|
||||
// При > 800 точек прореживаем до ~800 для раскраски (базовая полилиния
|
||||
// из всех точек остаётся под низом). 800 сегментов оба провайдера
|
||||
// тянут нормально; если Яндекс просядет — порог снизить до 400.
|
||||
const COLORIZE_POINTS_LIMIT = 800;
|
||||
const speedSegments: GpsMapPolyline[] = useMemo(() => {
|
||||
if (points.length < 2) return [];
|
||||
const step = Math.max(1, Math.ceil(points.length / COLORIZE_POINTS_LIMIT));
|
||||
const pts = step === 1 ? points : points.filter((_, idx) => idx % step === 0);
|
||||
const segments: GpsMapPolyline[] = [];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
segments.push({
|
||||
id: `seg-${i}`,
|
||||
points: [
|
||||
[pts[i - 1].lat, pts[i - 1].lng],
|
||||
[pts[i].lat, pts[i].lng],
|
||||
],
|
||||
color: speedColor(pts[i - 1].speed),
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
}, [points]);
|
||||
|
||||
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи.
|
||||
// При включённой раскраске: базовая полилиния из всех точек под низом,
|
||||
// сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек)
|
||||
const colorizeEnabled = colorize && points.length >= 2;
|
||||
const mapPolylines: GpsMapPolyline[] =
|
||||
trackCoords.length >= 2
|
||||
? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }]
|
||||
? [
|
||||
{
|
||||
id: 'track',
|
||||
points: trackCoords,
|
||||
color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6',
|
||||
},
|
||||
...(colorizeEnabled ? speedSegments : []),
|
||||
]
|
||||
: [];
|
||||
const mapMarkers: GpsMapMarker[] = [
|
||||
// Точки записи координат (стрелки курса с попапом; в кластер не включаем)
|
||||
@@ -235,6 +289,10 @@ export function GpsRoutesTab() {
|
||||
{isFetching && <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />}
|
||||
Показать
|
||||
</Button>
|
||||
<label className="flex items-center gap-2 pt-1 cursor-pointer">
|
||||
<Checkbox checked={colorize} onCheckedChange={(v) => setColorize(!!v)} />
|
||||
<span className="text-xs">Раскрасить по скорости</span>
|
||||
</label>
|
||||
</div>
|
||||
</AssetTreePanel>
|
||||
|
||||
@@ -246,6 +304,19 @@ export function GpsRoutesTab() {
|
||||
className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border"
|
||||
/>
|
||||
|
||||
{/* Легенда раскраски трека по скорости */}
|
||||
{colorizeEnabled && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-[10px] text-muted-foreground">
|
||||
{SPEED_STOPS.map((s) => (
|
||||
<span key={s.label} className="flex items-center gap-1">
|
||||
<span className="w-4 h-1.5 rounded-sm" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
<span>км/ч</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applied && !isFetching && points.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
За выбранный период точек нет
|
||||
|
||||
106
client/src/pages/gps/SettingsTab.tsx
Normal file
106
client/src/pages/gps/SettingsTab.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Save, SlidersHorizontal } from 'lucide-react';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface GpsSettings {
|
||||
success: boolean;
|
||||
jitterMeters: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таб «Настройки» GPS-модуля.
|
||||
* Секция «Трекинг»: фильтр дрожания позиции (джиттер) — сервер не записывает
|
||||
* точки ближе N метров при скорости < 5 км/ч. Сюда же позже добавятся
|
||||
* другие настройки модуля — структура секций-Card под это готова.
|
||||
*/
|
||||
export function GpsSettingsTab() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery<GpsSettings>({
|
||||
queryKey: ['/api/gps/settings'],
|
||||
});
|
||||
|
||||
const [jitterMeters, setJitterMeters] = useState('0');
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setJitterMeters(String(data.jitterMeters));
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const value = Number(jitterMeters);
|
||||
const res = await apiRequest('PUT', '/api/gps/settings', { jitterMeters: value });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Настройки сохранены' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Ошибка',
|
||||
description: err?.message || 'Не удалось сохранить настройки',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const jitterValid = /^\d+$/.test(jitterMeters.trim()) && Number(jitterMeters) >= 0 && Number(jitterMeters) <= 1000;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Трекинг
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Фильтр дрожания позиции, метров</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="h-8 w-32"
|
||||
value={jitterMeters}
|
||||
onChange={(e) => setJitterMeters(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
0 = выключен. Точки ближе N метров при скорости < 5 км/ч не записываются — убирает
|
||||
«кучу мелких перемещений» у стоящего объекта. Действует на новые точки.
|
||||
</p>
|
||||
{!jitterValid && (
|
||||
<p className="text-xs text-destructive">Целое число от 0 до 1000</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={saveMutation.isPending || !jitterValid}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Сохранить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLocation } from 'wouter';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Map, Truck, Hexagon, Route, Folder, BookOpen } from 'lucide-react';
|
||||
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings } from 'lucide-react';
|
||||
import Layout from '@/components/Layout';
|
||||
import { GpsMapTab } from './MapTab';
|
||||
import { GpsAssetsTab } from './AssetsTab';
|
||||
@@ -8,6 +8,7 @@ import { GpsGeozonesTab } from './GeozonesTab';
|
||||
import { GpsRoutesTab } from './RoutesTab';
|
||||
import { GpsGroupsTab } from './GroupsTab';
|
||||
import { GpsGuidesTab } from './GuidesTab';
|
||||
import { GpsSettingsTab } from './SettingsTab';
|
||||
|
||||
const TABS = [
|
||||
{ value: 'map', label: 'Карта', icon: Map },
|
||||
@@ -16,6 +17,7 @@ const TABS = [
|
||||
{ value: 'routes', label: 'Маршруты', icon: Route },
|
||||
{ value: 'groups', label: 'Группы', icon: Folder },
|
||||
{ value: 'guides', label: 'Инструкции', icon: BookOpen },
|
||||
{ value: 'settings', label: 'Настройки', icon: Settings },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,9 @@ export default function GpsPage() {
|
||||
<TabsContent value="guides">
|
||||
<GpsGuidesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<GpsSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface GpsAsset {
|
||||
lastSpeed: number | null;
|
||||
lastCourse: number | null;
|
||||
lastSeenAt: string | null;
|
||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||
lastRecordedAt?: string | null;
|
||||
createdAt: string | null;
|
||||
/** Группы объекта (добавляет GET /api/gps/assets) */
|
||||
groups?: Array<{ id: number; name: string }>;
|
||||
@@ -38,7 +40,12 @@ export interface GpsPosition {
|
||||
lng: number | null;
|
||||
speed: number | null;
|
||||
course: number | null;
|
||||
/** Время приёма последней точки сервером (= реальная активность объекта) */
|
||||
recordedAt: string | null;
|
||||
/** Время приёма последней точки сервером */
|
||||
lastSeenAt?: string | null;
|
||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||
lastRecordedAt?: string | null;
|
||||
}
|
||||
|
||||
/** Точка трека (GET /api/gps/assets/:id/track) */
|
||||
|
||||
Reference in New Issue
Block a user