feat(site-finder): PermitsNearbyBlock — разрешения на строительство в §6 (#2367 PR-2, frontend-half) (#2403)
This commit is contained in:
parent
828d691c0d
commit
ab250a132b
5 changed files with 523 additions and 3 deletions
|
|
@ -239,8 +239,14 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast).
|
||||||
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
permits_nearby (#2367) приходит из /analyze (не из forecast-
|
||||||
|
эндпоинта, который Section6Forecast self-fetch'ит) — прокидываем. */}
|
||||||
|
<Section6Forecast
|
||||||
|
cad={cad}
|
||||||
|
selectedHorizon={horizon}
|
||||||
|
permitsNearby={analysis.permits_nearby}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
|
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
|
||||||
Seeded из анализа (geom + financial_estimate), считается по
|
Seeded из анализа (geom + financial_estimate), считается по
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PermitsNearbyBlock — «Разрешения на строительство поблизости» (#2367 PR-2).
|
||||||
|
*
|
||||||
|
* Точный радиус-запрос (500 м) по официальному реестру ГИСОГД Свердловской
|
||||||
|
* области: разрешения на строительство (RS) и вводы в эксплуатацию (RV) рядом с
|
||||||
|
* участком. Ценность девелоперу — что реально строится/сдаётся в шаговой
|
||||||
|
* доступности (ранний сигнал будущей конкуренции/спроса).
|
||||||
|
*
|
||||||
|
* Источник данных — блок `permits_nearby` из /analyze. Backend держит его как
|
||||||
|
* `dict[str, Any] | None` (Pydantic extra=allow), поэтому в api-types.ts он
|
||||||
|
* приходит как generic-объект; форму типизируем здесь локально и валидируем
|
||||||
|
* обязательные поля в рантайме (`asPermitsNearby`), без `any`.
|
||||||
|
*
|
||||||
|
* Рендер:
|
||||||
|
* • total_count === 0 → нейтральная строка «В радиусе 500 м … не найдено»
|
||||||
|
* (блок НЕ скрывается — юзер видит «проверили, рядом нет»).
|
||||||
|
* • total_count > 0 → KPI-строка (RS / RV / ближайшее) + список items с
|
||||||
|
* progressive disclosure (первые 8 видимы, остальные под <details>, как в
|
||||||
|
* ResourceReservesCard / ForecastFutureSupplyBlock).
|
||||||
|
* • items_truncated → честная приписка «показаны ближайшие N из M».
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Building2, FileCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { Section } from "@/components/analytics/Section";
|
||||||
|
import { KpiCard } from "@/components/site-finder/KpiCard";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
|
||||||
|
// ── Локальная форма данных (типизируем сами — см. шапку) ─────────────────────
|
||||||
|
|
||||||
|
type PermitDocGroup = "RS" | "RV";
|
||||||
|
|
||||||
|
interface PermitItem {
|
||||||
|
doc_group: PermitDocGroup;
|
||||||
|
doc_name: string | null;
|
||||||
|
doc_num: string | null;
|
||||||
|
date_doc: string | null;
|
||||||
|
approved_organization: string | null;
|
||||||
|
distance_m: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermitsNearby {
|
||||||
|
radius_m: number;
|
||||||
|
total_count: number;
|
||||||
|
rs_count: number;
|
||||||
|
rv_count: number;
|
||||||
|
nearest_distance_m: number | null;
|
||||||
|
items: PermitItem[];
|
||||||
|
items_truncated: boolean;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Сырой `permits_nearby` из /analyze (generic-объект / null). Narrow'им в
|
||||||
|
* рантайме через asPermitsNearby — на входе `unknown`, чтобы не тащить сюда
|
||||||
|
* ложную строгость из api-types.ts.
|
||||||
|
*/
|
||||||
|
permitsNearby: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime narrowing (без any) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === "object" && v !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStr(v: unknown): string | null {
|
||||||
|
return typeof v === "string" && v.length > 0 ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPermitItem(v: unknown): PermitItem | null {
|
||||||
|
if (!isRecord(v)) return null;
|
||||||
|
const group =
|
||||||
|
v.doc_group === "RV" ? "RV" : v.doc_group === "RS" ? "RS" : null;
|
||||||
|
if (group === null) return null;
|
||||||
|
return {
|
||||||
|
doc_group: group,
|
||||||
|
doc_name: toStr(v.doc_name),
|
||||||
|
doc_num: toStr(v.doc_num),
|
||||||
|
date_doc: toStr(v.date_doc),
|
||||||
|
approved_organization: toStr(v.approved_organization),
|
||||||
|
distance_m: toNum(v.distance_m),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow сырого `permits_nearby` в строгую форму. Возвращает null, если объекта
|
||||||
|
* нет / это не объект / нет обязательного `total_count` (старый кэш без поля).
|
||||||
|
*/
|
||||||
|
function asPermitsNearby(raw: unknown): PermitsNearby | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const total = toNum(raw.total_count);
|
||||||
|
if (total === null) return null;
|
||||||
|
|
||||||
|
const items = Array.isArray(raw.items)
|
||||||
|
? raw.items.map(toPermitItem).filter((it): it is PermitItem => it !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
radius_m: toNum(raw.radius_m) ?? 500,
|
||||||
|
total_count: total,
|
||||||
|
rs_count: toNum(raw.rs_count) ?? 0,
|
||||||
|
rv_count: toNum(raw.rv_count) ?? 0,
|
||||||
|
nearest_distance_m: toNum(raw.nearest_distance_m),
|
||||||
|
items,
|
||||||
|
items_truncated: raw.items_truncated === true,
|
||||||
|
source: toStr(raw.source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Форматирование ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GROUP_LABEL: Record<PermitDocGroup, string> = {
|
||||||
|
RS: "Разрешение на строительство",
|
||||||
|
RV: "Ввод в эксплуатацию",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRuDate(raw: string | null): string {
|
||||||
|
if (!raw) return "—";
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeters(v: number | null): string {
|
||||||
|
return v != null ? `${Math.round(v).toLocaleString("ru")} м` : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первые N видимых сразу; остальные — под <details> (progressive disclosure,
|
||||||
|
// как в ResourceReservesCard / ForecastFutureSupplyBlock).
|
||||||
|
const HEAD_COUNT = 8;
|
||||||
|
|
||||||
|
// ── PermitsNearbyBlock ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PermitsNearbyBlock({ permitsNearby }: Props) {
|
||||||
|
const data = asPermitsNearby(permitsNearby);
|
||||||
|
|
||||||
|
// Поле отсутствует / null / старый кэш без формы — не рендерим блок совсем
|
||||||
|
// (о нём просто нечего сказать, в отличие от честного нуля).
|
||||||
|
if (data === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title="Разрешения на строительство поблизости"
|
||||||
|
subtitle={`Радиус ${data.radius_m.toLocaleString("ru")} м · официальный реестр ГИСОГД Свердловской области`}
|
||||||
|
>
|
||||||
|
{data.total_count === 0 ? (
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||||
|
В радиусе {data.radius_m.toLocaleString("ru")} м новых разрешений не
|
||||||
|
найдено.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<PermitsContent data={data} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Содержимое при total_count > 0 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function PermitsContent({ data }: { data: PermitsNearby }) {
|
||||||
|
const head = data.items.slice(0, HEAD_COUNT);
|
||||||
|
const rest = data.items.slice(HEAD_COUNT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* KPI-строка: RS / RV / ближайшее (паттерн KpiCard-грид). */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Разрешений на строительство"
|
||||||
|
value={data.rs_count.toLocaleString("ru")}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Вводов в эксплуатацию"
|
||||||
|
value={data.rv_count.toLocaleString("ru")}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Ближайшее, м"
|
||||||
|
value={
|
||||||
|
data.nearest_distance_m != null
|
||||||
|
? Math.round(data.nearest_distance_m).toLocaleString("ru")
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
color="neutral"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список разрешений (первые HEAD_COUNT видимы). */}
|
||||||
|
{data.items.length > 0 ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<PermitsTable items={head} />
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--accent)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Раскрыть всё · ещё {rest.length.toLocaleString("ru")}
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<PermitsTable items={rest} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Честная приписка, если бэкенд капнул список (cap 30). */}
|
||||||
|
{data.items_truncated && (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||||
|
Показаны ближайшие {data.items.length.toLocaleString("ru")} из{" "}
|
||||||
|
{data.total_count.toLocaleString("ru")}.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Таблица разрешений ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TH_STYLE: React.CSSProperties = {
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
borderBottom: "1px solid var(--border-card)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TD_STYLE: React.CSSProperties = {
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
verticalAlign: "top",
|
||||||
|
borderBottom: "1px solid var(--border-soft)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUM_TH: React.CSSProperties = { ...TH_STYLE, textAlign: "right" };
|
||||||
|
const NUM_TD: React.CSSProperties = {
|
||||||
|
...TD_STYLE,
|
||||||
|
textAlign: "right",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PermitsTable({ items }: { items: PermitItem[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{ width: "100%", borderCollapse: "collapse", minWidth: 640 }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={TH_STYLE}>Тип</th>
|
||||||
|
<th style={TH_STYLE}>Документ</th>
|
||||||
|
<th style={{ ...TH_STYLE, whiteSpace: "nowrap" }}>Дата</th>
|
||||||
|
<th style={NUM_TH}>Расстояние</th>
|
||||||
|
<th style={{ ...TH_STYLE, minWidth: 200 }}>Орган</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<PermitRow
|
||||||
|
key={`${it.doc_num ?? it.doc_name ?? "permit"}-${i}`}
|
||||||
|
item={it}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermitRow({ item }: { item: PermitItem }) {
|
||||||
|
const isRs = item.doc_group === "RS";
|
||||||
|
const label = GROUP_LABEL[item.doc_group];
|
||||||
|
// doc_name основной текст (в реестре он несёт полный «№ … от …»); при его
|
||||||
|
// отсутствии деградируем к doc_num.
|
||||||
|
const docText = item.doc_name ?? item.doc_num ?? "—";
|
||||||
|
const org = item.approved_organization;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td style={TD_STYLE}>
|
||||||
|
<Badge
|
||||||
|
variant={isRs ? "info" : "success"}
|
||||||
|
size="sm"
|
||||||
|
icon={
|
||||||
|
isRs ? (
|
||||||
|
<Building2 size={12} aria-hidden />
|
||||||
|
) : (
|
||||||
|
<FileCheck size={12} aria-hidden />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...TD_STYLE, maxWidth: 320 }}>{docText}</td>
|
||||||
|
<td style={{ ...TD_STYLE, whiteSpace: "nowrap" }}>
|
||||||
|
{formatRuDate(item.date_doc)}
|
||||||
|
</td>
|
||||||
|
<td style={NUM_TD}>{formatMeters(item.distance_m)}</td>
|
||||||
|
<td style={{ ...TD_STYLE, maxWidth: 280 }}>
|
||||||
|
{org ? (
|
||||||
|
<span
|
||||||
|
title={org}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{org}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
|
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
|
||||||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||||||
|
import { PermitsNearbyBlock } from "./PermitsNearbyBlock";
|
||||||
import { ForecastMetaLine } from "./ForecastMetaLine";
|
import { ForecastMetaLine } from "./ForecastMetaLine";
|
||||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||||
import { StageDetails } from "./StageDetails";
|
import { StageDetails } from "./StageDetails";
|
||||||
|
|
@ -56,6 +57,16 @@ interface Props {
|
||||||
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
||||||
*/
|
*/
|
||||||
selectedHorizon?: number;
|
selectedHorizon?: number;
|
||||||
|
/**
|
||||||
|
* Сырой `permits_nearby` из /analyze (#2367) — разрешения РС/ввод в
|
||||||
|
* эксплуатацию в радиусе 500 м. Приходит из analyze-payload (НЕ из forecast-
|
||||||
|
* эндпоинта, который self-fetch'ит эта секция), поэтому прокидывается пропом из
|
||||||
|
* AnalysisPageContent. `unknown` — backend держит его generic-объектом; форму
|
||||||
|
* narrow'ит PermitsNearbyBlock. Рендерится ПЕРВЫМ в секции §6, ДО forecast-
|
||||||
|
* контента и НЕЗАВИСИМО от статуса §22-прогноза (данные /analyze уже загружены,
|
||||||
|
* не должны ждать медленный несвязанный прогноз 30-180с).
|
||||||
|
*/
|
||||||
|
permitsNearby?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADVISORY_DISCLAIMER =
|
const ADVISORY_DISCLAIMER =
|
||||||
|
|
@ -73,16 +84,28 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
|
||||||
|
|
||||||
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
export function Section6Forecast({
|
||||||
|
cad,
|
||||||
|
selectedHorizon,
|
||||||
|
permitsNearby,
|
||||||
|
}: Props) {
|
||||||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||||||
|
|
||||||
const isPending =
|
const isPending =
|
||||||
isLoading || (data != null && data.status !== "ready") || data == null;
|
isLoading || (data != null && data.status !== "ready") || data == null;
|
||||||
|
|
||||||
|
// permits_nearby (#2367) приходит из /analyze (быстрый, уже загружен к этому
|
||||||
|
// рендеру), а НЕ из forecast-эндпоинта, который эта секция self-fetch'ит и
|
||||||
|
// который на холодном участке считается 30-180с. Рендерим блок разрешений
|
||||||
|
// ПЕРВЫМ и БЕЗУСЛОВНО — он не должен ждать §22-прогноз (юзер видит данные,
|
||||||
|
// которые уже есть). Дальше — обычная pending/error/ready-логика прогноза.
|
||||||
|
const permits = <PermitsNearbyBlock permitsNearby={permitsNearby} />;
|
||||||
|
|
||||||
// ── Pending: forecast still computing (poll in flight) ─────────────────────
|
// ── Pending: forecast still computing (poll in flight) ─────────────────────
|
||||||
if (isPending && !error) {
|
if (isPending && !error) {
|
||||||
return (
|
return (
|
||||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
|
{permits}
|
||||||
<HeadlineBar
|
<HeadlineBar
|
||||||
title="6. Прогноз"
|
title="6. Прогноз"
|
||||||
subtitle="Прогноз спроса, предложения и сценарии (§22)."
|
subtitle="Прогноз спроса, предложения и сценарии (§22)."
|
||||||
|
|
@ -137,6 +160,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||||
if (error || !data || data.status !== "ready" || !data.report) {
|
if (error || !data || data.status !== "ready" || !data.report) {
|
||||||
return (
|
return (
|
||||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
|
{permits}
|
||||||
<HeadlineBar title="6. Прогноз" />
|
<HeadlineBar title="6. Прогноз" />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -167,6 +191,7 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||||
report={data.report}
|
report={data.report}
|
||||||
runCreatedAt={data.created_at}
|
runCreatedAt={data.created_at}
|
||||||
selectedHorizon={selectedHorizon}
|
selectedHorizon={selectedHorizon}
|
||||||
|
permits={permits}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -178,12 +203,19 @@ function ForecastReady({
|
||||||
report,
|
report,
|
||||||
runCreatedAt,
|
runCreatedAt,
|
||||||
selectedHorizon,
|
selectedHorizon,
|
||||||
|
permits,
|
||||||
}: {
|
}: {
|
||||||
cad: string;
|
cad: string;
|
||||||
report: ForecastReport;
|
report: ForecastReport;
|
||||||
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
|
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
|
||||||
runCreatedAt?: string | null;
|
runCreatedAt?: string | null;
|
||||||
selectedHorizon?: number;
|
selectedHorizon?: number;
|
||||||
|
/**
|
||||||
|
* #2367 — готовый <PermitsNearbyBlock> (собран в Section6Forecast из
|
||||||
|
* analyze-payload). Рендерится ПЕРВЫМ в секции, до forecast-контента, чтобы
|
||||||
|
* не зависеть от статуса §22-прогноза (данные /analyze уже есть).
|
||||||
|
*/
|
||||||
|
permits: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const exec = report.exec_summary;
|
const exec = report.exec_summary;
|
||||||
const fm = report.future_market;
|
const fm = report.future_market;
|
||||||
|
|
@ -274,6 +306,9 @@ function ForecastReady({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
|
{/* Разрешения на строительство поблизости (#2367) — ПЕРВЫМ в секции, до
|
||||||
|
прогноза: приходит из /analyze (уже загружен), не ждёт §22-прогноз. */}
|
||||||
|
{permits}
|
||||||
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { PermitsNearbyBlock } from "../PermitsNearbyBlock";
|
||||||
|
|
||||||
|
// PermitsNearbyBlock — чистый компонент (нет useQuery), рендерим напрямую.
|
||||||
|
// Форму permits_nearby подаём как generic-объект (unknown на входе), как её
|
||||||
|
// отдаёт /analyze; проверяем honest-zero, RS/RV-подписи, truncated-приписку.
|
||||||
|
|
||||||
|
function buildPermit(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
doc_group: "RS",
|
||||||
|
doc_name: "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
|
||||||
|
doc_num: "66-45-08-2026",
|
||||||
|
date_doc: "2026-06-02",
|
||||||
|
approved_organization: "Комитет по архитектуре",
|
||||||
|
distance_m: 120.0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PermitsNearbyBlock", () => {
|
||||||
|
it("рендерит honest-zero строку при total_count === 0 (блок не скрыт)", () => {
|
||||||
|
render(
|
||||||
|
<PermitsNearbyBlock
|
||||||
|
permitsNearby={{
|
||||||
|
radius_m: 500,
|
||||||
|
total_count: 0,
|
||||||
|
rs_count: 0,
|
||||||
|
rv_count: 0,
|
||||||
|
nearest_distance_m: null,
|
||||||
|
items: [],
|
||||||
|
items_truncated: false,
|
||||||
|
source: "gisogd66",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Заголовок секции виден, и внутри — честная нейтральная строка.
|
||||||
|
expect(
|
||||||
|
screen.getByText("Разрешения на строительство поблизости"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/В радиусе 500 м новых разрешений не найдено/),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не рендерит ничего, когда permits_nearby отсутствует / null (старый кэш)", () => {
|
||||||
|
const { container: c1 } = render(
|
||||||
|
<PermitsNearbyBlock permitsNearby={null} />,
|
||||||
|
);
|
||||||
|
expect(c1.firstChild).toBeNull();
|
||||||
|
|
||||||
|
const { container: c2 } = render(
|
||||||
|
<PermitsNearbyBlock permitsNearby={undefined} />,
|
||||||
|
);
|
||||||
|
expect(c2.firstChild).toBeNull();
|
||||||
|
|
||||||
|
// Объект без обязательного total_count → тоже не рендерим.
|
||||||
|
const { container: c3 } = render(
|
||||||
|
<PermitsNearbyBlock permitsNearby={{ radius_m: 500 }} />,
|
||||||
|
);
|
||||||
|
expect(c3.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает человеко-читаемые подписи RS → «Разрешение на строительство», RV → «Ввод в эксплуатацию»", () => {
|
||||||
|
render(
|
||||||
|
<PermitsNearbyBlock
|
||||||
|
permitsNearby={{
|
||||||
|
radius_m: 500,
|
||||||
|
total_count: 2,
|
||||||
|
rs_count: 1,
|
||||||
|
rv_count: 1,
|
||||||
|
nearest_distance_m: 0,
|
||||||
|
items: [
|
||||||
|
buildPermit({ doc_group: "RS" }),
|
||||||
|
buildPermit({
|
||||||
|
doc_group: "RV",
|
||||||
|
doc_name: "Разрешение на ввод объекта в эксплуатацию",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
items_truncated: false,
|
||||||
|
source: "gisogd66",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Разрешение на строительство")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Ввод в эксплуатацию")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("НЕ показывает truncated-приписку, когда items_truncated === false", () => {
|
||||||
|
render(
|
||||||
|
<PermitsNearbyBlock
|
||||||
|
permitsNearby={{
|
||||||
|
radius_m: 500,
|
||||||
|
total_count: 1,
|
||||||
|
rs_count: 1,
|
||||||
|
rv_count: 0,
|
||||||
|
nearest_distance_m: 120,
|
||||||
|
items: [buildPermit()],
|
||||||
|
items_truncated: false,
|
||||||
|
source: "gisogd66",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Показаны ближайшие/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает truncated-приписку «показаны ближайшие N из M», когда items_truncated === true", () => {
|
||||||
|
const items = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
buildPermit({ doc_num: `66-${i}` }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<PermitsNearbyBlock
|
||||||
|
permitsNearby={{
|
||||||
|
radius_m: 500,
|
||||||
|
total_count: 42,
|
||||||
|
rs_count: 20,
|
||||||
|
rv_count: 22,
|
||||||
|
nearest_distance_m: 0,
|
||||||
|
items,
|
||||||
|
items_truncated: true,
|
||||||
|
source: "gisogd66",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// «Показаны ближайшие 30 из 42.»
|
||||||
|
expect(screen.getByText(/Показаны ближайшие 30 из 42/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -550,6 +550,11 @@ export interface ParcelAnalysis {
|
||||||
// это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
|
// это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
|
||||||
// на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
|
// на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
|
||||||
program_alternatives?: ProgramAlternatives | null;
|
program_alternatives?: ProgramAlternatives | null;
|
||||||
|
// #2367 — разрешения на строительство (RS) и вводы в эксплуатацию (RV) в
|
||||||
|
// радиусе 500 м по реестру ГИСОГД. Backend держит блок как dict[str, Any] |
|
||||||
|
// None, поэтому здесь — generic-объект; строгую форму narrow'ит
|
||||||
|
// PermitsNearbyBlock в рантайме. Может отсутствовать/быть null (старый кэш).
|
||||||
|
permits_nearby?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue