Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
251 lines
7.6 KiB
TypeScript
251 lines
7.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|