- EntryMap: Leaflet карта ЕКБ с CircleMarker парцелей, bbox-aware refetch (TanStack Query), click-to-select, deselect on backdrop, isFetching indicator - MapFilterBar: chip-фильтры (status/area/vri) + district select + counter, bbox-linked counter через FilterBarBridge без ремаунта карты - ParcelDrawer: slide-in panel 360px — 4 KPI (status/area/district/vri) + coords + CTA «Открыть анализ» -> /site-finder/analysis/[cad], fallback ПКК Росреестр - RecentParcels: TanStack Query hook с localStorage fallback (gd_recent_parcels), merges B2 server list + local-only items; empty state + hover effects - ParcelLegend: status color legend (free/in_progress/favorite) + STATUS_COLORS export reused in EntryMap markers - site-finder-api.ts: useParcelsBboxQuery + useRecentParcels hooks, mock fallback via MOCK_PARCELS_BBOX / MOCK_RECENT_PARCELS; getLocalRecentParcels / addLocalRecentParcel - parcels-bbox.json: 20 ЕКБ-парцелей фикстура (6 районов, все статусы, разные ВРИ) - page.tsx: заменены MapPlaceholder + SidebarPlaceholder на реальные компоненты
184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import Link from "next/link";
|
||
import { useState } from "react";
|
||
|
||
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";
|
||
|
||
// 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 FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
||
const { data: allInBbox } = useParcelsBboxQuery(bbox, {});
|
||
const { data: filtered } = useParcelsBboxQuery(bbox, filters);
|
||
|
||
return (
|
||
<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={{
|
||
minHeight: "100vh",
|
||
background: "var(--bg-app)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<header
|
||
style={{
|
||
background: "var(--bg-card)",
|
||
borderBottom: "1px solid var(--border-card)",
|
||
padding: "12px 24px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 16,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<Link
|
||
href="/"
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary)",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
← Главная
|
||
</Link>
|
||
<span style={{ color: "var(--border-soft)" }}>·</span>
|
||
<h1
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
SiteFinder · карта участков
|
||
</h1>
|
||
</header>
|
||
|
||
{/* Filter bar */}
|
||
<div style={{ flexShrink: 0 }}>
|
||
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
|
||
</div>
|
||
|
||
{/* Main layout */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
gap: 0,
|
||
overflow: "hidden",
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
{/* Map area */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
padding: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
<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",
|
||
}}
|
||
>
|
||
<RecentParcels />
|
||
<ParcelLegend />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Parcel drawer (slide-in from right) */}
|
||
<ParcelDrawer parcel={selectedParcel} onClose={handleParcelDeselect} />
|
||
</main>
|
||
);
|
||
}
|