diff --git a/client/src/App.tsx b/client/src/App.tsx index 091b0c5..420492c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import { useToast } from "@/hooks/use-toast"; import { usePrefetchOfflineTasks } from "@/hooks/usePrefetchOfflineTasks"; import { useEvents } from "@/hooks/useEvents"; import { usePwaFontSize } from "@/hooks/usePwaFontSize"; +import { useGpsTracking } from "@/hooks/useGpsTracking"; import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary"; import { Loader2 } from "lucide-react"; @@ -78,6 +79,7 @@ const DashboardPage = safeLazy(() => import("@/pages/DashboardPage")); const CalendarPage = safeLazy(() => import("@/pages/CalendarPage")); const GanttPage = safeLazy(() => import("@/pages/GanttPage")); const MedSchedulePage = safeLazy(() => import("@/pages/medschedule")); +const GpsPage = safeLazy(() => import("@/pages/gps")); const ShareTarget = safeLazy(() => import("@/pages/ShareTarget")); const QrHandler = safeLazy(() => import("@/pages/QrHandler")); const NotFound = safeLazy(() => import("@/pages/not-found")); @@ -98,6 +100,7 @@ const ROUTE_TITLES: Array<[RegExp, string]> = [ [/^\/calendar/, 'Календарь'], [/^\/gantt/, 'Гантт'], [/^\/med/, 'MedSchedule'], + [/^\/gps/, 'GPS-мониторинг'], [/^\/chat/, 'Чат'], [/^\/users/, 'Пользователи'], [/^\/forms\/[^/]+\/tasks/, 'Задачи'], @@ -172,6 +175,8 @@ function Router() { return ( + + @@ -214,6 +219,12 @@ function PrefetchWrapper({ children }: { children: React.ReactNode }) { return <>{children}; } +/** Глобальный GPS-трекинг: шлёт геолокацию пользователя, если включено в профиле */ +function GpsTrackingRunner() { + useGpsTracking(); + return null; +} + function GlobalEventListener() { useEvents({ onNotification: useCallback(async () => { @@ -312,6 +323,7 @@ function App() { + {auth.isAuthenticated && } diff --git a/client/src/components/MobileBottomNav.tsx b/client/src/components/MobileBottomNav.tsx index 8fb1905..509bbcd 100644 --- a/client/src/components/MobileBottomNav.tsx +++ b/client/src/components/MobileBottomNav.tsx @@ -17,7 +17,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank } from 'lucide-react'; +import { Bookmark, User, Folder, Menu, FileText, Table2, CheckSquare, BarChart3, Layers, Users, Settings, MessageSquare, Home, Download, Smartphone, PiggyBank, MapPin } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useBookmarkFolders, useBookmarksByFolder } from '@/hooks/useBookmarks'; import { CreateBookmarkModal } from '@/components/CreateBookmarkModal'; @@ -381,6 +381,12 @@ export function MobileBottomNav() { )} + + + + GPS-мониторинг + + diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 3a410c5..e648057 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -59,6 +59,7 @@ import { LayoutDashboard, ChartGantt, HeartPulse, + MapPin, } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/use-theme'; @@ -1267,6 +1268,43 @@ function GanttNavItem({ collapsed }: { collapsed: boolean }) { ); } +function GpsNavItem({ collapsed }: { collapsed: boolean }) { + const [location] = useLocation(); + const isActive = location === '/gps' || location.startsWith('/gps/'); + + if (collapsed) { + return ( + + + +
+ +
+ +
+ + GPS-мониторинг + +
+ ); + } + + return ( + +
+ + GPS +
+ + ); +} + function MedScheduleNavItem({ collapsed }: { collapsed: boolean }) { const { user } = useAuth(); const [location] = useLocation(); @@ -1491,6 +1529,7 @@ export function Sidebar({ children }: SidebarProps) { + diff --git a/client/src/components/gps/YandexMap.tsx b/client/src/components/gps/YandexMap.tsx new file mode 100644 index 0000000..0b61aba --- /dev/null +++ b/client/src/components/gps/YandexMap.tsx @@ -0,0 +1,136 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, MapPin } from 'lucide-react'; +import type { DomEventHandler } from '@yandex/ymaps3-types/imperative/YMapListener'; +import { loadYandexMaps, type YmapsReactModule } from './ymaps-loader'; + +/** [долгота, широта] — порядок координат Яндекс.Карт JS API 3.0 (LngLat) */ +export type LngLat = [number, number]; +/** Прямоугольник карты: [[lng, lat], [lng, lat]] (юго-запад, северо-восток) */ +export type LngLatBounds = [LngLat, LngLat]; + +/** Контекст с reactify-модулем ymaps3 — дочерние компоненты (маркеры, фичи) берут из него YMapMarker/YMapFeature */ +const YmapsContext = createContext(null); + +/** Доступ к react-компонентам ymaps3 внутри ; null, пока скрипт не загружен */ +export function useYmaps(): YmapsReactModule | null { + return useContext(YmapsContext); +} + +interface YandexMapProps { + /** API-ключ Яндекс.Карт; null — ключ не настроен, показываем заглушку */ + apiKey?: string | null; + /** Центр [lng, lat]; по умолчанию — Москва */ + center?: LngLat; + zoom?: number; + /** Границы для автоподгонки; приоритетнее center/zoom */ + bounds?: LngLatBounds; + /** Клик по карте (используется для рисования геозон) */ + onMapClick?: (coordinates: LngLat) => void; + className?: string; + children?: React.ReactNode; +} + +const MOSCOW: LngLat = [37.617698, 55.755864]; + +/** + * Обёртка над Яндекс.Картами JS API 3.0. + * Загружает скрипт один раз (singleton в ymaps-loader), пока грузится — скелетон, + * без ключа — заглушка с инструкцией. + */ +export function YandexMap({ + apiKey, + center, + zoom = 10, + bounds, + onMapClick, + className, + children, +}: YandexMapProps) { + const [ymaps, setYmaps] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!apiKey) return; + let cancelled = false; + setError(null); + loadYandexMaps(apiKey) + .then((module) => { + if (!cancelled) setYmaps(module); + }) + .catch((err) => { + console.error('[GPS] Ошибка загрузки Яндекс.Карт:', err); + if (!cancelled) setError('Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.'); + }); + return () => { + cancelled = true; + }; + }, [apiKey]); + + // Стабильная location: пересоздаём объект только при реальном изменении, + // чтобы рефетчи react-query не сбрасывали viewport, который двигает пользователь + const locationKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`; + const location = useMemo( + () => (bounds ? { bounds } : { center: center ?? MOSCOW, zoom }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [locationKey] + ); + + // Колбэк клика через ref, чтобы не пересоздавать YMapListener + const onMapClickRef = useRef(onMapClick); + useEffect(() => { + onMapClickRef.current = onMapClick; + }); + + const handleMapClick: DomEventHandler = (_object, event) => { + const c = event.coordinates; + onMapClickRef.current?.([c[0], c[1]]); + }; + + if (!apiKey) { + return ( +
+
+ +

+ Укажите API-ключ Яндекс.Карт в настройках (Боты → Доступы к сервисам → «Яндекс.Карты (для модуля GPS)»). +

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (!ymaps) { + return ( +
+
+ +
+
+ ); + } + + const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapListener } = ymaps; + + return ( +
+ + + + + + {children} + + +
+ ); +} diff --git a/client/src/components/gps/map-features.tsx b/client/src/components/gps/map-features.tsx new file mode 100644 index 0000000..13b8fa4 --- /dev/null +++ b/client/src/components/gps/map-features.tsx @@ -0,0 +1,165 @@ +import { Car, User } from 'lucide-react'; +import { useYmaps, type LngLat } from './YandexMap'; + +/** + * Общие элементы карты GPS-модуля: маркер объекта, полигон геозоны, полилиния трека. + * Все компоненты должны рендериться внутри (берут ymaps3 из контекста). + */ + +const DEFAULT_COLOR = '#3b82f6'; + +/** DOM-содержимое маркера объекта: цветной кружок с иконкой + подпись */ +function AssetMarkerContent({ + name, + type, + color, + isOnline, +}: { + name: string; + type: 'vehicle' | 'person'; + color?: string | null; + isOnline?: boolean | null; +}) { + const Icon = type === 'person' ? User : Car; + return ( +
+
+ +
+
+ {name} +
+
+ ); +} + +/** Маркер объекта на карте */ +export function GpsAssetMarker({ + coordinates, + name, + type, + color, + isOnline, + zIndex, + onClick, +}: { + coordinates: LngLat; + name: string; + type: 'vehicle' | 'person'; + color?: string | null; + isOnline?: boolean | null; + zIndex?: number; + onClick?: () => void; +}) { + const ymaps = useYmaps(); + if (!ymaps) return null; + const { YMapMarker } = ymaps; + return ( + onClick() : undefined} + > + + + ); +} + +/** Простой круглый маркер (старт/финиш трека, точка воспроизведения) */ +export function GpsDotMarker({ + coordinates, + color, + label, + zIndex, +}: { + coordinates: LngLat; + color: string; + label?: string; + zIndex?: number; +}) { + const ymaps = useYmaps(); + if (!ymaps) return null; + const { YMapMarker } = ymaps; + return ( + +
+
+ {label && ( +
+ {label} +
+ )} +
+ + ); +} + +/** Полигон геозоны (polygon хранится как пары [lat, lng] — конвертируем в LngLat) */ +export function GpsGeozonePolygon({ + polygon, + color, + fillOpacity = 0.15, +}: { + polygon: Array<[number, number]>; + color?: string | null; + fillOpacity?: number; +}) { + const ymaps = useYmaps(); + if (!ymaps || polygon.length < 3) return null; + const { YMapFeature } = ymaps; + const c = color || DEFAULT_COLOR; + return ( + [lng, lat] as LngLat)], + }} + style={{ + fill: c, + fillOpacity, + stroke: [{ color: c, width: 2 }], + }} + /> + ); +} + +/** Полилиния трека/маршрута (points — пары [lat, lng]) */ +export function GpsPolyline({ + points, + color = DEFAULT_COLOR, + width = 4, + dashed = false, +}: { + points: Array<[number, number]>; + color?: string; + width?: number; + dashed?: boolean; +}) { + const ymaps = useYmaps(); + if (!ymaps || points.length < 2) return null; + const { YMapFeature } = ymaps; + return ( + [lng, lat] as LngLat), + }} + style={{ + stroke: [{ color, width, ...(dashed ? { dash: [8, 6] } : {}) }], + }} + /> + ); +} diff --git a/client/src/components/gps/ymaps-loader.ts b/client/src/components/gps/ymaps-loader.ts new file mode 100644 index 0000000..1be6cd8 --- /dev/null +++ b/client/src/components/gps/ymaps-loader.ts @@ -0,0 +1,69 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +// Типы JS API 3.0: подключают глобальный `ymaps3` и типы reactify-модуля. +// Пакет types-only, в рантайме скрипт грузится с api-maps.yandex.ru. +import type { ReactifiedModule } from '@yandex/ymaps3-types/reactify'; + +/** + * Реакт-обёртка над ymaps3: результат reactify.module(ymaps3). + * Содержит YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, + * YMapMarker, YMapFeature, YMapListener и т.д. как React-компоненты. + */ +export type YmapsReactModule = ReactifiedModule; + +let loadPromise: Promise | null = null; +let loadedApiKey: string | null = null; + +/** + * Загружает Яндекс.Карты JS API 3.0 и возвращает reactify-модуль (singleton). + * Скрипт подключается один раз на страницу; при повторных вызовах + * с тем же ключом возвращается тот же промис. + * + * Официальная схема (https://yandex.ru/dev/jsapi30/): + *