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).
342 lines
11 KiB
TypeScript
342 lines
11 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,
|
||
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 — пороги выровнены под калиброванную 0–40 шкалу _score_label
|
||
// (#1871 P2: было 0–100, давало red на хорошем ~30/40 участке).
|
||
// <5 плохо · 5–15 средне · 15–25 хорошо · ≥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-скор · 0–40"
|
||
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>
|
||
);
|
||
}
|