gendesign/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
Light1YT 6adba229f0
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Successful in 1m45s
fix(site-finder): wire encumbrance/obj_class/ЗОУИТ display + forecast axis & flat-state (#1953)
Four frontend display fixes for the Site Finder analysis report (audit #1953);
all consume fields the (already-deployed) backend now returns.

- #1954: ЕГРН «Обременения» row was hardcoded «—». Wire top-level
  `encumbrance` block {has_zouit, zouit_count, zouit_types} via
  formatEncumbrance → «ЗОУИТ: N (types)» / «не выявлено (НСПД)».
- #1955: show obj_class per success bucket («Студии 15-30 · Комфорт») so
  rows with identical area-labels are distinct; fix top-row match + React key
  to (bucket, obj_class).
- #1957: NspdZouitOverlapsBlock renders human type_zone + reg_numb_border,
  adds a СЗЗ caveat banner (СанПиН 2.2.1/2.1.1.1200-03) and «СЗЗ вашего
  участка» highlight; switched raw hex to UI tokens.
- #1958: ForecastChart axis names → nameLocation:middle + nameGap (no legend
  overlap); grey flat-state callout when the deficit line is degenerate
  (scenarios_collapsed or all points clamped at ±1).

Tests: formatEncumbrance + isDeficitDegenerate (15 new). Real segment
differentiation is backend #1959 (separate).
2026-06-27 16:51:40 +05:00

342 lines
11 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,
formatEncumbrance,
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);
// #1954 — обременения живут в отдельном `encumbrance_block` (top-level
// `encumbrance`), а НЕ в `egrn_block`, поэтому adaptEgrn по дизайну ставит
// «—». Подставляем ЗОУИТ-сводку, чтобы строка «Обременения» ЕГРН-таблицы
// показывала реальные данные («ЗОУИТ: N (типы)» / «не выявлено (НСПД)»)
// вместо захардкоженного прочерка.
egrn.encumbrance = formatEncumbrance(data.encumbrance);
// 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 — пороги выровнены под калиброванную 040 шкалу _score_label
// (#1871 P2: было 0100, давало red на хорошем ~30/40 участке).
// <5 плохо · 515 средне · 1525 хорошо · ≥25 отлично.
const scoreColor =
data.score >= 25
? "green"
: data.score >= 15
? "blue"
: data.score >= 5
? "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="POI-скор · 040"
caption="оценочный"
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>
);
}