feat(gps): на вкладке «Карта» слой задач формы (селектор формы+geo-поля) + создание объекта правым кликом с координатами
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
@@ -57,9 +60,11 @@ export function OsmMap({
|
||||
// при смене идентичности массивов (react-query рефетчи)
|
||||
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
|
||||
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 рендерится внутри контейнера карты)
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<ymaps.Map | null>(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<string, any>());
|
||||
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<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) => {
|
||||
@@ -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
|
||||
<Ruler className="w-3.5 h-3.5 mr-1" />
|
||||
Линейка
|
||||
</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 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Линейка: кликайте по карте, 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
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user