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.
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
"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)",
|
||
}}
|
||
>
|
||
·
|
||
</span>
|
||
)}
|
||
{part}
|
||
</span>
|
||
))}
|
||
<span style={{ margin: "0 4px", color: "var(--fg-tertiary, #73767E)" }}>
|
||
·
|
||
</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>
|
||
</>
|
||
);
|
||
}
|