feat(gps): фронт индикатора потери GPS — бейдж «Нет GPS» в объектах, настройка порога в часах
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
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 { apiRequest } from '@/lib/queryClient';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { invalidateGps, useGpsAssetSubscribers, useGpsAssets, useOrgUsers } from '@/hooks/useGps';
|
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');
|
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 {
|
function userLabel(u: { firstName?: string; lastName?: string; email: string; fullName?: string }): string {
|
||||||
return u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email;
|
return u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email;
|
||||||
}
|
}
|
||||||
@@ -50,6 +56,13 @@ const EMPTY_FORM: AssetFormState = {
|
|||||||
export function GpsAssetsTab() {
|
export function GpsAssetsTab() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data, isLoading } = useGpsAssets();
|
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 { data: usersData } = useOrgUsers();
|
||||||
const assets = data?.assets || [];
|
const assets = data?.assets || [];
|
||||||
const users = usersData?.users || [];
|
const users = usersData?.users || [];
|
||||||
@@ -173,6 +186,15 @@ export function GpsAssetsTab() {
|
|||||||
style={{ backgroundColor: asset.isOnline ? '#22c55e' : '#9ca3af' }}
|
style={{ backgroundColor: asset.isOnline ? '#22c55e' : '#9ca3af' }}
|
||||||
/>
|
/>
|
||||||
{asset.isOnline ? 'Онлайн' : 'Офлайн'}
|
{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>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>
|
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface GpsSettings {
|
|||||||
jitterMeters: number;
|
jitterMeters: number;
|
||||||
maxJumpSpeedKmh: number;
|
maxJumpSpeedKmh: number;
|
||||||
tripStopMinutes: number;
|
tripStopMinutes: number;
|
||||||
|
gpsLostHours: number;
|
||||||
defaultLat: number | null;
|
defaultLat: number | null;
|
||||||
defaultLng: number | null;
|
defaultLng: number | null;
|
||||||
defaultZoom: number | null;
|
defaultZoom: number | null;
|
||||||
@@ -43,12 +44,14 @@ export function GpsSettingsTab() {
|
|||||||
const [jitterMeters, setJitterMeters] = useState('0');
|
const [jitterMeters, setJitterMeters] = useState('0');
|
||||||
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
|
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
|
||||||
const [tripStopMinutes, setTripStopMinutes] = useState('10');
|
const [tripStopMinutes, setTripStopMinutes] = useState('10');
|
||||||
|
const [gpsLostHours, setGpsLostHours] = useState('5');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setJitterMeters(String(data.jitterMeters));
|
setJitterMeters(String(data.jitterMeters));
|
||||||
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
|
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
|
||||||
setTripStopMinutes(String(data.tripStopMinutes));
|
setTripStopMinutes(String(data.tripStopMinutes));
|
||||||
|
setGpsLostHours(String(data.gpsLostHours));
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ export function GpsSettingsTab() {
|
|||||||
jitterMeters: Number(jitterMeters),
|
jitterMeters: Number(jitterMeters),
|
||||||
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
|
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
|
||||||
tripStopMinutes: Number(tripStopMinutes),
|
tripStopMinutes: Number(tripStopMinutes),
|
||||||
|
gpsLostHours: Number(gpsLostHours),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
@@ -80,6 +84,7 @@ export function GpsSettingsTab() {
|
|||||||
const jitterValid = isIntInRange(jitterMeters, 1000);
|
const jitterValid = isIntInRange(jitterMeters, 1000);
|
||||||
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
|
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
|
||||||
const tripValid = isIntInRange(tripStopMinutes, 120);
|
const tripValid = isIntInRange(tripStopMinutes, 120);
|
||||||
|
const gpsLostValid = /^\d+$/.test(gpsLostHours.trim()) && Number(gpsLostHours) >= 1 && Number(gpsLostHours) <= 72;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-4">
|
<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>}
|
{!jumpValid && <p className="text-xs text-destructive">Целое число от 0 до 5000</p>}
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Остановка — конец поездки, минут</Label>
|
<Label className="text-xs">Остановка — конец поездки, минут</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -148,7 +170,7 @@ export function GpsSettingsTab() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid}
|
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid || !gpsLostValid}
|
||||||
onClick={() => saveTrackingMutation.mutate()}
|
onClick={() => saveTrackingMutation.mutate()}
|
||||||
>
|
>
|
||||||
{saveTrackingMutation.isPending ? (
|
{saveTrackingMutation.isPending ? (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface GpsAsset {
|
|||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||||
lastRecordedAt?: string | null;
|
lastRecordedAt?: string | null;
|
||||||
|
lastGpsFixAt?: string | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
/** Группы объекта (добавляет GET /api/gps/assets) */
|
/** Группы объекта (добавляет GET /api/gps/assets) */
|
||||||
groups?: Array<{ id: number; name: string }>;
|
groups?: Array<{ id: number; name: string }>;
|
||||||
@@ -46,6 +47,7 @@ export interface GpsPosition {
|
|||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||||
lastRecordedAt?: string | null;
|
lastRecordedAt?: string | null;
|
||||||
|
lastGpsFixAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Точка трека (GET /api/gps/assets/:id/track) */
|
/** Точка трека (GET /api/gps/assets/:id/track) */
|
||||||
|
|||||||
Reference in New Issue
Block a user