gendesign/frontend/src/app/site-finder/page.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

251 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { CadInput } from "@/components/site-finder/CadInput";
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 {
addLocalRecentParcel,
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) {
// useParcelsBboxQuery has no keepPreviousData (unlike useParcelAnalyzeQuery),
// and its queryKey carries the bbox — so on every pan/zoom both `data` go
// undefined mid-fetch. We hold the last fully-resolved pair so the counter
// doesn't flash "0 / 0" while fetching (#1419), and so totalCount/matchCount
// are always taken from the *same* committed frame — never a new bbox's
// matchCount over an old bbox's totalCount, which could read matchCount >
// totalCount and break the X ⊆ Y invariant (#1420).
const allQuery = useParcelsBboxQuery(bbox, {});
const filteredQuery = useParcelsBboxQuery(bbox, filters);
const lastCounts = useRef<{ totalCount: number; matchCount: number } | null>(
null,
);
// Only commit a new pair once BOTH queries have data for the current render;
// until then keep the previous pair (placeholderData: keepPreviousData
// equivalent, scoped to this bridge).
if (allQuery.data != null && filteredQuery.data != null) {
lastCounts.current = {
totalCount: allQuery.data.length,
matchCount: filteredQuery.data.length,
};
}
const counts = lastCounts.current ?? { totalCount: 0, matchCount: 0 };
return (
<MapFilterBar
filters={filters}
onChange={onChange}
totalCount={counts.totalCount}
matchCount={counts.matchCount}
/>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function SiteFinderPage() {
const router = useRouter();
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);
}
function handleParcelOpen(parcel: ParcelBboxItem) {
// Record the visit so RecentParcels stays populated, then navigate.
addLocalRecentParcel({
cad_num: parcel.cad_num,
address: parcel.address,
area_ha: parcel.area_ha,
district: parcel.district,
visited_at: new Date().toISOString(),
});
router.push(`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`);
}
function handleCadSubmit(cad: string) {
router.push(`/site-finder/analysis/${encodeURIComponent(cad)}`);
}
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>
<Link
href="/site-finder/compare"
style={{
marginLeft: "auto",
fontSize: 13,
color: "var(--accent)",
textDecoration: "none",
fontWeight: 500,
}}
>
Сравнить участки
</Link>
</header>
{/* Filter bar */}
<div style={{ flexShrink: 0 }}>
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
</div>
{/* Main layout. На планшете/телефоне .gd-split встаёт в колонку
(карта над сайдбаром), .gd-map-shell держит высоту карты (issue #66). */}
<div
className="gd-split"
style={{
flex: 1,
display: "flex",
gap: 0,
overflow: "hidden",
minHeight: 0,
}}
>
{/* Map area — position: relative so ParcelDrawer can anchor absolutely here */}
<div
className="gd-map-shell"
style={{
flex: 1,
padding: 16,
display: "flex",
flexDirection: "column",
minHeight: 0,
position: "relative",
}}
>
<EntryMap
filters={filters}
selectedCad={selectedParcel?.cad_num ?? null}
onParcelOpen={handleParcelOpen}
onParcelSelect={handleParcelSelect}
onParcelDeselect={handleParcelDeselect}
onBboxChange={setBbox}
/>
{/* ParcelDrawer — anchored to map area right edge, not page */}
<ParcelDrawer
parcel={selectedParcel}
onClose={handleParcelDeselect}
/>
</div>
{/* Right sidebar — always visible; CadInput accessible parallel with drawer */}
<div
className="gd-split-aside"
style={{
width: 300,
flexShrink: 0,
padding: "16px 16px 16px 0",
display: "flex",
flexDirection: "column",
gap: 12,
overflowY: "auto",
}}
>
<CadInput onSubmit={handleCadSubmit} loading={false} />
<RecentParcels />
<ParcelLegend />
</div>
</div>
</main>
);
}