feat(gps): фокус геозоны на карте, город по умолчанию для всех карт, доступ к GPS по пользователям/ролям (админ)
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 метров при скорости < 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user