diff --git a/client/src/components/gps/AssetTreePanel.tsx b/client/src/components/gps/AssetTreePanel.tsx new file mode 100644 index 0000000..a97049c --- /dev/null +++ b/client/src/components/gps/AssetTreePanel.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Car, ChevronDown, ChevronRight, Search, User } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { GpsAsset, GpsGroup } from '@/pages/gps/types'; + +/** + * Левая панель-дерево объектов GPS-модуля (общая для табов «Карта» и «Маршруты»). + * Группы — раскрывающиеся узлы с объектами внутри; объекты без группы — внизу + * панели. Поиск фильтрует объекты по имени (группы с совпадениями автораскрыты). + */ + +interface AssetTreePanelProps { + assets: GpsAsset[]; + groups: GpsGroup[]; + /** Фильтр по группе (null — все). Без onSelectGroup выбор группы недоступен */ + selectedGroupId?: number | null; + /** Выбранный объект (подсветка строки) */ + selectedAssetId?: number | null; + onSelectGroup?: (groupId: number | null) => void; + onSelectAsset?: (asset: GpsAsset) => void; + showSearch?: boolean; + title?: string; + className?: string; + /** Дополнительный контент под деревом (например, контролы периода в «Маршрутах») */ + children?: React.ReactNode; +} + +export function AssetTreePanel({ + assets, + groups, + selectedGroupId = null, + selectedAssetId = null, + onSelectGroup, + onSelectAsset, + showSearch = true, + title = 'Объекты', + className, + children, +}: AssetTreePanelProps) { + const [search, setSearch] = useState(''); + const [collapsed, setCollapsed] = useState>(new Set()); + const query = search.trim().toLowerCase(); + + const matches = (a: GpsAsset) => !query || a.name.toLowerCase().includes(query); + const assetsById = new Map(assets.map((a) => [a.id, a])); + + // При выбранной группе дерево показывает только её (сброс — кнопка «Все») + const visibleGroups = selectedGroupId ? groups.filter((g) => g.id === selectedGroupId) : groups; + + const groupedAssetIds = new Set(groups.flatMap((g) => g.assetIds)); + const ungroupedAssets = selectedGroupId ? [] : assets.filter((a) => !groupedAssetIds.has(a.id) && matches(a)); + + const isExpanded = (groupId: number) => (query ? true : !collapsed.has(groupId)); + + const toggleGroup = (groupId: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + }; + + const renderAsset = (asset: GpsAsset) => { + const TypeIcon = asset.type === 'person' ? User : Car; + return ( + + ); + }; + + return ( + + +
+

{title}

+ {onSelectGroup && ( + + )} +
+ + {showSearch && ( +
+ + setSearch(e.target.value)} + /> +
+ )} + + {assets.length === 0 ? ( +

+ Объектов нет. Добавьте их на вкладке «Объекты». +

+ ) : ( +
+ {visibleGroups.map((group) => { + const groupAssets = group.assetIds + .map((id) => assetsById.get(id)) + .filter((a): a is GpsAsset => !!a && matches(a)); + if (groupAssets.length === 0 && query) return null; + const expanded = isExpanded(group.id); + return ( +
+
+ + +
+ {expanded &&
{groupAssets.map(renderAsset)}
} +
+ ); + })} + + {/* Объекты без группы — в самом низу панели */} + {ungroupedAssets.length > 0 && ( +
+ {visibleGroups.length > 0 && ( +
+ Без группы +
+ )} +
{ungroupedAssets.map(renderAsset)}
+
+ )} + + {query && visibleGroups.every((g) => g.assetIds.map((id) => assetsById.get(id)).filter((a) => a && matches(a)).length === 0) && ungroupedAssets.length === 0 && ( +

Ничего не найдено

+ )} +
+ )} + + {children} +
+
+ ); +} diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index efe5198..46a4fb6 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -2,30 +2,38 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { X, Route } from 'lucide-react'; import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap'; +import { AssetTreePanel } from '@/components/gps/AssetTreePanel'; import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; -import type { GpsPosition } from './types'; +import { useToast } from '@/hooks/use-toast'; +import type { GpsAsset, GpsPosition } from './types'; function formatDateTime(value: string | null): string { if (!value) return '—'; return new Date(value).toLocaleString('ru-RU'); } +/** Вид карты: либо автоподгонка по bounds, либо центрирование на точке */ +interface MapView { + bounds?: [[number, number], [number, number]]; + center?: [number, number]; + zoom?: number; +} + /** - * Таб «Карта»: текущие позиции всех активных объектов, геозоны, - * фильтр по группе, попап объекта с переходом к маршруту за сегодня. - * Провайдер карты (Яндекс/OSM) выбирается внутри GpsMap. + * Таб «Карта»: слева панель-дерево объектов (группы/поиск/фильтр), + * справа — текущие позиции на карте, геозоны, попап объекта. */ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) { + const { toast } = useToast(); const { data: assetsData } = useGpsAssets(); const { data: positionsData } = useGpsPositions(); const { data: geozonesData } = useGpsGeozones(); const { data: groupsData } = useGpsGroups(); useGpsLiveUpdates(); - const [groupFilter, setGroupFilter] = useState('all'); + const [selectedGroupId, setSelectedGroupId] = useState(null); const [selected, setSelected] = useState(null); const assets = assetsData?.assets || []; @@ -33,30 +41,33 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v const geozones = (geozonesData?.geozones || []).filter((g) => g.isActive !== false); const groups = groupsData?.groups || []; - // Фильтр по группе: принадлежность объекта группам берём из списка assets + // Фильтр маркеров по выбранной в дереве группе const groupAssetIds = useMemo(() => { - if (groupFilter === 'all') return null; - const group = groups.find((g) => String(g.id) === groupFilter); + if (selectedGroupId === null) return null; + const group = groups.find((g) => g.id === selectedGroupId); return group ? new Set(group.assetIds) : new Set(); - }, [groupFilter, groups]); + }, [selectedGroupId, groups]); const visiblePositions = useMemo( () => (groupAssetIds ? positions.filter((p) => groupAssetIds.has(p.assetId)) : positions), [positions, groupAssetIds] ); - // Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам) - const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); + // Вид карты: при первой загрузке позиций — автоподгонка bounds, + // при выборе объекта в дереве — центрирование на нём + const [view, setView] = useState({}); useEffect(() => { - if (bounds || positions.length === 0) return; + if (view.bounds || view.center || positions.length === 0) return; const lats = positions.map((p) => p.lat as number); const lngs = positions.map((p) => p.lng as number); const pad = 0.01; - setBounds([ - [Math.min(...lats) - pad, Math.min(...lngs) - pad], - [Math.max(...lats) + pad, Math.max(...lngs) + pad], - ]); - }, [positions, bounds]); + setView({ + bounds: [ + [Math.min(...lats) - pad, Math.min(...lngs) - pad], + [Math.max(...lats) + pad, Math.max(...lngs) + pad], + ], + }); + }, [positions, view]); // Если выбранный объект пропал из позиций (стал неактивным) — закрываем панель useEffect(() => { @@ -65,6 +76,18 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v } }, [positions, selected]); + // Клик по объекту в дереве: центрируем карту и открываем попап; + // объект без координат — только подсказка + const handleSelectAsset = (asset: GpsAsset) => { + const pos = positions.find((p) => p.assetId === asset.id); + if (!pos) { + toast({ title: 'У объекта пока нет координат', description: asset.name }); + return; + } + setSelected(pos); + setView({ center: [pos.lat as number, pos.lng as number], zoom: 15 }); + }; + const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null; const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null; @@ -87,71 +110,68 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v })); return ( -
-
- - +
+ + +
+
Объектов на карте: {visiblePositions.length} - -
+
+
+ -
- - - {/* Панель выбранного объекта поверх карты */} - {selectedLive && ( - -
-
-
{selectedLive.name}
-
- - {selectedLive.type === 'person' ? 'Человек' : 'Транспорт'} - - - - {selectedLive.isOnline ? 'Онлайн' : 'Офлайн'} - + {/* Панель выбранного объекта поверх карты */} + {selectedLive && ( + +
+
+
{selectedLive.name}
+
+ + {selectedLive.type === 'person' ? 'Человек' : 'Транспорт'} + + + + {selectedLive.isOnline ? 'Онлайн' : 'Офлайн'} + +
+
- -
-
-
- Скорость:{' '} - {selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'} -
-
Последняя точка: {formatDateTime(selectedLive.recordedAt)}
- {selectedAsset?.externalId &&
Трекер: {selectedAsset.externalId}
} -
- - - )} + + )} +
); diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index 34ec526..cd54052 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -4,10 +4,10 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Loader2, Pause, Play } from 'lucide-react'; import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap'; -import { useGpsAssets, useGpsTrack } from '@/hooks/useGps'; +import { AssetTreePanel } from '@/components/gps/AssetTreePanel'; +import { useGpsAssets, useGpsGroups, useGpsTrack } from '@/hooks/useGps'; import type { GpsTrackPoint } from './types'; /** Расстояние между двумя точками в км (формула гаверсинуса) */ @@ -41,7 +41,9 @@ const PLAY_SPEEDS = [1, 5, 20] as const; */ export function GpsRoutesTab() { const { data: assetsData } = useGpsAssets(); + const { data: groupsData } = useGpsGroups(); const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false); + const groups = groupsData?.groups || []; // Предвыбор объекта из query (?assetId=) — переход «Маршрут за сегодня» с карты const search = useSearch(); @@ -181,48 +183,42 @@ export function GpsRoutesTab() { ]; return ( -
-
-
- - +
+ {/* Левая панель: дерево объектов + период + кнопка «Показать» */} + setAssetId(String(a.id))} + > +
+
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+
-
- - setFrom(e.target.value)} /> -
-
- - setTo(e.target.value)} /> -
- -
+ - +
+ {applied && !isFetching && points.length === 0 && (

@@ -290,6 +286,7 @@ export function GpsRoutesTab() { )} +

); } diff --git a/server/index.ts b/server/index.ts index 532cf49..d321921 100644 --- a/server/index.ts +++ b/server/index.ts @@ -233,7 +233,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:", "https://api-maps.yandex.ru", "https://yastatic.net"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:", "blob:", "https:", "http:"], connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],