gendesign/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
Light1YT ed72e0ba1d
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
feat(forecast): render product_tz (ТЗ на ЖК) as in-app subsection 6.4
Adds «6.4 Рекомендация по продукту» to Section6Forecast — the §13.4
product recommendation (class, unit-mix, commercial, USP, §16 reasons)
that previously existed only in the export renderers. Unit-mix is shown
as token-colored horizontal bars; since the assembler emits no per-format
share (pct), bars are honestly labeled as a deficit-by-format signal, not
a build-share. Graceful on thin/202-pending/partial reports.

- types/forecast.ts: ReportProductTz + nested interfaces, optional on ForecastReport
- ForecastProductTzBlock.tsx: per-field guards, role=img bars, reasons disclosure
- forecast-helpers.ts: deficitBarWidthPct
- Section6Forecast + sidebar nav: wire 6.4

Part of #958.
2026-06-07 14:55:17 +05:00

394 lines
13 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 { 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 { 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} />
<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} />
<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} />
{/* Controls row — horizon selector drives the analyze + forecast queries */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 16,
}}
>
<HorizonSelector
value={horizon}
onChange={handleHorizonChange}
disabled={isFetching}
/>
</div>
{/* Page layout: sidebar (240px) + main scroll */}
<div
style={{
display: "grid",
gridTemplateColumns: "240px 1fr",
gap: 24,
marginTop: 20,
alignItems: "start",
}}
>
{/* ── Sidebar nav ───────────────────────────────────────────────── */}
<aside
style={{
position: "sticky",
top: 72,
alignSelf: "start",
}}
>
<AnalysisSidebarNav />
</aside>
{/* ── Main scroll area ──────────────────────────────────────────── */}
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{/* Section 1 — IMPLEMENTED in A5 */}
<Section1ParcelInfo cad={cad} />
{/* Section 2 — IMPLEMENTED in A6 */}
<Section2NetworksUtilities cad={cad} />
{/* Section 3 — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* Section 4 — IMPLEMENTED in A10 */}
<Section4Estimate cad={cad} />
{/* Section 5 — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} selectedHorizon={horizon} />
</div>
</div>
</main>
);
}
// ── 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>
);
}
// ── Sidebar nav ────────────────────────────────────────────────────────────────
const NAV_ITEMS = [
{ id: "section-1", label: "1. Информация" },
{ id: "section-2", label: "2. Сети" },
{
id: "section-3",
label: "3. Продажи конкурентов",
children: [
{ id: "section-3-1", label: "3.1 Настройки выборки" },
{ id: "section-3-2", label: "3.2 Планировки" },
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
],
},
{ id: "section-4", label: "4. Оценка" },
{ 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 Рекомендация по продукту" },
],
},
];
function AnalysisSidebarNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
return (
<nav
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 12,
padding: "12px 0",
display: "flex",
flexDirection: "column",
}}
>
{NAV_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>
{"children" in item &&
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>
))}
</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>
</>
);
}