feat(gps): левая панель-дерево объектов вместо select'ов; fix(csp): разрешён api-maps.yandex.ru для JS API 2.1
This commit is contained in:
193
client/src/components/gps/AssetTreePanel.tsx
Normal file
193
client/src/components/gps/AssetTreePanel.tsx
Normal file
@@ -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<Set<number>>(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 (
|
||||||
|
<button
|
||||||
|
key={asset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectAsset?.(asset)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-1.5 px-2 py-1 rounded text-xs text-left transition-colors',
|
||||||
|
selectedAssetId === asset.id
|
||||||
|
? 'bg-muted text-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0 border border-border"
|
||||||
|
style={{ backgroundColor: asset.color || '#3b82f6' }}
|
||||||
|
/>
|
||||||
|
<TypeIcon className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate flex-1">{asset.name}</span>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: asset.isOnline ? '#22c55e' : '#9ca3af' }}
|
||||||
|
title={asset.isOnline ? 'Онлайн' : 'Офлайн'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardContent className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
{onSelectGroup && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={selectedGroupId === null ? 'default' : 'outline'}
|
||||||
|
className="h-6 text-[11px] px-2"
|
||||||
|
onClick={() => onSelectGroup(null)}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
placeholder="Поиск объекта…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground py-4 text-center">
|
||||||
|
Объектов нет. Добавьте их на вкладке «Объекты».
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-[50vh] overflow-y-auto">
|
||||||
|
{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 (
|
||||||
|
<div key={group.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded transition-colors',
|
||||||
|
selectedGroupId === group.id ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => toggleGroup(group.id)}
|
||||||
|
title={expanded ? 'Свернуть' : 'Развернуть'}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-left py-1.5 pr-2 text-xs truncate',
|
||||||
|
selectedGroupId === group.id ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => (onSelectGroup ? onSelectGroup(group.id) : toggleGroup(group.id))}
|
||||||
|
title={group.name}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">({groupAssets.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && <div className="ml-4 space-y-0.5">{groupAssets.map(renderAsset)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Объекты без группы — в самом низу панели */}
|
||||||
|
{ungroupedAssets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{visibleGroups.length > 0 && (
|
||||||
|
<div className="px-2 pt-1.5 pb-0.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Без группы
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5">{ungroupedAssets.map(renderAsset)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query && visibleGroups.every((g) => g.assetIds.map((id) => assetsById.get(id)).filter((a) => a && matches(a)).length === 0) && ungroupedAssets.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground py-3 text-center">Ничего не найдено</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,30 +2,38 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { X, Route } from 'lucide-react';
|
import { X, Route } from 'lucide-react';
|
||||||
import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap';
|
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 { 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 {
|
function formatDateTime(value: string | null): string {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
return new Date(value).toLocaleString('ru-RU');
|
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 }) {
|
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
|
||||||
|
const { toast } = useToast();
|
||||||
const { data: assetsData } = useGpsAssets();
|
const { data: assetsData } = useGpsAssets();
|
||||||
const { data: positionsData } = useGpsPositions();
|
const { data: positionsData } = useGpsPositions();
|
||||||
const { data: geozonesData } = useGpsGeozones();
|
const { data: geozonesData } = useGpsGeozones();
|
||||||
const { data: groupsData } = useGpsGroups();
|
const { data: groupsData } = useGpsGroups();
|
||||||
useGpsLiveUpdates();
|
useGpsLiveUpdates();
|
||||||
|
|
||||||
const [groupFilter, setGroupFilter] = useState<string>('all');
|
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||||
const [selected, setSelected] = useState<GpsPosition | null>(null);
|
const [selected, setSelected] = useState<GpsPosition | null>(null);
|
||||||
|
|
||||||
const assets = assetsData?.assets || [];
|
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 geozones = (geozonesData?.geozones || []).filter((g) => g.isActive !== false);
|
||||||
const groups = groupsData?.groups || [];
|
const groups = groupsData?.groups || [];
|
||||||
|
|
||||||
// Фильтр по группе: принадлежность объекта группам берём из списка assets
|
// Фильтр маркеров по выбранной в дереве группе
|
||||||
const groupAssetIds = useMemo(() => {
|
const groupAssetIds = useMemo(() => {
|
||||||
if (groupFilter === 'all') return null;
|
if (selectedGroupId === null) return null;
|
||||||
const group = groups.find((g) => String(g.id) === groupFilter);
|
const group = groups.find((g) => g.id === selectedGroupId);
|
||||||
return group ? new Set(group.assetIds) : new Set<number>();
|
return group ? new Set(group.assetIds) : new Set<number>();
|
||||||
}, [groupFilter, groups]);
|
}, [selectedGroupId, groups]);
|
||||||
|
|
||||||
const visiblePositions = useMemo(
|
const visiblePositions = useMemo(
|
||||||
() => (groupAssetIds ? positions.filter((p) => groupAssetIds.has(p.assetId)) : positions),
|
() => (groupAssetIds ? positions.filter((p) => groupAssetIds.has(p.assetId)) : positions),
|
||||||
[positions, groupAssetIds]
|
[positions, groupAssetIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам)
|
// Вид карты: при первой загрузке позиций — автоподгонка bounds,
|
||||||
const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null);
|
// при выборе объекта в дереве — центрирование на нём
|
||||||
|
const [view, setView] = useState<MapView>({});
|
||||||
useEffect(() => {
|
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 lats = positions.map((p) => p.lat as number);
|
||||||
const lngs = positions.map((p) => p.lng as number);
|
const lngs = positions.map((p) => p.lng as number);
|
||||||
const pad = 0.01;
|
const pad = 0.01;
|
||||||
setBounds([
|
setView({
|
||||||
|
bounds: [
|
||||||
[Math.min(...lats) - pad, Math.min(...lngs) - pad],
|
[Math.min(...lats) - pad, Math.min(...lngs) - pad],
|
||||||
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
|
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
|
||||||
]);
|
],
|
||||||
}, [positions, bounds]);
|
});
|
||||||
|
}, [positions, view]);
|
||||||
|
|
||||||
// Если выбранный объект пропал из позиций (стал неактивным) — закрываем панель
|
// Если выбранный объект пропал из позиций (стал неактивным) — закрываем панель
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +76,18 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
|||||||
}
|
}
|
||||||
}, [positions, selected]);
|
}, [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 selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null;
|
||||||
const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null;
|
const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null;
|
||||||
|
|
||||||
@@ -87,29 +110,25 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-4">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<AssetTreePanel
|
||||||
<Select value={groupFilter} onValueChange={setGroupFilter}>
|
assets={assets}
|
||||||
<SelectTrigger className="w-56 h-8 text-xs">
|
groups={groups}
|
||||||
<SelectValue placeholder="Все группы" />
|
selectedGroupId={selectedGroupId}
|
||||||
</SelectTrigger>
|
selectedAssetId={selected?.assetId ?? null}
|
||||||
<SelectContent>
|
onSelectGroup={setSelectedGroupId}
|
||||||
<SelectItem value="all">Все группы</SelectItem>
|
onSelectAsset={handleSelectAsset}
|
||||||
{groups.map((g) => (
|
/>
|
||||||
<SelectItem key={g.id} value={String(g.id)}>
|
|
||||||
{g.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Объектов на карте: {visiblePositions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Объектов на карте: {visiblePositions.length}
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<GpsMap
|
<GpsMap
|
||||||
bounds={bounds ?? undefined}
|
bounds={view.bounds}
|
||||||
|
center={view.center}
|
||||||
|
zoom={view.zoom ?? 10}
|
||||||
markers={mapMarkers}
|
markers={mapMarkers}
|
||||||
polygons={mapPolygons}
|
polygons={mapPolygons}
|
||||||
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
|
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
|
||||||
@@ -154,5 +173,6 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ 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';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Loader2, Pause, Play } from 'lucide-react';
|
import { Loader2, Pause, Play } from 'lucide-react';
|
||||||
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
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';
|
import type { GpsTrackPoint } from './types';
|
||||||
|
|
||||||
/** Расстояние между двумя точками в км (формула гаверсинуса) */
|
/** Расстояние между двумя точками в км (формула гаверсинуса) */
|
||||||
@@ -41,7 +41,9 @@ const PLAY_SPEEDS = [1, 5, 20] as const;
|
|||||||
*/
|
*/
|
||||||
export function GpsRoutesTab() {
|
export function GpsRoutesTab() {
|
||||||
const { data: assetsData } = useGpsAssets();
|
const { data: assetsData } = useGpsAssets();
|
||||||
|
const { data: groupsData } = useGpsGroups();
|
||||||
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
|
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
|
||||||
|
const groups = groupsData?.groups || [];
|
||||||
|
|
||||||
// Предвыбор объекта из query (?assetId=) — переход «Маршрут за сегодня» с карты
|
// Предвыбор объекта из query (?assetId=) — переход «Маршрут за сегодня» с карты
|
||||||
const search = useSearch();
|
const search = useSearch();
|
||||||
@@ -181,34 +183,26 @@ export function GpsRoutesTab() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-4">
|
||||||
<div className="flex items-end gap-2 flex-wrap">
|
{/* Левая панель: дерево объектов + период + кнопка «Показать» */}
|
||||||
<div className="space-y-1">
|
<AssetTreePanel
|
||||||
<Label className="text-xs">Объект</Label>
|
assets={assets}
|
||||||
<Select value={assetId} onValueChange={setAssetId}>
|
groups={groups}
|
||||||
<SelectTrigger className="w-56 h-8 text-xs">
|
selectedAssetId={assetId ? Number(assetId) : null}
|
||||||
<SelectValue placeholder="Выберите объект" />
|
onSelectAsset={(a) => setAssetId(String(a.id))}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
{assets.map((a) => (
|
|
||||||
<SelectItem key={a.id} value={String(a.id)}>
|
|
||||||
{a.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">С</Label>
|
<Label className="text-xs">С</Label>
|
||||||
<Input type="datetime-local" className="w-auto h-8 text-xs" value={from} onChange={(e) => setFrom(e.target.value)} />
|
<Input type="datetime-local" className="w-full h-8 text-xs" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">По</Label>
|
<Label className="text-xs">По</Label>
|
||||||
<Input type="datetime-local" className="w-auto h-8 text-xs" value={to} onChange={(e) => setTo(e.target.value)} />
|
<Input type="datetime-local" className="w-full h-8 text-xs" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="w-full h-8"
|
||||||
disabled={!assetId || !from || !to || isFetching}
|
disabled={!assetId || !from || !to || isFetching}
|
||||||
onClick={() => setApplied({ assetId: Number(assetId), from, to })}
|
onClick={() => setApplied({ assetId: Number(assetId), from, to })}
|
||||||
>
|
>
|
||||||
@@ -216,7 +210,9 @@ export function GpsRoutesTab() {
|
|||||||
Показать
|
Показать
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</AssetTreePanel>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
<GpsMap
|
<GpsMap
|
||||||
bounds={bounds}
|
bounds={bounds}
|
||||||
polylines={mapPolylines}
|
polylines={mapPolylines}
|
||||||
@@ -291,5 +287,6 @@ export function GpsRoutesTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ app.use(helmet({
|
|||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
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"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||||
imgSrc: ["'self'", "data:", "blob:", "https:", "http:"],
|
imgSrc: ["'self'", "data:", "blob:", "https:", "http:"],
|
||||||
connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],
|
connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],
|
||||||
|
|||||||
Reference in New Issue
Block a user