feat(gps): на вкладке «Карта» слой задач формы (селектор формы+geo-поля) + создание объекта правым кликом с координатами
This commit is contained in:
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 рендерится внутри контейнера карты)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user