feat(gps): на вкладке «Карта» слой задач формы (селектор формы+geo-поля) + создание объекта правым кликом с координатами

This commit is contained in:
2026-09-22 20:59:54 +03:00
parent fe57e368da
commit 42894aaf8f
4 changed files with 184 additions and 2 deletions

View File

@@ -69,6 +69,8 @@ interface GpsMapProps {
polylines?: GpsMapPolyline[]; polylines?: GpsMapPolyline[];
/** Клик по карте (режим рисования геозон) */ /** Клик по карте (режим рисования геозон) */
onMapClick?: (lat: number, lng: number) => void; onMapClick?: (lat: number, lng: number) => void;
/** Правый клик по карте (создание объекта с координатами) */
onMapContextMenu?: (lat: number, lng: number) => void;
className?: string; className?: string;
} }
@@ -122,7 +124,9 @@ export function GpsMap({
polygons, polygons,
polylines, polylines,
onMapClick, onMapClick,
onMapContextMenu,
className, className,
}: GpsMapProps) { }: GpsMapProps) {
const { data: configData, isLoading: configLoading } = useGpsConfig(); const { data: configData, isLoading: configLoading } = useGpsConfig();
const { toast } = useToast(); const { toast } = useToast();
@@ -244,6 +248,7 @@ export function GpsMap({
polygons={polygons} polygons={polygons}
polylines={polylines} polylines={polylines}
onMapClick={onMapClick} onMapClick={onMapClick}
onMapContextMenu={onMapContextMenu}
onReady={() => setYandexReady(true)} onReady={() => setYandexReady(true)}
onError={(message) => triggerFallback(message)} onError={(message) => triggerFallback(message)}
className="h-full w-full" className="h-full w-full"
@@ -257,6 +262,7 @@ export function GpsMap({
polygons={polygons} polygons={polygons}
polylines={polylines} polylines={polylines}
onMapClick={onMapClick} onMapClick={onMapClick}
onMapContextMenu={onMapContextMenu}
className="h-full w-full rounded-md overflow-hidden" className="h-full w-full rounded-md overflow-hidden"
/> />
)} )}

View File

@@ -31,6 +31,8 @@ interface OsmMapProps {
polygons?: GpsMapPolygon[]; polygons?: GpsMapPolygon[];
polylines?: GpsMapPolyline[]; polylines?: GpsMapPolyline[];
onMapClick?: (lat: number, lng: number) => void; onMapClick?: (lat: number, lng: number) => void;
/** Правый клик по карте (создание объекта с координатами) */
onMapContextMenu?: (lat: number, lng: number) => void;
className?: string; className?: string;
} }
@@ -45,6 +47,7 @@ export function OsmMap({
polygons = [], polygons = [],
polylines = [], polylines = [],
onMapClick, onMapClick,
onMapContextMenu,
className, className,
}: OsmMapProps) { }: OsmMapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -57,9 +60,11 @@ export function OsmMap({
// при смене идентичности массивов (react-query рефетчи) // при смене идентичности массивов (react-query рефетчи)
const markersByIdRef = useRef(new Map<string, GpsMapMarker>()); const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
const onMapClickRef = useRef(onMapClick); const onMapClickRef = useRef(onMapClick);
const onMapContextMenuRef = useRef(onMapContextMenu);
useEffect(() => { useEffect(() => {
markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m]));
onMapClickRef.current = onMapClick; onMapClickRef.current = onMapClick;
onMapContextMenuRef.current = onMapContextMenu;
}); });
// Создание карты (один раз) // Создание карты (один раз)
@@ -100,6 +105,10 @@ export function OsmMap({
map.on('click', (e: L.LeafletMouseEvent) => { map.on('click', (e: L.LeafletMouseEvent) => {
onMapClickRef.current?.(e.latlng.lat, e.latlng.lng); onMapClickRef.current?.(e.latlng.lat, e.latlng.lng);
}); });
// Правый клик — создание объекта с координатами
map.on('contextmenu', (e: L.LeafletMouseEvent) => {
onMapContextMenuRef.current?.(e.latlng.lat, e.latlng.lng);
});
// Делегированный клик по пункту списка кластера в popup // Делегированный клик по пункту списка кластера в popup
// (контент popup рендерится внутри контейнера карты) // (контент popup рендерится внутри контейнера карты)

View File

@@ -26,6 +26,8 @@ interface Yandex21MapProps {
polygons?: GpsMapPolygon[]; polygons?: GpsMapPolygon[];
polylines?: GpsMapPolyline[]; polylines?: GpsMapPolyline[];
onMapClick?: (lat: number, lng: number) => void; onMapClick?: (lat: number, lng: number) => void;
/** Правый клик по карте (создание объекта с координатами) */
onMapContextMenu?: (lat: number, lng: number) => void;
/** Карта создана и готова к работе */ /** Карта создана и готова к работе */
onReady?: () => void; onReady?: () => void;
/** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */ /** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */
@@ -44,7 +46,8 @@ export function Yandex21Map({
onMapClick, onMapClick,
onReady, onReady,
onError, onError,
className, className, onMapContextMenu,
}: Yandex21MapProps) { }: Yandex21MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<ymaps.Map | null>(null); const mapRef = useRef<ymaps.Map | null>(null);
@@ -57,6 +60,7 @@ export function Yandex21Map({
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
const onMapClickRef = useRef(onMapClick); const onMapClickRef = useRef(onMapClick);
const onMapContextMenuRef = useRef(onMapContextMenu);
const placemarksRef = useRef(new Map<string, any>()); const placemarksRef = useRef(new Map<string, any>());
const markersByIdRef = useRef(new Map<string, GpsMapMarker>()); const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
useEffect(() => { useEffect(() => {
@@ -64,6 +68,7 @@ export function Yandex21Map({
onErrorRef.current = onError; onErrorRef.current = onError;
onMapClickRef.current = onMapClick; onMapClickRef.current = onMapClick;
markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m]));
onMapContextMenuRef.current = onMapContextMenu;
}); });
// Загрузка скрипта и создание карты (один раз на apiKey) // Загрузка скрипта и создание карты (один раз на apiKey)
@@ -86,6 +91,13 @@ export function Yandex21Map({
onMapClickRef.current?.(coords[0], coords[1]); 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(); const collection = new ymaps.GeoObjectCollection();
map.geoObjects.add(collection); map.geoObjects.add(collection);

View File

@@ -2,7 +2,12 @@ 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 { 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 { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
import { AssetTreePanel } from '@/components/gps/AssetTreePanel'; import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
@@ -124,6 +129,93 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
return total; return total;
}, [rulerPoints]); }, [rulerPoints]);
// === Слой задач формы: выбранная форма + её geo-поле → задачи на карте ===
const [, setLocation] = useLocation();
const [tasksLayer, setTasksLayer] = useState(false);
const [mapFormId, setMapFormId] = useState<number | null>(null);
const [mapFieldId, setMapFieldId] = useState<number | null>(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<number, unknown>;
}>;
}>({
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) => { const handleSelectAsset = (asset: GpsAsset) => {
@@ -154,6 +246,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
approximate: p.lastIsLbs === true, approximate: p.lastIsLbs === true,
onClick: rulerMode ? undefined : () => setSelected(p), onClick: rulerMode ? undefined : () => setSelected(p),
})), })),
...taskMarkers,
...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({ ...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({
id: `ruler-${idx}`, id: `ruler-${idx}`,
lat, lat,
@@ -207,6 +300,50 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
<Ruler className="w-3.5 h-3.5 mr-1" /> <Ruler className="w-3.5 h-3.5 mr-1" />
Линейка Линейка
</Button> </Button>
<Button
size="sm"
variant={tasksLayer ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={() => setTasksLayer((v) => !v)}
>
<MapPinPlus className="w-3.5 h-3.5 mr-1" />
Объекты
</Button>
{tasksLayer && (
<>
<Select
value={mapFormId !== null ? String(mapFormId) : ''}
onValueChange={(v) => { setMapFormId(Number(v)); }}
>
<SelectTrigger className="h-7 w-44 text-xs">
<SelectValue placeholder="Форма" />
</SelectTrigger>
<SelectContent>
{(formsData?.forms ?? []).map((f) => (
<SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>
))}
</SelectContent>
</Select>
{geoFields.length > 1 && (
<Select
value={mapFieldId !== null ? String(mapFieldId) : ''}
onValueChange={(v) => setMapFieldId(Number(v))}
>
<SelectTrigger className="h-7 w-44 text-xs">
<SelectValue placeholder="Поле (адрес)" />
</SelectTrigger>
<SelectContent>
{geoFields.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<span className="text-xs text-muted-foreground">
Правый клик по карте — новый объект здесь
</span>
</>
)}
{rulerMode ? ( {rulerMode ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход
@@ -226,6 +363,7 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
polygons={mapPolygons} polygons={mapPolygons}
polylines={mapPolylines} polylines={mapPolylines}
onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined} 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" 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
)} )}
</div> </div>
</div> </div>
{/* Диалог создания объекта по правому клику на карте */}
{mapFormId !== null && mapFieldId !== null && (
<CreateTaskDialog
formId={mapFormId}
open={createAt !== null}
onOpenChange={(open) => { 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'] });
}}
/>
)}
</div> </div>
); );
} }