549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
"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)",
|
||
}}
|
||
>
|
||
·
|
||
</span>
|
||
)}
|
||
{part}
|
||
</span>
|
||
))}
|
||
<span style={{ margin: "0 4px", color: "var(--fg-tertiary, #73767E)" }}>
|
||
·
|
||
</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). Нумерация секций сквозная 1–6 под новый
|
||
// порядок: «Участок» → 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>
|
||
</>
|
||
);
|
||
}
|