diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 54ed66b4..3fe11222 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,101 +1,87 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; -import { MapPin } from "lucide-react"; +import { useState } from "react"; -// ── Placeholder components ───────────────────────────────────────────────── -// Replaced in A2 with , , +import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar"; +import { ParcelDrawer } from "@/components/site-finder/entry/ParcelDrawer"; +import { ParcelLegend } from "@/components/site-finder/entry/ParcelLegend"; +import { RecentParcels } from "@/components/site-finder/entry/RecentParcels"; +import { useParcelsBboxQuery } from "@/lib/site-finder-api"; +import type { + ParcelBboxFilters, + ParcelBboxItem, + BboxCoords, +} from "@/lib/site-finder-api"; -function MapPlaceholder() { - return ( -
- - TODO A2: EntryMap — Leaflet full-screen ЕКБ + parcel layers - - Данные: GET /api/v1/parcels/by-bbox (B1 ready) - -
- ); +// EntryMap uses Leaflet — must load without SSR +const EntryMap = dynamic( + () => + import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap), + { + ssr: false, + loading: () => ( +
+ Загрузка карты... +
+ ), + }, +); + +// ── FilterCountBridge ────────────────────────────────────────────────────────── +// Reads parcel counts for current bbox without re-mounting EntryMap. + +interface FilterBarBridgeProps { + filters: ParcelBboxFilters; + onChange: (f: ParcelBboxFilters) => void; + bbox: BboxCoords | null; } -function SidebarPlaceholder() { +function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) { + const { data: allInBbox } = useParcelsBboxQuery(bbox, {}); + const { data: filtered } = useParcelsBboxQuery(bbox, filters); + return ( - + ); } // ── Page ────────────────────────────────────────────────────────────────────── export default function SiteFinderPage() { + const [filters, setFilters] = useState({}); + const [selectedParcel, setSelectedParcel] = useState( + null, + ); + const [bbox, setBbox] = useState(null); + + function handleParcelSelect(parcel: ParcelBboxItem) { + setSelectedParcel(parcel); + } + + function handleParcelDeselect() { + setSelectedParcel(null); + } + return (
SiteFinder · карта участков - {/* TODO A4: справа */} - {/* TODO A2: MapFilterBar top-bar с chips (free/area/vri/district) + counter «148 / 34 подходят» */} + {/* Filter bar */} +
+ +
- {/* Main layout: карта слева, sidebar справа */} + {/* Main layout */}
{/* Map area */}
- {/* TODO A2: replace with */} - +
{/* Right sidebar */}
- {/* TODO A2: + + */} - + +
- {/* TODO A2: — slide-in справа при клике на парцель (query ?selected=cad) */} + {/* Parcel drawer (slide-in from right) */} +
); } diff --git a/frontend/src/components/site-finder/entry/EntryMap.tsx b/frontend/src/components/site-finder/entry/EntryMap.tsx new file mode 100644 index 00000000..4160d1c8 --- /dev/null +++ b/frontend/src/components/site-finder/entry/EntryMap.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + MapContainer, + TileLayer, + CircleMarker, + Tooltip, + useMapEvents, +} from "react-leaflet"; +import type { LeafletMouseEvent, Map as LeafletMap } from "leaflet"; +import "leaflet/dist/leaflet.css"; + +import type { + ParcelBboxItem, + BboxCoords, + ParcelBboxFilters, +} from "@/lib/site-finder-api"; +import { useParcelsBboxQuery } from "@/lib/site-finder-api"; +import { STATUS_COLORS } from "./ParcelLegend"; + +// ── Bbox change listener ─────────────────────────────────────────────────────── + +interface BboxListenerProps { + onBboxChange: (bbox: BboxCoords) => void; +} + +function BboxListener({ onBboxChange }: BboxListenerProps) { + const map = useMapEvents({ + moveend() { + const bounds = map.getBounds(); + onBboxChange({ + minLat: bounds.getSouth(), + minLon: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLon: bounds.getEast(), + }); + }, + zoomend() { + const bounds = map.getBounds(); + onBboxChange({ + minLat: bounds.getSouth(), + minLon: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLon: bounds.getEast(), + }); + }, + }); + + // Emit initial bbox on mount + useEffect(() => { + const bounds = map.getBounds(); + onBboxChange({ + minLat: bounds.getSouth(), + minLon: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLon: bounds.getEast(), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +// ── Parcel markers ───────────────────────────────────────────────────────────── + +interface ParcelMarkersProps { + parcels: ParcelBboxItem[]; + selectedCad: string | null; + onSelect: (parcel: ParcelBboxItem) => void; +} + +function ParcelMarkers({ parcels, selectedCad, onSelect }: ParcelMarkersProps) { + return ( + <> + {parcels.map((parcel) => { + const color = STATUS_COLORS[parcel.status] ?? "#73767E"; + const isSelected = parcel.cad_num === selectedCad; + return ( + { + e.originalEvent.stopPropagation(); + onSelect(parcel); + }, + }} + > + +
+
{parcel.cad_num}
+
+ {parcel.area_ha.toFixed(2)} га · {parcel.district} +
+
+
+
+ ); + })} + + ); +} + +// ── EntryMap ─────────────────────────────────────────────────────────────────── + +// Yekaterinburg center +const EKB_CENTER: [number, number] = [56.8389, 60.6057]; +const DEFAULT_ZOOM = 12; + +interface EntryMapProps { + filters: ParcelBboxFilters; + selectedCad: string | null; + onParcelSelect: (parcel: ParcelBboxItem) => void; + onParcelDeselect: () => void; + /** Called whenever the map viewport changes — allows parent to read bbox for filter counters */ + onBboxChange?: (bbox: BboxCoords) => void; +} + +export function EntryMap({ + filters, + selectedCad, + onParcelSelect, + onParcelDeselect, + onBboxChange, +}: EntryMapProps) { + const [bbox, setBbox] = useState(null); + const mapRef = useRef(null); + + const { data: parcels, isFetching } = useParcelsBboxQuery(bbox, filters); + + const handleBboxChange = useCallback( + (newBbox: BboxCoords) => { + setBbox(newBbox); + onBboxChange?.(newBbox); + }, + [onBboxChange], + ); + + return ( +
+ + + + {parcels && parcels.length > 0 && ( + + )} + + + {/* Deselect on map click — overlay to capture clicks outside markers */} + {selectedCad && ( + + ); +} diff --git a/frontend/src/components/site-finder/entry/MapFilterBar.tsx b/frontend/src/components/site-finder/entry/MapFilterBar.tsx new file mode 100644 index 00000000..10c3a559 --- /dev/null +++ b/frontend/src/components/site-finder/entry/MapFilterBar.tsx @@ -0,0 +1,257 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import type { + ParcelBboxFilters, + ParcelStatus, + ParcelVri, +} from "@/lib/site-finder-api"; + +interface MapFilterBarProps { + filters: ParcelBboxFilters; + onChange: (filters: ParcelBboxFilters) => void; + totalCount: number; + matchCount: number; +} + +interface ChipProps { + label: string; + selected: boolean; + onClick: () => void; +} + +function Chip({ label, selected, onClick }: ChipProps) { + return ( + + ); +} + +const DISTRICTS = [ + "Ленинский", + "Верх-Исетский", + "Орджоникидзевский", + "Чкаловский", + "Кировский", + "Октябрьский", +]; + +const VRI_OPTIONS: Array<{ value: ParcelVri; label: string }> = [ + { value: "multistory", label: "МКД" }, + { value: "mixed", label: "Смешанный" }, + { value: "individual", label: "ИЖС" }, + { value: "office", label: "Офисный" }, +]; + +const AREA_OPTIONS: Array<{ + label: string; + min?: number; + max?: number; +}> = [ + { label: "до 0.5 га", max: 0.5 }, + { label: "0.5–1 га", min: 0.5, max: 1 }, + { label: "1–3 га", min: 1, max: 3 }, + { label: "от 3 га", min: 3 }, +]; + +export function MapFilterBar({ + filters, + onChange, + totalCount, + matchCount, +}: MapFilterBarProps) { + function toggleStatus(s: ParcelStatus) { + onChange({ ...filters, status: filters.status === s ? undefined : s }); + } + + function toggleDistrict(d: string) { + onChange({ + ...filters, + district: filters.district === d ? undefined : d, + }); + } + + function toggleVri(v: ParcelVri) { + onChange({ ...filters, vri: filters.vri === v ? undefined : v }); + } + + function toggleArea(min?: number, max?: number) { + const same = filters.min_area === min && filters.max_area === max; + onChange({ + ...filters, + min_area: same ? undefined : min, + max_area: same ? undefined : max, + }); + } + + function clearAll() { + onChange({}); + } + + const hasFilters = + filters.status != null || + filters.district != null || + filters.vri != null || + filters.min_area != null || + filters.max_area != null; + + return ( +
+ {/* Counter */} + + {matchCount.toLocaleString("ru")} / {totalCount.toLocaleString("ru")}{" "} + участков + + + {/* Status chips */} + toggleStatus("free")} + /> + toggleStatus("in_progress")} + /> + toggleStatus("favorite")} + /> + + {/* Separator */} + + + {/* Area chips */} + {AREA_OPTIONS.map((opt) => ( + toggleArea(opt.min, opt.max)} + /> + ))} + + {/* Separator */} + + + {/* VRI chips */} + {VRI_OPTIONS.map((opt) => ( + toggleVri(opt.value)} + /> + ))} + + {/* Separator */} + + + {/* District selector */} + + + {/* Clear all */} + {hasFilters && ( + + )} +
+ ); +} diff --git a/frontend/src/components/site-finder/entry/ParcelDrawer.tsx b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx new file mode 100644 index 00000000..f8914c69 --- /dev/null +++ b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx @@ -0,0 +1,374 @@ +"use client"; + +import Link from "next/link"; +import { X, MapPin, Maximize2, ArrowRight } from "lucide-react"; +import type { ParcelBboxItem } from "@/lib/site-finder-api"; +import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend"; + +interface ParcelDrawerProps { + parcel: ParcelBboxItem | null; + onClose: () => void; +} + +const VRI_LABELS: Record = { + multistory: "Многоэтажный", + mixed: "Смешанный", + individual: "ИЖС", + office: "Офисный", + other: "Иное", +}; + +export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) { + if (!parcel) return null; + + const statusColor = STATUS_COLORS[parcel.status] ?? "#73767E"; + const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status; + + return ( + <> + {/* Backdrop */} +