gendesign/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
Light1YT 6894c297d6
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m11s
CI / openapi-codegen-check (pull_request) Successful in 2m13s
feat(site-finder): прогрессивное раскрытие секций отчёта §1–§6
Каждая секция показывает по умолчанию только основную информацию (вердикт-плашка
+ KPI-ряд), остальное (карты, таблицы, графики, разбивки) — под единым тумблером
«Раскрыть всё ▾». Выполнение запроса: «на каждом этапе только основную информацию,
далее жмёшь раскрыть и показывает всё что у нас есть».

- StageDetails — примитив-тумблер: default-collapsed, conditional-mount детей
  (НЕ display:none — чтобы Leaflet-карты инициализировались при раскрытии),
  aria-expanded, токены, Unicode-каретка.
- §1 Объект: свёрнуты 2-кол грид (карта+ЕГРН/НСПД/POI) + экспорт.
- §2 Оценка: свёрнуты risk-cards + ЗОУИТ + рекомендация.
- §3 Сети: свёрнуты предупреждение ЛЭП + счётчики + таблица + карта + caveat.
- §4 Рынок: свёрнуты настройки+таблица+блоки; CompetitorDetailDrawer оставлен
  смонтированным вне тумблера.
- §5 Атмосфера: свёрнут сезонный климат.
- §6 Прогноз: свёрнуты график + 6.1–6.6 + футер.
- §7 Концепция — не тронут (уже сам по себе progressive: форма → авто-результат).
2026-06-30 20:27:53 +05:00

359 lines
12 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 { NspdRegulationCard } from "./NspdRegulationCard";
import { NspdVerifyLink } from "./NspdVerifyLink";
import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
import { StageDetails } from "./StageDetails";
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>
<StageDetails>
{/* 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} />
{/* #1962 — реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
Раньше отчёт показывал только мёртвый zoning.data_available=false
(закрытый PKK6-путь) и НЕ выводил резолвленный регламент. КСИТ-ёмкость
считается от площади ЕГРН (egrn.area_m2). */}
<NspdRegulationCard
zoning={data.nspd_zoning}
areaM2={
Number.isFinite(egrn.area_m2) && egrn.area_m2 > 0
? egrn.area_m2
: null
}
/>
{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>
</StageDetails>
</section>
);
}