Merge pull request 'feat(sf-fe-a2): /site-finder entry — EntryMap + filters + drawer + recent' (#343) from feat/sf-fe-a2-entry into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been cancelled

This commit is contained in:
lekss361 2026-05-17 22:19:17 +00:00
commit 5aab8a97af
8 changed files with 1629 additions and 93 deletions

View file

@ -1,101 +1,87 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { MapPin } from "lucide-react";
import { useState } from "react";
// ── Placeholder components ─────────────────────────────────────────────────
// Replaced in A2 with <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 (
<div
style={{
flex: 1,
minHeight: 480,
background: "var(--bg-card-alt)",
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>
);
// 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,
minHeight: 480,
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 14,
}}
>
Загрузка карты...
</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>
);
}

View 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='&copy; <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>
);
}

View 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.51 га", min: 0.5, max: 1 },
{ label: "13 га", 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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
}
]

View 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,
});
}