Каждая секция показывает по умолчанию только основную информацию (вердикт-плашка + 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: форма → авто-результат).
359 lines
12 KiB
TypeScript
359 lines
12 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 { 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 — пороги выровнены под калиброванную 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>
|
||
|
||
<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>
|
||
);
|
||
}
|