feat(gps): фокус геозоны на карте, город по умолчанию для всех карт, доступ к GPS по пользователям/ролям (админ)

This commit is contained in:
2026-08-26 10:20:38 +03:00
parent 3e8849019a
commit 8f5789ec28
15 changed files with 718 additions and 122 deletions

View File

@@ -125,6 +125,13 @@ export function MobileBottomNav() {
const inboxCount = useInboxCount();
// Прячем GPS, если у пользователя нет доступа к модулю
const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({
queryKey: ['/api/gps/config'],
staleTime: 5 * 60_000,
});
const gpsAllowed = gpsConfig?.allowed !== false;
useEvents({
onConvMessageCreated: useCallback(() => {
queryClient.setQueryData<{ success: boolean; unreadCount: number }>(['/api/messenger/unread-count'], (old) => {
@@ -381,12 +388,14 @@ export function MobileBottomNav() {
</DropdownMenuItem>
</Link>
)}
<Link href="/gps">
<DropdownMenuItem className="text-xs">
<MapPin className="w-3.5 h-3.5 mr-2" />
GPS-мониторинг
</DropdownMenuItem>
</Link>
{gpsAllowed && (
<Link href="/gps">
<DropdownMenuItem className="text-xs">
<MapPin className="w-3.5 h-3.5 mr-2" />
GPS-мониторинг
</DropdownMenuItem>
</Link>
)}
<Link href="/settings">
<DropdownMenuItem className="text-xs">
<Settings className="w-3.5 h-3.5 mr-2" />

View File

@@ -1271,6 +1271,13 @@ function GanttNavItem({ collapsed }: { collapsed: boolean }) {
function GpsNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/gps' || location.startsWith('/gps/');
// Прячем пункт, если у пользователя нет доступа к GPS-модулю
const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({
queryKey: ['/api/gps/config'],
staleTime: 5 * 60_000,
});
if (gpsConfig && gpsConfig.allowed === false) return null;
if (collapsed) {
return (

View File

@@ -42,6 +42,8 @@ export interface GpsMapPolygon {
points: Array<[number, number]>;
color?: string | null;
fillOpacity?: number;
/** Толщина обводки (по умолчанию 2; выбранная геозона — толще) */
strokeWidth?: number;
}
export interface GpsMapPolyline {
@@ -110,7 +112,7 @@ function readProviderPref(): ProviderPref {
export function GpsMap({
center,
zoom = 10,
zoom,
bounds,
markers,
polygons,
@@ -122,6 +124,13 @@ export function GpsMap({
const { toast } = useToast();
const apiKey = configData?.yandexMapsKey ?? null;
// Центр по умолчанию из настроек организации (город по умолчанию);
// используется, только если таб не передал явный center/bounds
const defaultCenter = configData?.defaultCenter ?? null;
const effectiveCenter: [number, number] | undefined =
center ?? (defaultCenter ? [defaultCenter.lat, defaultCenter.lng] : undefined);
const effectiveZoom = zoom ?? defaultCenter?.zoom ?? 10;
// Ручной выбор подложки: 'auto' | 'yandex' | 'osm', хранится в localStorage
const [pref, setPrefState] = useState<ProviderPref>(readProviderPref);
const prefRef = useRef(pref);
@@ -224,8 +233,8 @@ export function GpsMap({
{provider === 'yandex' ? (
<Yandex21Map
apiKey={apiKey!}
center={center}
zoom={zoom}
center={effectiveCenter}
zoom={effectiveZoom}
bounds={bounds}
markers={markers}
polygons={polygons}
@@ -237,8 +246,8 @@ export function GpsMap({
/>
) : (
<OsmMap
center={center}
zoom={zoom}
center={effectiveCenter}
zoom={effectiveZoom}
bounds={bounds}
markers={markers}
polygons={polygons}

View File

@@ -185,7 +185,7 @@ export function OsmMap({
// Полигоны и полилинии
const featuresKey = JSON.stringify([
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]),
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
]);
useEffect(() => {
@@ -197,7 +197,7 @@ export function OsmMap({
const color = p.color || DEFAULT_COLOR;
L.polygon(p.points, {
color,
weight: 2,
weight: p.strokeWidth ?? 2,
fillColor: color,
fillOpacity: p.fillOpacity ?? 0.15,
}).addTo(layer);

View File

@@ -190,7 +190,7 @@ export function Yandex21Map({
// колбэки кликов не входят в ключ — они читаются из ref в момент клика
const dataKey = JSON.stringify([
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]),
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]),
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
]);
useEffect(() => {
@@ -208,7 +208,7 @@ export function Yandex21Map({
fillColor: color,
fillOpacity: p.fillOpacity ?? 0.15,
strokeColor: color,
strokeWidth: 2,
strokeWidth: p.strokeWidth ?? 2,
})
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
import { invalidateGps, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps';
import { cn } from '@/lib/utils';
import type { GpsGeozone } from './types';
function formatDateTime(value: string): string {
@@ -137,10 +138,41 @@ export function GpsGeozonesTab() {
setEditing(g);
};
// Фокус геозоны: клик по зоне в списке — карта подгоняет bounds под полигон,
// зона выделяется (ярче/толще), остальные приглушены; повторный клик — снятие
const [focusedZoneId, setFocusedZoneId] = useState<number | null>(null);
const focusedBounds = useMemo((): [[number, number], [number, number]] | undefined => {
if (focusedZoneId === null) return undefined;
const zone = geozones.find((g) => g.id === focusedZoneId);
if (!zone || zone.polygon.length === 0) return undefined;
const lats = zone.polygon.map(([lat]) => lat);
const lngs = zone.polygon.map(([, lng]) => lng);
const pad = 0.002;
return [
[Math.min(...lats) - pad, Math.min(...lngs) - pad],
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
];
}, [focusedZoneId, geozones]);
const toggleZoneFocus = (id: number) => {
setFocusedZoneId((prev) => (prev === id ? null : id));
};
// Данные для провайдер-агностичной карты: существующие зоны, строящийся
// полигон (замкнутая заливка при 3+ точках, иначе пунктирная линия), вершины
const mapPolygons: GpsMapPolygon[] = [
...geozones.map((g): GpsMapPolygon => ({ id: g.id, points: g.polygon, color: g.color })),
...geozones.map((g): GpsMapPolygon => {
const focused = focusedZoneId === g.id;
const dimmed = focusedZoneId !== null && !focused;
return {
id: g.id,
points: g.polygon,
color: g.color,
fillOpacity: focused ? 0.3 : dimmed ? 0.06 : 0.15,
strokeWidth: focused ? 3 : dimmed ? 1 : 2,
};
}),
...(draft.length >= 3
? [{ id: 'draft', points: draft, color: '#f97316', fillOpacity: 0.25 }]
: []),
@@ -189,7 +221,13 @@ export function GpsGeozonesTab() {
{geozones.map((g) => (
<div
key={g.id}
className={`border border-border rounded px-2.5 py-2 ${g.isActive === false ? 'opacity-50' : ''}`}
className={cn(
'border border-border rounded px-2.5 py-2 cursor-pointer transition-colors hover:bg-muted/50',
g.isActive === false && 'opacity-50',
focusedZoneId === g.id && 'bg-muted border-foreground/30'
)}
onClick={() => toggleZoneFocus(g.id)}
title="Показать на карте"
>
<div className="flex items-center gap-2">
<span
@@ -197,14 +235,23 @@ export function GpsGeozonesTab() {
style={{ backgroundColor: g.color || '#f97316' }}
/>
<span className="text-xs font-medium flex-1 truncate">{g.name}</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEdit(g)}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
openEdit(g);
}}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Удалить геозону «${g.name}»?`)) {
deleteMutation.mutate(g.id);
}
@@ -254,6 +301,7 @@ export function GpsGeozonesTab() {
)}
<GpsMap
className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border"
bounds={focusedBounds}
onMapClick={drawing ? (lat, lng) => setDraft((d) => [...d, [lat, lng]]) : undefined}
polygons={mapPolygons}
polylines={mapPolylines}

View File

@@ -219,7 +219,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
<GpsMap
bounds={view.bounds}
center={view.center}
zoom={view.zoom ?? 10}
zoom={view.zoom}
markers={mapMarkers}
polygons={mapPolygons}
polylines={mapPolylines}

View File

@@ -1,28 +1,44 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } 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 { Checkbox } from '@/components/ui/checkbox';
import { Loader2, MapPin, Save, SlidersHorizontal, ShieldCheck, X } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { isAdmin } from '@/lib/permissions';
interface GpsSettings {
success: boolean;
jitterMeters: number;
maxJumpSpeedKmh: number;
tripStopMinutes: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
allowedUserIds: number[] | null;
allowedRoleIds: number[] | null;
}
interface GeoSuggestion {
value: string;
data: { geo_lat: string | null; geo_lon: string | null };
}
/**
* Таб «Настройки» GPS-модуля.
* Секция «Трекинг»: фильтры входящих точек — дрожание позиции (джиттер)
* и максимальный скачок скорости (LBS-прыжки). Сюда же позже добавятся
* другие настройки модуля — структура секций-Card под это готова.
* Секции: «Трекинг» (фильтры точек — всем с доступом), «Карта» (город
* по умолчанию — только admin), «Доступ к GPS-модулю» (только admin).
*/
export function GpsSettingsTab() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdminUser = isAdmin(user);
const { data, isLoading } = useQuery<GpsSettings>({
queryKey: ['/api/gps/settings'],
});
@@ -39,9 +55,14 @@ export function GpsSettingsTab() {
}
}, [data]);
const saveMutation = useMutation({
const invalidateSettings = () => {
queryClient.invalidateQueries({ queryKey: ['/api/gps/settings'] });
queryClient.invalidateQueries({ queryKey: ['/api/gps/config'] });
};
const saveTrackingMutation = useMutation({
mutationFn: async () => {
// Все три поля сохраняются одним PUT (непереданные на сервере сохраняются)
// Непереданные поля сервер сохраняет (обновляются только переданные)
const res = await apiRequest('PUT', '/api/gps/settings', {
jitterMeters: Number(jitterMeters),
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
@@ -51,13 +72,10 @@ export function GpsSettingsTab() {
},
onSuccess: () => {
toast({ title: 'Настройки сохранены' });
invalidateSettings();
},
onError: (err: any) => {
toast({
title: 'Ошибка',
description: err?.message || 'Не удалось сохранить настройки',
variant: 'destructive',
});
toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить настройки', variant: 'destructive' });
},
});
@@ -95,9 +113,7 @@ export function GpsSettingsTab() {
0 = выключен. Точки ближе N метров при скорости &lt; 5 км/ч не записываются — убирает
«кучу мелких перемещений» у стоящего объекта. Действует на новые точки.
</p>
{!jitterValid && (
<p className="text-xs text-destructive">Целое число от 0 до 1000</p>
)}
{!jitterValid && <p className="text-xs text-destructive">Целое число от 0 до 1000</p>}
</div>
<div className="space-y-1">
@@ -113,9 +129,7 @@ export function GpsSettingsTab() {
0 = выключен. Точки, требующие перемещения быстрее N км/ч от предыдущей,
отбрасываются — отсекает LBS-прыжки на вышки при потере GPS. Действует на новые точки.
</p>
{!jumpValid && (
<p className="text-xs text-destructive">Целое число от 0 до 5000</p>
)}
{!jumpValid && <p className="text-xs text-destructive">Целое число от 0 до 5000</p>}
</div>
<div className="space-y-1">
@@ -131,18 +145,16 @@ export function GpsSettingsTab() {
0 = не делить на поездки. Перерыв в точках дольше N минут при стоянке разбивает
маршрут дня на отдельные поездки на вкладке «Маршруты».
</p>
{!tripValid && (
<p className="text-xs text-destructive">Целое число от 0 до 120</p>
)}
{!tripValid && <p className="text-xs text-destructive">Целое число от 0 до 120</p>}
</div>
<Button
size="sm"
className="h-8"
disabled={saveMutation.isPending || !jitterValid || !jumpValid || !tripValid}
onClick={() => saveMutation.mutate()}
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid}
onClick={() => saveTrackingMutation.mutate()}
>
{saveMutation.isPending ? (
{saveTrackingMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<Save className="w-3.5 h-3.5 mr-1" />
@@ -153,6 +165,297 @@ export function GpsSettingsTab() {
)}
</CardContent>
</Card>
{/* Город по умолчанию — только admin меняет */}
<DefaultCityCard
settings={data}
isLoading={isLoading}
isAdminUser={isAdminUser}
onSaved={invalidateSettings}
/>
{/* Доступ к модулю — только admin */}
{isAdminUser && <AccessCard settings={data} onSaved={invalidateSettings} />}
</div>
);
}
/** Card «Карта»: город по умолчанию (центр всех карт модуля) */
function DefaultCityCard({
settings,
isLoading,
isAdminUser,
onSaved,
}: {
settings: GpsSettings | undefined;
isLoading: boolean;
isAdminUser: boolean;
onSaved: () => void;
}) {
const { toast } = useToast();
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<GeoSuggestion[]>([]);
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Подсказки адресов через серверный геокодинг (debounce 300 мс)
const handleQueryChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!value || value.length < 3) {
setSuggestions([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
try {
const res = await apiRequest('POST', '/api/geo/suggest', { query: value, count: 5 });
const data = await res.json();
const list: GeoSuggestion[] = data.suggestions || [];
setSuggestions(list);
setOpen(list.length > 0);
} catch {
setSuggestions([]);
setOpen(false);
}
}, 300);
};
const saveMutation = useMutation({
mutationFn: async (center: { lat: number; lng: number; zoom: number } | null) => {
// Все три поля вместе: либо координаты, либо null (очистка)
const res = await apiRequest('PUT', '/api/gps/settings', {
defaultLat: center?.lat ?? null,
defaultLng: center?.lng ?? null,
defaultZoom: center?.zoom ?? null,
});
return res.json();
},
onSuccess: (_d, center) => {
toast({ title: center ? 'Город по умолчанию сохранён' : 'Город по умолчанию очищен' });
setQuery('');
setSuggestions([]);
setOpen(false);
onSaved();
},
onError: (err: any) => {
toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить', variant: 'destructive' });
},
});
const selectSuggestion = (s: GeoSuggestion) => {
const lat = Number(s.data.geo_lat);
const lng = Number(s.data.geo_lon);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
toast({ title: 'У адреса нет координат', variant: 'destructive' });
return;
}
setQuery(s.value);
setOpen(false);
saveMutation.mutate({ lat, lng, zoom: 11 });
};
const hasCenter = settings?.defaultLat != null && settings?.defaultLng != null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<MapPin 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>
) : (
<>
{hasCenter && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
Текущий центр: {settings!.defaultLat!.toFixed(5)}, {settings!.defaultLng!.toFixed(5)} (zoom{' '}
{settings!.defaultZoom})
</span>
{isAdminUser && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[11px] px-2"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(null)}
>
<X className="w-3 h-3 mr-1" />
Очистить
</Button>
)}
</div>
)}
<div className="space-y-1 relative">
<Label className="text-xs">Город по умолчанию</Label>
<Input
className="h-8"
placeholder={isAdminUser ? 'Начните вводить город…' : 'Не задан'}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
disabled={!isAdminUser || saveMutation.isPending}
/>
{!isAdminUser && (
<p className="text-xs text-muted-foreground">Меняет администратор</p>
)}
{isAdminUser && open && suggestions.length > 0 && (
<div className="absolute z-10 top-full mt-1 w-full rounded-md border border-border bg-popover shadow-md">
{suggestions.map((s, idx) => (
<button
key={idx}
type="button"
className="w-full text-left px-2.5 py-1.5 text-xs hover:bg-muted transition-colors"
onClick={() => selectSuggestion(s)}
>
{s.value}
</button>
))}
</div>
)}
{isAdminUser && (
<p className="text-xs text-muted-foreground">
Выберите адрес из подсказок — карты модуля будут открываться на нём (zoom 11)
</p>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
/** Card «Доступ к GPS-модулю» (только admin): пользователи и роли */
function AccessCard({ settings, onSaved }: { settings: GpsSettings | undefined; onSaved: () => void }) {
const { toast } = useToast();
const [selectedUsers, setSelectedUsers] = useState<Set<number> | null>(null);
const [selectedRoles, setSelectedRoles] = useState<Set<number> | null>(null);
// Инициализация из настроек (один раз при загрузке)
useEffect(() => {
if (settings && selectedUsers === null) {
setSelectedUsers(new Set(settings.allowedUserIds ?? []));
setSelectedRoles(new Set(settings.allowedRoleIds ?? []));
}
}, [settings, selectedUsers]);
const { data: usersData } = useQuery<{ success: boolean; users: Array<{ id: number; firstName?: string; lastName?: string; email: string; fullName?: string }> }>({
queryKey: ['/api/users'],
});
const { data: rolesData } = useQuery<{ success: boolean; roles: Array<{ id: number; name: string }> }>({
queryKey: ['/api/roles'],
});
const users = usersData?.users || [];
const roles = rolesData?.roles || [];
const toggle = (set: Set<number>, id: number): Set<number> => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
};
const saveMutation = useMutation({
mutationFn: async () => {
const userIds = [...(selectedUsers ?? [])];
const roleIds = [...(selectedRoles ?? [])];
// Семантика бэкенда: оба списка пустые = доступ у всех → шлём null/null
const res = await apiRequest('PUT', '/api/gps/settings', {
allowedUserIds: userIds.length > 0 ? userIds : null,
allowedRoleIds: roleIds.length > 0 ? roleIds : null,
});
return res.json();
},
onSuccess: () => {
toast({ title: 'Доступ сохранён' });
onSaved();
},
onError: (err: any) => {
toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить доступ', variant: 'destructive' });
},
});
const userLabel = (u: { firstName?: string; lastName?: string; email: string; fullName?: string }) =>
u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email;
if (selectedUsers === null || selectedRoles === null) {
return (
<Card>
<CardContent className="p-4 flex items-center justify-center h-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
Доступ к GPS-модулю
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Пусто = доступ у всех. Если выбраны пользователи или роли — модуль виден только им
(администраторы видят всегда).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Пользователи</Label>
<div className="max-h-44 overflow-y-auto border border-border rounded p-2 space-y-1">
{users.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет пользователей</p>
) : (
users.map((u) => (
<label key={u.id} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={selectedUsers.has(u.id)}
onCheckedChange={() => setSelectedUsers(toggle(selectedUsers, u.id))}
/>
<span className="text-xs truncate">{userLabel(u)}</span>
</label>
))
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Роли</Label>
<div className="max-h-44 overflow-y-auto border border-border rounded p-2 space-y-1">
{roles.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет ролей</p>
) : (
roles.map((r) => (
<label key={r.id} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={selectedRoles.has(r.id)}
onCheckedChange={() => setSelectedRoles(toggle(selectedRoles, r.id))}
/>
<span className="text-xs truncate">{r.name}</span>
</label>
))
)}
</div>
</div>
</div>
<Button size="sm" className="h-8" disabled={saveMutation.isPending} 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>
);
}

View File

@@ -1,7 +1,9 @@
import { useLocation } from 'wouter';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings, ShieldAlert, Loader2 } from 'lucide-react';
import Layout from '@/components/Layout';
import { useGpsConfig } from '@/hooks/useGps';
import { GpsMapTab } from './MapTab';
import { GpsAssetsTab } from './AssetsTab';
import { GpsGeozonesTab } from './GeozonesTab';
@@ -29,8 +31,39 @@ export default function GpsPage() {
const match = location.match(/\/gps\/(.*)/);
const activeTab = TABS.some((t) => t.value === match?.[1]) ? match![1] : 'map';
// Гейтинг доступа: config открыт всем, allowed=false — заглушка вместо модуля
const { data: configData, isLoading: configLoading } = useGpsConfig();
const onTabChange = (value: string) => setLocation(value === 'map' ? '/gps' : `/gps/${value}`);
if (configLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Layout>
);
}
if (configData && configData.allowed === false) {
return (
<Layout>
<div className="container mx-auto p-4 max-w-md">
<Card>
<CardContent className="p-8 text-center space-y-3">
<ShieldAlert className="h-10 w-10 mx-auto text-muted-foreground" />
<p className="font-medium">Нет доступа к GPS-модулю</p>
<p className="text-sm text-muted-foreground">
Доступ выдаёт администратор в настройках модуля.
</p>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container mx-auto p-4 max-w-7xl">

View File

@@ -117,4 +117,8 @@ export interface GpsSubscriber {
export interface GpsConfig {
success: boolean;
yandexMapsKey: string | null;
/** false — у пользователя нет доступа к GPS-модулю (гейтинг UI) */
allowed: boolean;
/** Центр карты по умолчанию (задаётся админом в настройках GPS) */
defaultCenter: { lat: number; lng: number; zoom: number } | null;
}