feat(sf-fe-a2): /site-finder entry — EntryMap + filters + drawer + recent #343
8 changed files with 1629 additions and 93 deletions
|
|
@ -1,13 +1,27 @@
|
|||
"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 <EntryMap/>, <MapFilterBar/>, <RecentParcels/>
|
||||
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 (
|
||||
// EntryMap uses Leaflet — must load without SSR
|
||||
const EntryMap = dynamic(
|
||||
() =>
|
||||
import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
|
|
@ -16,86 +30,58 @@ function MapPlaceholder() {
|
|||
border: "1px dashed var(--border-strong)",
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
color: "var(--fg-tertiary)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<MapPin
|
||||
size={32}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--fg-tertiary)" }}
|
||||
/>
|
||||
<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 (
|
||||
<aside
|
||||
style={{
|
||||
width: 360,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
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>
|
||||
<MapFilterBar
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
totalCount={allInBbox?.length ?? 0}
|
||||
matchCount={filtered?.length ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<main
|
||||
style={{
|
||||
|
|
@ -114,6 +100,7 @@ export default function SiteFinderPage() {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
|
|
@ -137,44 +124,61 @@ export default function SiteFinderPage() {
|
|||
>
|
||||
SiteFinder · карта участков
|
||||
</h1>
|
||||
{/* TODO A4: <UserAvatar/> справа */}
|
||||
</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
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
overflow: "hidden",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* Map area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* TODO A2: replace with <EntryMap/> */}
|
||||
<MapPlaceholder />
|
||||
<EntryMap
|
||||
filters={filters}
|
||||
selectedCad={selectedParcel?.cad_num ?? null}
|
||||
onParcelSelect={handleParcelSelect}
|
||||
onParcelDeselect={handleParcelDeselect}
|
||||
onBboxChange={setBbox}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: 300,
|
||||
flexShrink: 0,
|
||||
padding: "16px 16px 16px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{/* TODO A2: <MapFilterBar/> + <RecentParcels/> + <ParcelLegend/> */}
|
||||
<SidebarPlaceholder />
|
||||
<RecentParcels />
|
||||
<ParcelLegend />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO A2: <ParcelDrawer/> — slide-in справа при клике на парцель (query ?selected=cad) */}
|
||||
{/* Parcel drawer (slide-in from right) */}
|
||||
<ParcelDrawer parcel={selectedParcel} onClose={handleParcelDeselect} />
|
||||
</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