gendesign/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
Light1YT 5d6895127b
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m14s
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 14m24s
fix(site-finder): invest-score advisory flag + velocity data-honesty + h1 a11y (#1926)
Остаток аудита #1871 (#1926), тема data-honesty #1953.

- invest_score: machine-readable score_is_advisory + score_advisory
  {kind=poi_density_heuristic, scale_min/scale_max=SCORE_MAX_REFERENCE} в
  analyze-payload + AnalyzeResponse (optional, backward-compat) + регенерён
  api-types.ts. Явно помечает score как POI-эвристику, не абсолютный рейтинг.
- velocity (Section3 VelocityBlock): явный провенанс объёма — бейдж
  «Источник: Objective» + видимый --warn-soft caveat когда объём по прокси/
  кад.кварталу, а не прямые продажи Objective. Поля провенанса уже доходили.
- a11y: <h1> во всех состояниях страницы анализа (был только в success).
- cleanup: удалён неиспользуемый export confidenceLevelToValue (latent risk).

units_sold-пункт #1926 закрыт ранее (#2054 inline _SOLD_COUNT_SQL).
2026-06-29 11:27:02 +05:00

542 lines
19 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";
import { useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { ChatPanel } from "@/components/site-finder/ChatPanel";
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate";
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
export function AnalysisPageContent({ cad }: Props) {
const [horizon, setHorizon] = useState<number>(12);
const queryClient = useQueryClient();
const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery(
cad,
horizon,
);
// Changing the horizon re-runs /analyze (expensive ~10-30s), which re-enqueues
// the async §22 forecast (recompute ~30-180s). Invalidating ["parcel-forecast",
// cad] forces Section 6's poll to re-fetch; until the new run is ready it may
// still show the previous run's emphasis. The selectedHorizon prop passed to
// Section6Forecast gives an instant client-side target highlight regardless —
// that's intended.
function handleHorizonChange(h: number) {
setHorizon(h);
void queryClient.invalidateQueries({ queryKey: ["parcel-forecast", cad] });
}
// ── Loading ────────────────────────────────────────────────────────────────
if (isLoading) {
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<PageHeading cad={cad} />
<div
style={{
marginTop: 24,
padding: "40px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 14,
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-soft, #EEF0F3)",
borderRadius: 12,
}}
>
Анализируем участок {cad}
</div>
</main>
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data) {
const msg =
error instanceof Error ? error.message : "Ошибка загрузки анализа";
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
<Breadcrumb cad={cad} />
<PageHeading cad={cad} />
<div
style={{
marginTop: 24,
padding: "16px 20px",
background: "var(--danger-soft, #FEE2E2)",
border: "1px solid var(--danger, #B3261E)",
borderRadius: 12,
color: "var(--danger, #B3261E)",
fontSize: 13,
}}
>
{msg}
</div>
<div style={{ marginTop: 16 }}>
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--accent, #1D4ED8)",
textDecoration: "none",
}}
>
Вернуться к Site Finder
</Link>
</div>
</main>
);
}
// ── Results layout ─────────────────────────────────────────────────────────
// Cast to ParcelAnalysis (superset of ParcelAnalyzeResponse) — both describe
// the same /analyze endpoint; Section3 requires the richer type.
const analysis = data as unknown as ParcelAnalysis;
const districtName = analysis.district?.district_name ?? "—";
const areaHa = analysis.geometry_suitability?.area_ha;
const areaStr = areaHa != null ? `${areaHa.toFixed(2)} га` : null;
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
{/* Breadcrumb */}
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
<PageHeading cad={cad} districtName={districtName} />
{/* Controls row — horizon selector drives the analyze + forecast queries */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 16,
gap: 12,
}}
>
<HorizonSelector
value={horizon}
onChange={handleHorizonChange}
disabled={isFetching}
/>
</div>
{/* Page layout: sidebar (240px) + main scroll. На планшете/телефоне
.gd-grid-sidebar схлопывает грид в одну колонку (issue #66). */}
<div
className="gd-grid-sidebar"
style={{
display: "grid",
gridTemplateColumns: "240px 1fr",
gap: 24,
marginTop: 20,
alignItems: "start",
}}
>
{/* ── Sidebar nav ───────────────────────────────────────────────── */}
<aside
className="gd-sidebar"
style={{
position: "sticky",
top: 72,
alignSelf: "start",
}}
>
<AnalysisSidebarNav />
</aside>
{/* ── Main scroll area ──────────────────────────────────────────── */}
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{/* Вердикт-баннер участка — выше всех секций (#1741). */}
<GateVerdictBanner verdict={analysis.gate_verdict} />
{/* ── Группа «Участок» ──────────────────────────────────────── */}
<GroupDivider label="Участок" />
{/* 1. Информация — IMPLEMENTED in A5 */}
<Section1ParcelInfo cad={cad} />
{/* 2. Оценка участка — IMPLEMENTED in A10 */}
<Section4Estimate cad={cad} />
{/* 3. Сети и точки подключения — IMPLEMENTED in A6 */}
<Section2NetworksUtilities cad={cad} />
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
<GroupDivider label="Стройка и рынок" />
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />
{/* Статус пересчёта прогноза при смене горизонта (#1744) —
нейтральная серая строка вместо молчаливого disabled. */}
{isFetching && (
<div
role="status"
aria-live="polite"
style={{
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-tertiary, #73767E)",
transition: "opacity 0.2s",
}}
>
Пересчитываем прогноз на {horizon} мес
</div>
)}
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} selectedHorizon={horizon} />
{/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1).
Seeded из анализа (geom + financial_estimate), считается по
запросу; result lazy-mounted (Leaflet). */}
<Section7Concept analysis={analysis} />
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
<ChatPanel cadNum={cad} />
</div>
</div>
</main>
);
}
// ── Page heading (h1 a11y) ─────────────────────────────────────────────────────
//
// Единственный <h1> страницы анализа (#1926 a11y): присутствует во ВСЕХ состояниях
// (loading / error / success), не только в success — иначе SR-пользователь на этапе
// загрузки/ошибки остаётся без заголовка верхнего уровня. district дописывается
// когда известен (success). Стиль = h1-токен (22/28 weight 600, left-align).
function PageHeading({
cad,
districtName,
}: {
cad: string;
districtName?: string;
}) {
const hasDistrict = districtName != null && districtName !== "—";
return (
<h1
style={{
fontSize: 22,
fontWeight: 600,
margin: "12px 0 0",
color: "var(--fg-primary, #111111)",
}}
>
Анализ участка {cad}
{hasDistrict ? ` · ${districtName}` : ""}
</h1>
);
}
// ── Breadcrumb ─────────────────────────────────────────────────────────────────
function Breadcrumb({
cad,
districtName,
areaStr,
}: {
cad: string;
districtName?: string;
areaStr?: string | null;
}) {
const parts = [cad];
if (districtName && districtName !== "—") parts.push(districtName);
if (areaStr) parts.push(areaStr);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-secondary, #5B6066)",
}}
>
<Link
href="/site-finder"
style={{
color: "var(--accent, #1D4ED8)",
textDecoration: "none",
fontWeight: 500,
}}
>
Site Finder
</Link>
<span style={{ color: "var(--fg-tertiary, #73767E)" }}>/</span>
{parts.map((part, i) => (
<span key={i}>
{i > 0 && (
<span
style={{
margin: "0 6px",
color: "var(--fg-tertiary, #73767E)",
}}
>
&middot;
</span>
)}
{part}
</span>
))}
<span style={{ margin: "0 4px", color: "var(--fg-tertiary, #73767E)" }}>
&middot;
</span>
<span style={{ color: "var(--fg-tertiary, #73767E)" }}>анализ</span>
</div>
);
}
// ── Group divider ────────────────────────────────────────────────────────────
//
// Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон
// --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar.
function GroupDivider({ label }: { label: string }) {
return (
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "10px 18px",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
{label}
</div>
</div>
);
}
// ── Sidebar nav ────────────────────────────────────────────────────────────────
//
// Две группы с групп-хедерами (#1741). Нумерация секций сквозная 16 под новый
// порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6.
interface NavItem {
id: string;
label: string;
children?: { id: string; label: string }[];
}
interface NavGroup {
label: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
label: "Участок",
items: [
{ id: "section-1", label: "1. Информация" },
{ id: "section-4", label: "2. Оценка участка" },
{ id: "section-2", label: "3. Сети и точки подключения" },
],
},
{
label: "Стройка и рынок",
items: [
{
id: "section-3",
label: "4. Рынок и конкуренты",
children: [
{ id: "section-3-1", label: "4.1 Настройки выборки" },
{ id: "section-3-2", label: "4.2 Планировки" },
{ id: "section-3-3", label: "4.3 Остатки и скорость" },
],
},
{ id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз и рекомендация",
children: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
{ id: "section-7", label: "7. Концепция" },
],
},
];
function AnalysisSidebarNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
return (
<nav
className="gd-section-nav"
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "12px 0",
display: "flex",
flexDirection: "column",
}}
>
{NAV_GROUPS.map((group, gi) => (
<div key={group.label}>
<div
style={{
padding: gi === 0 ? "0 16px 6px" : "12px 16px 6px",
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--fg-tertiary, #73767E)",
}}
>
{group.label}
</div>
{group.items.map((item) => (
<div key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 16px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary, #111111)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{item.label}
</button>
{item.children?.map((child) => (
<button
key={child.id}
type="button"
onClick={() => scrollTo(child.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{child.label}
</button>
))}
</div>
))}
</div>
))}
</nav>
);
}
// ── Section placeholder ────────────────────────────────────────────────────────
function SectionPlaceholder({
number,
title,
note,
}: {
number: string;
title: string;
note: string;
}) {
return (
<>
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 12,
}}
>
<div
style={{
fontSize: 15,
fontWeight: 600,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
{number}. {title}
</div>
</div>
<div
style={{
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px dashed var(--border-strong, #D1D5DB)",
borderRadius: 12,
padding: "32px 24px",
textAlign: "center",
color: "var(--fg-tertiary, #73767E)",
fontSize: 13,
}}
>
{note}
</div>
</>
);
}