gendesign/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
bot-backend 02f49e795c
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m16s
feat(report): nav scrollspy + offset-jump, чат в плавающее окно справа (#958) (#2104)
2026-06-30 10:29:02 +00:00

628 lines
23 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 { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { ChatDock } from "@/components/site-finder/ChatDock";
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 { adaptEgrn, 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 ?? "—";
// Площадь крошки — из ТОГО ЖЕ источника, что KPI «Площадь» и ЕГРН-таблица в
// Section1: ЕГРН-area (egrn_block.area_m2 через adaptEgrn), а НЕ geometry-area
// (geometry_suitability.area_ha считается по геометрии кадастрового квартала и
// расходится с зарегистрированной площадью участка — давало 6.61 vs 11.68 га).
const egrnAreaM2 = adaptEgrn(analysis.egrn, cad)?.area_m2;
const areaStr =
egrnAreaM2 != null && Number.isFinite(egrnAreaM2) && egrnAreaM2 > 0
? `${(egrnAreaM2 / 10000).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} />
</div>
</div>
{/* Chat — grounded parcel-chat over the §22 forecast (#958). Docked
right-side floating panel (position: fixed), доступен при любом
скролле; mount once, placement in the tree is irrelevant. */}
<ChatDock cadNum={cad} />
</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. Концепция" },
],
},
];
// Top-level section anchors in scroll order — observed by the scrollspy.
// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit
// while the user is anywhere inside the section, which reads cleaner.
const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) =>
g.items.map((i) => i.id),
);
// Sticky chrome offset (header + breadcrumb). The jump lands the section title
// just below this; matches scrollMarginTop used on the section cards.
const SCROLL_OFFSET = 80;
// Robust jump: bare scrollIntoView({behavior:"smooth"}) can land short when
// async content (§6 forecast loads ~20-40s later) shifts heights mid-animation,
// and it ignores the sticky offset. Compute an absolute target relative to the
// document and scroll there, accounting for the sticky chrome.
function scrollToSection(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
window.scrollTo({ top, behavior: "smooth" });
}
function AnalysisSidebarNav() {
// Scrollspy: highlight whichever top-level section is currently near the top
// of the viewport. Default to the first so something is lit before any scroll.
const [activeId, setActiveId] = useState<string>(TOP_LEVEL_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// SSR / unsupported-env guard.
if (
typeof window === "undefined" ||
typeof IntersectionObserver === "undefined"
) {
return;
}
const targets = TOP_LEVEL_IDS.map((id) =>
document.getElementById(id),
).filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
// Track the set of currently-intersecting sections; the active one is the
// first (topmost in scroll order) still inside the band. rootMargin pushes
// the band's top down past the sticky chrome and shrinks its bottom so the
// "active" section is the one near the top of the viewport — not whatever
// merely peeks in from below.
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
const next = TOP_LEVEL_IDS.find((id) => visible.has(id));
if (next) setActiveId(next);
},
{ root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 },
);
targets.forEach((el) => observer.observe(el));
observerRef.current = observer;
return () => {
observer.disconnect();
observerRef.current = null;
};
}, []);
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) => {
const isActive = activeId === item.id;
return (
<div key={item.id}>
<button
type="button"
onClick={() => scrollToSection(item.id)}
aria-current={isActive ? "true" : undefined}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 16px",
background: isActive
? "var(--accent-soft, #DBEAFE)"
: "none",
border: "none",
borderLeft: isActive
? "3px solid var(--accent, #1D4ED8)"
: "3px solid transparent",
cursor: "pointer",
fontSize: 13,
fontWeight: isActive ? 600 : 500,
color: isActive
? "var(--accent, #1D4ED8)"
: "var(--fg-primary, #111111)",
transition: "background 0.1s, color 0.1s",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = "none";
}}
>
{item.label}
</button>
{item.children?.map((child) => (
<button
key={child.id}
type="button"
onClick={() => scrollToSection(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>
</>
);
}