gendesign/frontend/src/components/site-finder/analysis/Section1ParcelInfo.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

327 lines
9.9 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";
/**
* Section1ParcelInfo — "1. Инфо об участке"
*
* Layout:
* HeadlineBar (verdict + score delta subtitle)
* 4 KpiCard grid (Площадь / Район / Медиана / Score)
* 2-col grid: left = MiniMap, right = EgrnPropertyTable + PoiList2Gis
* ExportButtons
*
* Data: useParcelAnalyzeQuery (B5) + useParcelPoiScoreQuery (B6).
* Mock fallback: MOCK_ANALYZE / MOCK_POI_SCORE from mock-toggle.ts.
*/
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { MiniMap } from "./MiniMap";
import { EgrnPropertyTable } from "./EgrnPropertyTable";
import { NspdVerifyLink } from "./NspdVerifyLink";
import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
import {
adaptEgrn,
useParcelAnalyzeQuery,
useParcelPoiScoreQuery,
} from "@/lib/site-finder-api";
import type { ParcelEgrn } from "@/lib/site-finder-api";
// ── Fallback EGRN (when B5 extended not yet deployed) ────────────────────────
function buildFallbackEgrn(cad: string): ParcelEgrn {
return {
cad_num: cad,
address: "—",
area_m2: NaN,
vri: "—",
category: "—",
registration_date: null,
owner_type: "—",
encumbrance: "—",
status: "—",
last_updated: null,
};
}
// ── Score → verdict label ─────────────────────────────────────────────────────
function scoreVerdict(score: number, scoreLabel: string | undefined): string {
const label =
scoreLabel ??
(score >= 80
? "отлично"
: score >= 65
? "хорошо"
: score >= 45
? "средне"
: "плохо");
const labelUpper = label.toUpperCase();
if (score >= 65) return `ПОДХОДИТ · ${labelUpper}`;
if (score >= 45) return `СРЕДНЕ · ${labelUpper}`;
return `РИСКИ · ${labelUpper}`;
}
// ── Section 1 skeleton (loading state) ───────────────────────────────────────
function Section1Skeleton() {
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
{/* HeadlineBar skeleton */}
<div
style={{
height: 56,
background: "var(--bg-card-alt)",
borderRadius: 12,
marginBottom: 16,
}}
/>
{/* KPI skeleton */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 16,
}}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
height: 70,
background: "var(--bg-card-alt)",
borderRadius: 10,
}}
/>
))}
</div>
<div
style={{
height: 320,
background: "var(--bg-card-alt)",
borderRadius: 12,
}}
/>
</section>
);
}
// ── Section 1 error state ─────────────────────────────────────────────────────
function Section1Error({ message }: { message: string }) {
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<h2
style={{
margin: "0 0 8px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
1. Объект
</h2>
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--danger)",
}}
role="alert"
>
{message}
</p>
</section>
);
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
export function Section1ParcelInfo({ cad }: Props) {
const analyzeQuery = useParcelAnalyzeQuery(cad);
const poiQuery = useParcelPoiScoreQuery(cad);
if (analyzeQuery.isLoading) {
return <Section1Skeleton />;
}
if (analyzeQuery.isError || !analyzeQuery.data) {
return (
<Section1Error
message={
analyzeQuery.error instanceof Error
? analyzeQuery.error.message
: "Не удалось загрузить данные участка"
}
/>
);
}
const data = analyzeQuery.data;
const poiData = poiQuery.data;
// EGRN: adapt backend `egrn_block` (snake_case ЕГРН-cad_parcels columns) into
// the frontend ParcelEgrn shape. Backend ships `permitted_use_text` /
// `land_category` / `ownership_type` / `parcel_status` / `last_egrn_update_date`;
// frontend table & CSV expect `vri` / `category` / `owner_type` / `status` /
// `last_updated`. Without the adapter 6/10 rows of the ЕГРН table render
// empty in prod (#1217). Empty `{}` from backend → null → fallback stub.
const egrn: ParcelEgrn = adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad);
// KPI values
const areaHa =
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
const district = data.district?.district_name ?? "—";
const medianPrice = data.district?.median_price_per_m2
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс. ₽/м²`
: "—";
const scoreStr = `${data.score}`;
// Score color
const scoreColor =
data.score >= 80
? "green"
: data.score >= 65
? "blue"
: data.score >= 45
? "amber"
: "red";
// HeadlineBar texts
const headlineTitle = scoreVerdict(data.score, data.score_label);
// #1467: при отсутствии score_explanation (старый деплой / частичный мок /
// backend без поля) НЕ выдумываем дельту к району — формируем подзаголовок
// только из реально присутствующих полей (POI в радиусе 1 км).
const headlineSubtitle = data.score_explanation
? data.score_explanation
: `${data.poi_count} POI в радиусе 1 км`;
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
{/* Section title + verify-links на официальные источники (#97) */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
margin: "0 0 12px",
}}
>
<h2
style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
1. Объект
</h2>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<NspdVerifyLink cadNum={cad} source="nspd" />
<NspdVerifyLink cadNum={cad} source="pkk" />
</div>
</div>
{/* HeadlineBar — verdict */}
<div style={{ marginBottom: 16 }}>
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
</div>
{/* KPI row — 4 cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 20,
}}
>
<KpiCard value={areaHa} label="Площадь" color="neutral" />
<KpiCard value={district} label="Район" color="neutral" />
<KpiCard value={medianPrice} label="Медиана рынка" color="blue" />
<KpiCard value={scoreStr} label="Инвест-балл" color={scoreColor} />
</div>
{/* 2-column grid: map (left) + EGRN + POI (right) */}
<div
style={{
display: "grid",
gridTemplateColumns: "400px 1fr",
gap: 16,
alignItems: "start",
marginBottom: 20,
}}
>
{/* Left: mini-map */}
<MiniMap data={data} />
{/* Right: EGRN table + POI list */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<EgrnPropertyTable data={egrn} />
{poiData ? (
<PoiList2Gis
items={poiData.items}
totalScore={poiData.poi_weighted_score}
/>
) : (
// Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
// упал. score_breakdown несёт только name/distance_m — без реального
// per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
// инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
// фабрикуем weight/score_contribution (#1466). Показываем честный
// пустой стейт «POI данные недоступны» до прихода B6.
<PoiList2Gis items={[]} totalScore={0} />
)}
</div>
</div>
{/* Export buttons */}
<div
style={{
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<ExportButtons cad={cad} analyzeData={data} egrn={egrn} />
</div>
</section>
);
}