feat(sf-fe-a2): /site-finder entry — EntryMap + filters + drawer + recent #343
8 changed files with 1629 additions and 93 deletions
|
|
@ -1,101 +1,87 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MapPin } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// ── Placeholder components ─────────────────────────────────────────────────
|
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
||||||
// Replaced in A2 with <EntryMap/>, <MapFilterBar/>, <RecentParcels/>
|
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() {
|
// EntryMap uses Leaflet — must load without SSR
|
||||||
return (
|
const EntryMap = dynamic(
|
||||||
<div
|
() =>
|
||||||
style={{
|
import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap),
|
||||||
flex: 1,
|
{
|
||||||
minHeight: 480,
|
ssr: false,
|
||||||
background: "var(--bg-card-alt)",
|
loading: () => (
|
||||||
border: "1px dashed var(--border-strong)",
|
<div
|
||||||
borderRadius: 12,
|
style={{
|
||||||
display: "flex",
|
flex: 1,
|
||||||
flexDirection: "column",
|
minHeight: 480,
|
||||||
alignItems: "center",
|
background: "var(--bg-card-alt)",
|
||||||
justifyContent: "center",
|
border: "1px dashed var(--border-strong)",
|
||||||
gap: 8,
|
borderRadius: 12,
|
||||||
color: "var(--fg-tertiary)",
|
display: "flex",
|
||||||
fontSize: 14,
|
alignItems: "center",
|
||||||
}}
|
justifyContent: "center",
|
||||||
>
|
color: "var(--fg-tertiary)",
|
||||||
<MapPin
|
fontSize: 14,
|
||||||
size={32}
|
}}
|
||||||
strokeWidth={1.5}
|
>
|
||||||
style={{ color: "var(--fg-tertiary)" }}
|
Загрузка карты...
|
||||||
/>
|
</div>
|
||||||
<span>TODO A2: EntryMap — Leaflet full-screen ЕКБ + parcel layers</span>
|
),
|
||||||
<span style={{ fontSize: 12 }}>
|
},
|
||||||
Данные: GET /api/v1/parcels/by-bbox (B1 ready)
|
);
|
||||||
</span>
|
|
||||||
</div>
|
// ── 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 (
|
return (
|
||||||
<aside
|
<MapFilterBar
|
||||||
style={{
|
filters={filters}
|
||||||
width: 360,
|
onChange={onChange}
|
||||||
flexShrink: 0,
|
totalCount={allInBbox?.length ?? 0}
|
||||||
display: "flex",
|
matchCount={filtered?.length ?? 0}
|
||||||
flexDirection: "column",
|
/>
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* TODO A2: <MapFilterBar/> */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "12px 16px",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
TODO A2: MapFilterBar — chips free / area / vri / district + counter
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TODO A2: <RecentParcels/> */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "12px 16px",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
TODO A2: RecentParcels — 3 строки из localStorage gd_recent_parcels
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TODO A2: <ParcelLegend/> */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: "12px 16px",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
TODO A2: ParcelLegend — расраска статусов (свободные / в работе / ...)
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SiteFinderPage() {
|
export default function SiteFinderPage() {
|
||||||
|
const [filters, setFilters] = useState<ParcelBboxFilters>({});
|
||||||
|
const [selectedParcel, setSelectedParcel] = useState<ParcelBboxItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [bbox, setBbox] = useState<BboxCoords | null>(null);
|
||||||
|
|
||||||
|
function handleParcelSelect(parcel: ParcelBboxItem) {
|
||||||
|
setSelectedParcel(parcel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleParcelDeselect() {
|
||||||
|
setSelectedParcel(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -114,6 +100,7 @@ export default function SiteFinderPage() {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -137,44 +124,61 @@ export default function SiteFinderPage() {
|
||||||
>
|
>
|
||||||
SiteFinder · карта участков
|
SiteFinder · карта участков
|
||||||
</h1>
|
</h1>
|
||||||
{/* TODO A4: <UserAvatar/> справа */}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* TODO A2: MapFilterBar top-bar с chips (free/area/vri/district) + counter «148 / 34 подходят» */}
|
{/* Filter bar */}
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main layout: карта слева, sidebar справа */}
|
{/* Main layout */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 0,
|
gap: 0,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: "flex",
|
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO A2: replace with <EntryMap/> */}
|
<EntryMap
|
||||||
<MapPlaceholder />
|
filters={filters}
|
||||||
|
selectedCad={selectedParcel?.cad_num ?? null}
|
||||||
|
onParcelSelect={handleParcelSelect}
|
||||||
|
onParcelDeselect={handleParcelDeselect}
|
||||||
|
onBboxChange={setBbox}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right sidebar */}
|
{/* Right sidebar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
width: 300,
|
||||||
|
flexShrink: 0,
|
||||||
padding: "16px 16px 16px 0",
|
padding: "16px 16px 16px 0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO A2: <MapFilterBar/> + <RecentParcels/> + <ParcelLegend/> */}
|
<RecentParcels />
|
||||||
<SidebarPlaceholder />
|
<ParcelLegend />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO A2: <ParcelDrawer/> — slide-in справа при клике на парцель (query ?selected=cad) */}
|
{/* Parcel drawer (slide-in from right) */}
|
||||||
|
<ParcelDrawer parcel={selectedParcel} onClose={handleParcelDeselect} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
frontend/src/components/site-finder/entry/EntryMap.tsx
Normal file
224
frontend/src/components/site-finder/entry/EntryMap.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<CircleMarker
|
||||||
|
key={parcel.cad_num}
|
||||||
|
center={[parcel.lat, parcel.lon]}
|
||||||
|
radius={isSelected ? 10 : 7}
|
||||||
|
pathOptions={{
|
||||||
|
color: isSelected ? "#0F172A" : color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
weight: isSelected ? 2.5 : 1.5,
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: (e: LeafletMouseEvent) => {
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
onSelect(parcel);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -8]} opacity={0.95}>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{parcel.cad_num}</div>
|
||||||
|
<div style={{ color: "#5B6066" }}>
|
||||||
|
{parcel.area_ha.toFixed(2)} га · {parcel.district}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<BboxCoords | null>(null);
|
||||||
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
|
||||||
|
const { data: parcels, isFetching } = useParcelsBboxQuery(bbox, filters);
|
||||||
|
|
||||||
|
const handleBboxChange = useCallback(
|
||||||
|
(newBbox: BboxCoords) => {
|
||||||
|
setBbox(newBbox);
|
||||||
|
onBboxChange?.(newBbox);
|
||||||
|
},
|
||||||
|
[onBboxChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||||
|
<MapContainer
|
||||||
|
center={EKB_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ width: "100%", height: "100%", borderRadius: 12 }}
|
||||||
|
ref={mapRef}
|
||||||
|
// Click on map backdrop deselects parcel
|
||||||
|
// handled via eventHandlers on markers + backdrop div
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<BboxListener onBboxChange={handleBboxChange} />
|
||||||
|
{parcels && parcels.length > 0 && (
|
||||||
|
<ParcelMarkers
|
||||||
|
parcels={parcels}
|
||||||
|
selectedCad={selectedCad}
|
||||||
|
onSelect={onParcelSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Deselect on map click — overlay to capture clicks outside markers */}
|
||||||
|
{selectedCad && (
|
||||||
|
<div
|
||||||
|
style={{ position: "absolute", inset: 0, zIndex: 399 }}
|
||||||
|
onClick={onParcelDeselect}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fetching indicator */}
|
||||||
|
{isFetching && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 1000,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "4px 14px",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка участков...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isFetching && parcels && parcels.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
zIndex: 1000,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "16px 24px",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нет участков в текущем виде. Измените фильтры или область карты.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
frontend/src/components/site-finder/entry/MapFilterBar.tsx
Normal file
257
frontend/src/components/site-finder/entry/MapFilterBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: selected
|
||||||
|
? "1px solid var(--accent)"
|
||||||
|
: "1px solid var(--border-card)",
|
||||||
|
background: selected ? "var(--accent-soft)" : "var(--bg-card)",
|
||||||
|
color: selected ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: selected ? 500 : 400,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
borderBottom: "1px solid var(--border-card)",
|
||||||
|
padding: "8px 16px",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Counter */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginRight: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchCount.toLocaleString("ru")} / {totalCount.toLocaleString("ru")}{" "}
|
||||||
|
участков
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status chips */}
|
||||||
|
<Chip
|
||||||
|
label="Свободные"
|
||||||
|
selected={filters.status === "free"}
|
||||||
|
onClick={() => toggleStatus("free")}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="В работе"
|
||||||
|
selected={filters.status === "in_progress"}
|
||||||
|
onClick={() => toggleStatus("in_progress")}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label="Избранные"
|
||||||
|
selected={filters.status === "favorite"}
|
||||||
|
onClick={() => toggleStatus("favorite")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--border-soft)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Area chips */}
|
||||||
|
{AREA_OPTIONS.map((opt) => (
|
||||||
|
<Chip
|
||||||
|
key={opt.label}
|
||||||
|
label={opt.label}
|
||||||
|
selected={
|
||||||
|
filters.min_area === opt.min && filters.max_area === opt.max
|
||||||
|
}
|
||||||
|
onClick={() => toggleArea(opt.min, opt.max)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--border-soft)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* VRI chips */}
|
||||||
|
{VRI_OPTIONS.map((opt) => (
|
||||||
|
<Chip
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
selected={filters.vri === opt.value}
|
||||||
|
onClick={() => toggleVri(opt.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
background: "var(--border-soft)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* District selector */}
|
||||||
|
<select
|
||||||
|
value={filters.district ?? ""}
|
||||||
|
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
onChange({ ...filters, district: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: filters.district
|
||||||
|
? "1px solid var(--accent)"
|
||||||
|
: "1px solid var(--border-card)",
|
||||||
|
background: filters.district
|
||||||
|
? "var(--accent-soft)"
|
||||||
|
: "var(--bg-card)",
|
||||||
|
color: filters.district ? "var(--accent)" : "var(--fg-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Все районы</option>
|
||||||
|
{DISTRICTS.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Clear all */}
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--danger)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px 8px",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
frontend/src/components/site-finder/entry/ParcelDrawer.tsx
Normal file
374
frontend/src/components/site-finder/entry/ParcelDrawer.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 40,
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer panel */}
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 360,
|
||||||
|
zIndex: 50,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
borderLeft: "1px solid var(--border-card)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: "-4px 0 24px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Карточка участка"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px 12px",
|
||||||
|
borderBottom: "1px solid var(--border-soft)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginTop: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.cad_num}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginTop: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 4,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Статус
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: statusColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Площадь
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.area_ha.toFixed(2)}{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
га
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* District */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Район
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.district}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VRI */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ВРИ
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VRI_LABELS[parcel.vri] ?? parcel.vri}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coords */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
gridColumn: "1 / -1",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Координаты
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.lat.toFixed(5)}, {parcel.lon.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderTop: "1px solid var(--border-soft)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 20px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть анализ
|
||||||
|
<ArrowRight size={16} strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`https://pkk.rosreestr.ru/#/search/cadastralNumber/${encodeURIComponent(parcel.cad_num)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "none",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 20px",
|
||||||
|
fontSize: 13,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Maximize2 size={14} strokeWidth={1.5} />
|
||||||
|
ПКК Росреестр
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/site-finder/entry/ParcelLegend.tsx
Normal file
67
frontend/src/components/site-finder/entry/ParcelLegend.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
// Status color constants — kept in sync with EntryMap marker colours
|
||||||
|
export const STATUS_COLORS: Record<string, string> = {
|
||||||
|
free: "#0A7A3A",
|
||||||
|
in_progress: "#9A6700",
|
||||||
|
favorite: "#1D4ED8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
|
free: "Свободный",
|
||||||
|
in_progress: "В работе",
|
||||||
|
favorite: "Избранный",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ParcelLegend() {
|
||||||
|
const entries = Object.keys(STATUS_LABELS) as Array<
|
||||||
|
keyof typeof STATUS_LABELS
|
||||||
|
>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 148,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Статус участка
|
||||||
|
</span>
|
||||||
|
{entries.map((status) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: STATUS_COLORS[status],
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--fg-primary)" }}>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
frontend/src/components/site-finder/entry/RecentParcels.tsx
Normal file
201
frontend/src/components/site-finder/entry/RecentParcels.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Clock, MapPin } from "lucide-react";
|
||||||
|
import { useRecentParcels } from "@/lib/site-finder-api";
|
||||||
|
|
||||||
|
const MAX_SHOWN = 5;
|
||||||
|
|
||||||
|
export function RecentParcels() {
|
||||||
|
const { data: parcels, isLoading } = useRecentParcels();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 14,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 12,
|
||||||
|
width: 120,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = (parcels ?? []).slice(0, MAX_SHOWN);
|
||||||
|
|
||||||
|
if (shown.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--fg-secondary)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Недавние участки
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
История просмотров пуста
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px 8px",
|
||||||
|
borderBottom: "1px solid var(--border-soft)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ color: "var(--fg-secondary)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Недавние участки
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{shown.map((parcel, idx) => (
|
||||||
|
<Link
|
||||||
|
key={parcel.cad_num}
|
||||||
|
href={`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
textDecoration: "none",
|
||||||
|
borderBottom:
|
||||||
|
idx < shown.length - 1
|
||||||
|
? "1px solid var(--border-soft)"
|
||||||
|
: "none",
|
||||||
|
transition: "background 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background =
|
||||||
|
"var(--bg-card-alt)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.cad_num}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parcel.district}
|
||||||
|
{parcel.area_ha ? ` · ${parcel.area_ha.toFixed(2)} га` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
frontend/src/lib/mocks/parcels-bbox.json
Normal file
202
frontend/src/lib/mocks/parcels-bbox.json
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0101001:10",
|
||||||
|
"address": "Екатеринбург, ул. Ленина, 1",
|
||||||
|
"area_ha": 0.45,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8389,
|
||||||
|
"lon": 60.6057
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0101002:23",
|
||||||
|
"address": "Екатеринбург, ул. Малышева, 51",
|
||||||
|
"area_ha": 0.82,
|
||||||
|
"status": "in_progress",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8377,
|
||||||
|
"lon": 60.6121
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0201001:5",
|
||||||
|
"address": "Екатеринбург, ул. Луначарского, 14",
|
||||||
|
"area_ha": 1.2,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"vri": "mixed",
|
||||||
|
"lat": 56.8451,
|
||||||
|
"lon": 60.5987
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0301001:8",
|
||||||
|
"address": "Екатеринбург, пр. Космонавтов, 3",
|
||||||
|
"area_ha": 0.67,
|
||||||
|
"status": "favorite",
|
||||||
|
"district": "Орджоникидзевский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8762,
|
||||||
|
"lon": 60.6214
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0302001:12",
|
||||||
|
"address": "Екатеринбург, ул. Победы, 80",
|
||||||
|
"area_ha": 0.33,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Орджоникидзевский",
|
||||||
|
"vri": "individual",
|
||||||
|
"lat": 56.8801,
|
||||||
|
"lon": 60.6189
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0401001:17",
|
||||||
|
"address": "Екатеринбург, ул. Амундсена, 107",
|
||||||
|
"area_ha": 1.85,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Чкаловский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.7998,
|
||||||
|
"lon": 60.6342
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0402001:31",
|
||||||
|
"address": "Екатеринбург, ул. Щербакова, 4",
|
||||||
|
"area_ha": 0.54,
|
||||||
|
"status": "in_progress",
|
||||||
|
"district": "Чкаловский",
|
||||||
|
"vri": "mixed",
|
||||||
|
"lat": 56.8021,
|
||||||
|
"lon": 60.6501
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0501001:6",
|
||||||
|
"address": "Екатеринбург, ул. Белинского, 200",
|
||||||
|
"area_ha": 0.91,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8278,
|
||||||
|
"lon": 60.6398
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0601001:44",
|
||||||
|
"address": "Екатеринбург, ул. Надеждинская, 12",
|
||||||
|
"area_ha": 2.1,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Кировский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8491,
|
||||||
|
"lon": 60.5781
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0602001:9",
|
||||||
|
"address": "Екатеринбург, ул. Тверитина, 38",
|
||||||
|
"area_ha": 0.72,
|
||||||
|
"status": "favorite",
|
||||||
|
"district": "Кировский",
|
||||||
|
"vri": "mixed",
|
||||||
|
"lat": 56.8532,
|
||||||
|
"lon": 60.5834
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0701001:18",
|
||||||
|
"address": "Екатеринбург, ул. Академика Постовского, 15",
|
||||||
|
"area_ha": 0.48,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Октябрьский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8187,
|
||||||
|
"lon": 60.5921
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0702001:27",
|
||||||
|
"address": "Екатеринбург, ул. Начдива Онуфриева, 2а",
|
||||||
|
"area_ha": 3.4,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Октябрьский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8112,
|
||||||
|
"lon": 60.5812
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0801001:55",
|
||||||
|
"address": "Екатеринбург, ул. Радищева, 33",
|
||||||
|
"area_ha": 0.61,
|
||||||
|
"status": "in_progress",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"vri": "office",
|
||||||
|
"lat": 56.8348,
|
||||||
|
"lon": 60.6234
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0802001:11",
|
||||||
|
"address": "Екатеринбург, ул. Сакко и Ванцетти, 67",
|
||||||
|
"area_ha": 0.38,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Ленинский",
|
||||||
|
"vri": "mixed",
|
||||||
|
"lat": 56.8363,
|
||||||
|
"lon": 60.6177
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0901001:7",
|
||||||
|
"address": "Екатеринбург, ул. Ботаническая, 11",
|
||||||
|
"area_ha": 1.54,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Чкаловский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.7892,
|
||||||
|
"lon": 60.6498
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:0902001:22",
|
||||||
|
"address": "Екатеринбург, пр. Латвийский, 12",
|
||||||
|
"area_ha": 0.83,
|
||||||
|
"status": "favorite",
|
||||||
|
"district": "Чкаловский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.7934,
|
||||||
|
"lon": 60.6541
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:1001001:3",
|
||||||
|
"address": "Екатеринбург, ул. Краснолесья, 26",
|
||||||
|
"area_ha": 2.67,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Октябрьский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.7812,
|
||||||
|
"lon": 60.5698
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:1002001:14",
|
||||||
|
"address": "Екатеринбург, ул. Ясная, 30",
|
||||||
|
"area_ha": 0.59,
|
||||||
|
"status": "in_progress",
|
||||||
|
"district": "Октябрьский",
|
||||||
|
"vri": "mixed",
|
||||||
|
"lat": 56.7856,
|
||||||
|
"lon": 60.5742
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:1101001:42",
|
||||||
|
"address": "Екатеринбург, ул. Крестинского, 46",
|
||||||
|
"area_ha": 1.12,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"vri": "multistory",
|
||||||
|
"lat": 56.8578,
|
||||||
|
"lon": 60.5634
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cad_num": "66:41:1102001:19",
|
||||||
|
"address": "Екатеринбург, ул. Новостроя, 8",
|
||||||
|
"area_ha": 0.76,
|
||||||
|
"status": "free",
|
||||||
|
"district": "Верх-Исетский",
|
||||||
|
"vri": "individual",
|
||||||
|
"lat": 56.8621,
|
||||||
|
"lon": 60.5589
|
||||||
|
}
|
||||||
|
]
|
||||||
207
frontend/src/lib/site-finder-api.ts
Normal file
207
frontend/src/lib/site-finder-api.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
/**
|
||||||
|
* Site Finder API hooks — TanStack Query wrappers.
|
||||||
|
*
|
||||||
|
* Mock fallback strategy (from mock-toggle.ts):
|
||||||
|
* MOCK_PARCELS_BBOX — uses parcels-bbox.json fixture (B1 not yet in prod)
|
||||||
|
* MOCK_RECENT_PARCELS — uses localStorage only (B2 stub not yet in prod)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { MOCK_PARCELS_BBOX, MOCK_RECENT_PARCELS } from "@/lib/mock-toggle";
|
||||||
|
import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ParcelStatus = "free" | "in_progress" | "favorite";
|
||||||
|
|
||||||
|
export type ParcelVri =
|
||||||
|
| "multistory"
|
||||||
|
| "mixed"
|
||||||
|
| "individual"
|
||||||
|
| "office"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface ParcelBboxItem {
|
||||||
|
cad_num: string;
|
||||||
|
address: string;
|
||||||
|
area_ha: number;
|
||||||
|
status: ParcelStatus;
|
||||||
|
district: string;
|
||||||
|
vri: ParcelVri;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParcelBboxFilters {
|
||||||
|
min_area?: number;
|
||||||
|
max_area?: number;
|
||||||
|
status?: ParcelStatus;
|
||||||
|
district?: string;
|
||||||
|
vri?: ParcelVri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BboxCoords {
|
||||||
|
minLat: number;
|
||||||
|
minLon: number;
|
||||||
|
maxLat: number;
|
||||||
|
maxLon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentParcel {
|
||||||
|
cad_num: string;
|
||||||
|
address: string;
|
||||||
|
area_ha: number;
|
||||||
|
district: string;
|
||||||
|
visited_at: string; // ISO string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_KEY = "gd_recent_parcels";
|
||||||
|
const MAX_RECENT = 10;
|
||||||
|
|
||||||
|
// ── localStorage helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getLocalRecentParcels(): RecentParcel[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed as RecentParcel[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLocalRecentParcel(parcel: RecentParcel): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const existing = getLocalRecentParcels();
|
||||||
|
const filtered = existing.filter((p) => p.cad_num !== parcel.cad_num);
|
||||||
|
const updated = [parcel, ...filtered].slice(0, MAX_RECENT);
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook: useParcelsBboxQuery ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyFilters(
|
||||||
|
parcels: ParcelBboxItem[],
|
||||||
|
filters: ParcelBboxFilters,
|
||||||
|
): ParcelBboxItem[] {
|
||||||
|
return parcels.filter((p) => {
|
||||||
|
if (filters.min_area != null && p.area_ha < filters.min_area) return false;
|
||||||
|
if (filters.max_area != null && p.area_ha > filters.max_area) return false;
|
||||||
|
if (filters.status != null && p.status !== filters.status) return false;
|
||||||
|
if (
|
||||||
|
filters.district != null &&
|
||||||
|
filters.district !== "" &&
|
||||||
|
p.district !== filters.district
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (filters.vri != null && filters.vri !== "other" && p.vri !== filters.vri)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByBbox(
|
||||||
|
parcels: ParcelBboxItem[],
|
||||||
|
bbox: BboxCoords,
|
||||||
|
): ParcelBboxItem[] {
|
||||||
|
return parcels.filter(
|
||||||
|
(p) =>
|
||||||
|
p.lat >= bbox.minLat &&
|
||||||
|
p.lat <= bbox.maxLat &&
|
||||||
|
p.lon >= bbox.minLon &&
|
||||||
|
p.lon <= bbox.maxLon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useParcelsBboxQuery(
|
||||||
|
bbox: BboxCoords | null,
|
||||||
|
filters: ParcelBboxFilters = {},
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"parcels-bbox",
|
||||||
|
bbox?.minLat,
|
||||||
|
bbox?.minLon,
|
||||||
|
bbox?.maxLat,
|
||||||
|
bbox?.maxLon,
|
||||||
|
filters.min_area,
|
||||||
|
filters.max_area,
|
||||||
|
filters.status,
|
||||||
|
filters.district,
|
||||||
|
filters.vri,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<ParcelBboxItem[]> => {
|
||||||
|
if (MOCK_PARCELS_BBOX) {
|
||||||
|
// Fixture: filter by bbox + filters client-side
|
||||||
|
const typed = fixtureParcels as ParcelBboxItem[];
|
||||||
|
const inBbox = bbox ? filterByBbox(typed, bbox) : typed;
|
||||||
|
return applyFilters(inBbox, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bbox) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
min_lat: String(bbox.minLat),
|
||||||
|
min_lon: String(bbox.minLon),
|
||||||
|
max_lat: String(bbox.maxLat),
|
||||||
|
max_lon: String(bbox.maxLon),
|
||||||
|
});
|
||||||
|
if (filters.min_area != null)
|
||||||
|
params.set("min_area", String(filters.min_area));
|
||||||
|
if (filters.max_area != null)
|
||||||
|
params.set("max_area", String(filters.max_area));
|
||||||
|
if (filters.status) params.set("status", filters.status);
|
||||||
|
if (filters.district) params.set("district", filters.district);
|
||||||
|
if (filters.vri) params.set("vri", filters.vri);
|
||||||
|
|
||||||
|
return apiFetch<ParcelBboxItem[]>(
|
||||||
|
`/api/v1/parcels/by-bbox?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: MOCK_PARCELS_BBOX || bbox != null,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook: useRecentParcels ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RecentParcelsResponse {
|
||||||
|
parcels: RecentParcel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentParcels() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["recent-parcels"],
|
||||||
|
queryFn: async (): Promise<RecentParcel[]> => {
|
||||||
|
const localItems = getLocalRecentParcels();
|
||||||
|
|
||||||
|
if (MOCK_RECENT_PARCELS) {
|
||||||
|
return localItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverData = await apiFetch<RecentParcelsResponse>(
|
||||||
|
"/api/v1/users/me/recent-parcels",
|
||||||
|
);
|
||||||
|
// Merge: server list + local items not already in server list
|
||||||
|
const serverCads = new Set(serverData.parcels.map((p) => p.cad_num));
|
||||||
|
const localOnly = localItems.filter((p) => !serverCads.has(p.cad_num));
|
||||||
|
return [...serverData.parcels, ...localOnly].slice(0, MAX_RECENT);
|
||||||
|
} catch {
|
||||||
|
// B2 stub may not be deployed yet — fall back to localStorage
|
||||||
|
return localItems;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue