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