feat(gps): фронт индикатора потери GPS — бейдж «Нет GPS» в объектах, настройка порога в часах

This commit is contained in:
2026-08-28 10:53:34 +03:00
parent 1af4126f7c
commit 3ea0f8c187
3 changed files with 49 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Pencil, Plus, Trash2, BellRing } from 'lucide-react';
import { Loader2, Pencil, Plus, Trash2, BellRing, Satellite } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { invalidateGps, useGpsAssetSubscribers, useGpsAssets, useOrgUsers } from '@/hooks/useGps';
@@ -21,6 +21,12 @@ function formatDateTime(value: string | null): string {
return new Date(value).toLocaleString('ru-RU');
}
/** Потерян ли GPS: последний фикс старше порога (часов) */
function isGpsLost(lastGpsFixAt: string | null | undefined, thresholdHours: number): boolean {
if (!lastGpsFixAt) return false; // данных ещё не было — не пугаем
return Date.now() - new Date(lastGpsFixAt).getTime() > thresholdHours * 3600_000;
}
function userLabel(u: { firstName?: string; lastName?: string; email: string; fullName?: string }): string {
return u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email;
}
@@ -50,6 +56,13 @@ const EMPTY_FORM: AssetFormState = {
export function GpsAssetsTab() {
const { toast } = useToast();
const { data, isLoading } = useGpsAssets();
// Порог потери GPS (часов) из настроек организации
const { data: gpsSettings } = useQuery<{ gpsLostHours?: number }>({
queryKey: ['/api/gps/settings'],
queryFn: async () => (await apiRequest('GET', '/api/gps/settings')).json(),
staleTime: 60_000,
});
const gpsLostHours = gpsSettings?.gpsLostHours ?? 5;
const { data: usersData } = useOrgUsers();
const assets = data?.assets || [];
const users = usersData?.users || [];
@@ -173,6 +186,15 @@ export function GpsAssetsTab() {
style={{ backgroundColor: asset.isOnline ? '#22c55e' : '#9ca3af' }}
/>
{asset.isOnline ? 'Онлайн' : 'Офлайн'}
{isGpsLost(asset.lastGpsFixAt, gpsLostHours) && (
<span
className="inline-flex items-center gap-0.5 px-1 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300"
title={`Нет GPS-фикса дольше ${gpsLostHours} ч. Последний фикс: ${formatDateTime(asset.lastGpsFixAt ?? null)}`}
>
<Satellite className="w-3 h-3" />
Нет GPS
</span>
)}
</span>
</TableCell>
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>

View File

@@ -15,6 +15,7 @@ interface GpsSettings {
jitterMeters: number;
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
@@ -43,12 +44,14 @@ export function GpsSettingsTab() {
const [jitterMeters, setJitterMeters] = useState('0');
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
const [tripStopMinutes, setTripStopMinutes] = useState('10');
const [gpsLostHours, setGpsLostHours] = useState('5');
useEffect(() => {
if (data) {
setJitterMeters(String(data.jitterMeters));
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
setTripStopMinutes(String(data.tripStopMinutes));
setGpsLostHours(String(data.gpsLostHours));
}
}, [data]);
@@ -64,6 +67,7 @@ export function GpsSettingsTab() {
jitterMeters: Number(jitterMeters),
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
tripStopMinutes: Number(tripStopMinutes),
gpsLostHours: Number(gpsLostHours),
});
return res.json();
},
@@ -80,6 +84,7 @@ export function GpsSettingsTab() {
const jitterValid = isIntInRange(jitterMeters, 1000);
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
const tripValid = isIntInRange(tripStopMinutes, 120);
const gpsLostValid = /^\d+$/.test(gpsLostHours.trim()) && Number(gpsLostHours) >= 1 && Number(gpsLostHours) <= 72;
return (
<div className="max-w-2xl space-y-4">
@@ -129,6 +134,23 @@ export function GpsSettingsTab() {
{!jumpValid && <p className="text-xs text-destructive">Целое число от 0 до 5000</p>}
</div>
<div className="space-y-1">
<Label className="text-xs">Потеря GPS, часов</Label>
<Input
type="text"
inputMode="numeric"
className="h-8 w-32"
value={gpsLostHours}
onChange={(e) => setGpsLostHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Если трекер не видел спутники дольше N часов (шлёт только старый фикс
или позицию по вышкам), на вкладке «Объекты» показывается предупреждение
«Нет GPS» с временем последнего фикса.
</p>
{!gpsLostValid && <p className="text-xs text-destructive">Целое число от 1 до 72</p>}
</div>
<div className="space-y-1">
<Label className="text-xs">Остановка — конец поездки, минут</Label>
<Input
@@ -148,7 +170,7 @@ export function GpsSettingsTab() {
<Button
size="sm"
className="h-8"
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid}
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid || !gpsLostValid}
onClick={() => saveTrackingMutation.mutate()}
>
{saveTrackingMutation.isPending ? (

View File

@@ -22,6 +22,7 @@ export interface GpsAsset {
lastSeenAt: string | null;
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
lastGpsFixAt?: string | null;
createdAt: string | null;
/** Группы объекта (добавляет GET /api/gps/assets) */
groups?: Array<{ id: number; name: string }>;
@@ -46,6 +47,7 @@ export interface GpsPosition {
lastSeenAt?: string | null;
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
lastGpsFixAt?: string | null;
}
/** Точка трека (GET /api/gps/assets/:id/track) */