diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 4df42b0..37e5be3 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -69,6 +69,8 @@ interface GpsMapProps { polylines?: GpsMapPolyline[]; /** Клик по карте (режим рисования геозон) */ onMapClick?: (lat: number, lng: number) => void; + /** Правый клик по карте (создание объекта с координатами) */ + onMapContextMenu?: (lat: number, lng: number) => void; className?: string; } @@ -122,7 +124,9 @@ export function GpsMap({ polygons, polylines, onMapClick, + onMapContextMenu, className, + }: GpsMapProps) { const { data: configData, isLoading: configLoading } = useGpsConfig(); const { toast } = useToast(); @@ -244,6 +248,7 @@ export function GpsMap({ polygons={polygons} polylines={polylines} onMapClick={onMapClick} + onMapContextMenu={onMapContextMenu} onReady={() => setYandexReady(true)} onError={(message) => triggerFallback(message)} className="h-full w-full" @@ -257,6 +262,7 @@ export function GpsMap({ polygons={polygons} polylines={polylines} onMapClick={onMapClick} + onMapContextMenu={onMapContextMenu} className="h-full w-full rounded-md overflow-hidden" /> )} diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx index c9b2a84..0d0e4e4 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -31,6 +31,8 @@ interface OsmMapProps { polygons?: GpsMapPolygon[]; polylines?: GpsMapPolyline[]; onMapClick?: (lat: number, lng: number) => void; + /** Правый клик по карте (создание объекта с координатами) */ + onMapContextMenu?: (lat: number, lng: number) => void; className?: string; } @@ -45,6 +47,7 @@ export function OsmMap({ polygons = [], polylines = [], onMapClick, + onMapContextMenu, className, }: OsmMapProps) { const containerRef = useRef(null); @@ -57,9 +60,11 @@ export function OsmMap({ // при смене идентичности массивов (react-query рефетчи) const markersByIdRef = useRef(new Map()); const onMapClickRef = useRef(onMapClick); + const onMapContextMenuRef = useRef(onMapContextMenu); useEffect(() => { markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); onMapClickRef.current = onMapClick; + onMapContextMenuRef.current = onMapContextMenu; }); // Создание карты (один раз) @@ -100,6 +105,10 @@ export function OsmMap({ map.on('click', (e: L.LeafletMouseEvent) => { onMapClickRef.current?.(e.latlng.lat, e.latlng.lng); }); + // Правый клик — создание объекта с координатами + map.on('contextmenu', (e: L.LeafletMouseEvent) => { + onMapContextMenuRef.current?.(e.latlng.lat, e.latlng.lng); + }); // Делегированный клик по пункту списка кластера в popup // (контент popup рендерится внутри контейнера карты) diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 95ab383..73b38eb 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -26,6 +26,8 @@ interface Yandex21MapProps { polygons?: GpsMapPolygon[]; polylines?: GpsMapPolyline[]; onMapClick?: (lat: number, lng: number) => void; + /** Правый клик по карте (создание объекта с координатами) */ + onMapContextMenu?: (lat: number, lng: number) => void; /** Карта создана и готова к работе */ onReady?: () => void; /** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */ @@ -44,7 +46,8 @@ export function Yandex21Map({ onMapClick, onReady, onError, - className, + className, onMapContextMenu, + }: Yandex21MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -57,6 +60,7 @@ export function Yandex21Map({ const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); const onMapClickRef = useRef(onMapClick); + const onMapContextMenuRef = useRef(onMapContextMenu); const placemarksRef = useRef(new Map()); const markersByIdRef = useRef(new Map()); useEffect(() => { @@ -64,6 +68,7 @@ export function Yandex21Map({ onErrorRef.current = onError; onMapClickRef.current = onMapClick; markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); + onMapContextMenuRef.current = onMapContextMenu; }); // Загрузка скрипта и создание карты (один раз на apiKey) @@ -86,6 +91,13 @@ export function Yandex21Map({ onMapClickRef.current?.(coords[0], coords[1]); }); + // Правый клик — создание объекта с координатами + map.events.add('contextmenu', (e: ymaps.IEvent) => { + if (!onMapContextMenuRef.current) return; + const coords = e.get('coords') as [number, number]; + onMapContextMenuRef.current(coords[0], coords[1]); + }); + const collection = new ymaps.GeoObjectCollection(); map.geoObjects.add(collection); diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index d0c8787..03524ff 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -2,7 +2,12 @@ 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 { X, Route, Ruler } from 'lucide-react'; +import { X, Route, Ruler, MapPinPlus } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useLocation } from 'wouter'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { CreateTaskDialog } from '@/components/CreateTaskDialog'; import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap'; import { AssetTreePanel } from '@/components/gps/AssetTreePanel'; import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; @@ -124,6 +129,93 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v return total; }, [rulerPoints]); + // === Слой задач формы: выбранная форма + её geo-поле → задачи на карте === + const [, setLocation] = useLocation(); + const [tasksLayer, setTasksLayer] = useState(false); + const [mapFormId, setMapFormId] = useState(null); + const [mapFieldId, setMapFieldId] = useState(null); + const [createAt, setCreateAt] = useState<{ lat: number; lng: number } | null>(null); + + const { data: formsData } = useQuery<{ success: boolean; forms: Array<{ id: number; name: string }> }>({ + queryKey: ['/api/forms'], + }); + const { data: fieldsData } = useQuery<{ + success: boolean; + fields: Array<{ id: number; name: string; type: string }>; + }>({ + queryKey: ['/api/forms', mapFormId, 'fields'], + enabled: mapFormId !== null, + }); + const geoFields = useMemo( + () => (fieldsData?.fields || []).filter((f) => f.type === 'geo'), + [fieldsData] + ); + // geo-поле по умолчанию — первое (при смене формы) + useEffect(() => { + if (mapFieldId === null || !geoFields.some((f) => f.id === mapFieldId)) { + setMapFieldId(geoFields[0]?.id ?? null); + } + }, [geoFields, mapFieldId]); + + const { data: tasksData } = useQuery<{ + success: boolean; + tasks: Array<{ + id: number; + title: string; + fieldValues: Record; + }>; + }>({ + queryKey: ['/api/forms', mapFormId, 'tasks', 'with-fields'], + queryFn: async () => (await apiRequest('GET', `/api/forms/${mapFormId}/tasks/with-fields`)).json(), + enabled: tasksLayer && mapFormId !== null, + }); + + /** Парсинг geo-значения поля: { address, lat, lng } или JSON-строка; lat/lng — строки */ + const parseGeo = (raw: unknown): { lat: number; lng: number } | null => { + if (raw == null) return null; + let obj = raw as { lat?: unknown; lng?: unknown }; + if (typeof raw === 'string') { + try { obj = JSON.parse(raw); } catch { return null; } + } + const lat = parseFloat(String(obj.lat)); + const lng = parseFloat(String(obj.lng)); + if (!isFinite(lat) || !isFinite(lng)) return null; + return { lat, lng }; + }; + + const taskMarkers: GpsMapMarker[] = useMemo(() => { + if (!tasksLayer || mapFieldId === null) return []; + const markers: GpsMapMarker[] = []; + for (const t of tasksData?.tasks ?? []) { + const geo = parseGeo(t.fieldValues?.[mapFieldId]); + if (!geo) continue; + markers.push({ + id: `task-${t.id}`, + lat: geo.lat, + lng: geo.lng, + kind: 'dot', + color: '#8b5cf6', + label: t.title, + noCluster: true, + zIndex: 40, + onClick: rulerMode ? undefined : () => setLocation(`/forms/${mapFormId}/tasks/${t.id}`), + }); + } + return markers; + }, [tasksLayer, mapFieldId, tasksData, rulerMode, setLocation, mapFormId]); + + // Правый клик по карте — создать объект выбранной формы с этими координатами + const handleMapContextMenu = (lat: number, lng: number) => { + if (!tasksLayer || mapFormId === null || mapFieldId === null) { + toast({ + title: 'Выберите форму в слое «Объекты»', + description: 'Тогда правый клик по карте создаст объект с этими координатами.', + }); + return; + } + setCreateAt({ lat, lng }); + }; + // Клик по объекту в дереве: центрируем карту и открываем попап; // объект без координат — только подсказка const handleSelectAsset = (asset: GpsAsset) => { @@ -154,6 +246,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v approximate: p.lastIsLbs === true, onClick: rulerMode ? undefined : () => setSelected(p), })), + ...taskMarkers, ...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({ id: `ruler-${idx}`, lat, @@ -207,6 +300,50 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v Линейка + + {tasksLayer && ( + <> + + {geoFields.length > 1 && ( + + )} + + Правый клик по карте — новый объект здесь + + + )} {rulerMode ? ( Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход @@ -226,6 +363,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v polygons={mapPolygons} polylines={mapPolylines} onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined} + onMapContextMenu={handleMapContextMenu} className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border" /> @@ -277,6 +415,23 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v )} + + {/* Диалог создания объекта по правому клику на карте */} + {mapFormId !== null && mapFieldId !== null && ( + { if (!open) setCreateAt(null); }} + initialValues={ + createAt + ? { [`customField_${mapFieldId}`]: { lat: createAt.lat, lng: createAt.lng } } + : undefined + } + onCreated={() => { + queryClient.invalidateQueries({ queryKey: ['/api/forms', mapFormId, 'tasks', 'with-fields'] }); + }} + /> + )} ); }