gendesign/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
bot-backend 86f0797499
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 3m8s
Deploy / deploy (push) Successful in 1m19s
fix(site-finder): report microcopy batch A — gate titles, breadcrumb area, §4.2/4.3 (#1953) (#2095)
2026-06-29 08:29:34 +00:00

549 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 { 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} />
{/* 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>
</>
);
}