feat(sf-fe-a7): Section 3.1 «Настройки выборки» + Server/Client split (rebased) (#356)
This commit is contained in:
parent
307a75f333
commit
68b1968826
3 changed files with 800 additions and 167 deletions
|
|
@ -0,0 +1,355 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
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 { 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 { data, isLoading, error } = useParcelAnalyzeQuery(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} />
|
||||||
|
|
||||||
|
{/* 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 placeholder — filled by A10 */}
|
||||||
|
<section id="section-4" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<SectionPlaceholder
|
||||||
|
number="4"
|
||||||
|
title="Оценка участка"
|
||||||
|
note="Заполняется в A10: ScoreBreakdown, GateVerdict, риски"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 5 placeholder — filled by A11 */}
|
||||||
|
<section id="section-5" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<SectionPlaceholder
|
||||||
|
number="5"
|
||||||
|
title="Атмосфера / воздух"
|
||||||
|
note="Заполняется в A11: SeasonalWeather, air quality"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</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. Атмосфера" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,181 +1,45 @@
|
||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { use } from "react";
|
import { AnalysisPageContent } from "./AnalysisPageContent";
|
||||||
import { LayoutDashboard } from "lucide-react";
|
|
||||||
|
|
||||||
import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar";
|
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||||
import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb";
|
|
||||||
import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar";
|
|
||||||
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
|
||||||
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
|
||||||
|
|
||||||
// ── Section placeholder (Wave 3–4 refactor will replace these) ─────────────────
|
|
||||||
|
|
||||||
interface SectionPlaceholderProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionPlaceholder({ id, title, note }: SectionPlaceholderProps) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id={id}
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 20,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
margin: "0 0 8px",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--fg-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{note && (
|
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-tertiary)" }}>
|
|
||||||
{note}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: "24px 16px",
|
|
||||||
background: "var(--bg-card-alt)",
|
|
||||||
border: "1px dashed var(--border-strong)",
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutDashboard
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ marginRight: 8, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
Заполняется в Wave 2–4 (A5–A11)
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ cad: string }>;
|
params: Promise<{ cad: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalysisPage({ params }: PageProps) {
|
export async function generateMetadata({
|
||||||
// React 19: use() unwraps Promise params (app router pattern).
|
params,
|
||||||
// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once here so
|
}: PageProps): Promise<Metadata> {
|
||||||
// downstream hooks/components see the canonical "66:41:0204016:10" and can
|
const { cad } = await params;
|
||||||
// re-encode exactly once when building API URLs / hrefs (prevents double-
|
return {
|
||||||
// encode that made backend regex reject the cad with HTTP 400).
|
title: `Анализ ${cad} — Site Finder`,
|
||||||
const { cad: cadRaw } = use(params);
|
description: `Анализ инвестиционного участка ${cad}`,
|
||||||
const cad = decodeURIComponent(cadRaw);
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default async function AnalysisPage({ params }: PageProps) {
|
||||||
|
const { cad } = await params;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<Suspense
|
||||||
style={{
|
fallback={
|
||||||
minHeight: "100vh",
|
<div
|
||||||
background: "var(--bg-app)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
|
||||||
<header
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
borderBottom: "1px solid var(--border-card)",
|
|
||||||
padding: "12px 24px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
minHeight: 56,
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AnalysisBreadcrumb cadNum={cad} />
|
|
||||||
|
|
||||||
{/* Push avatar to the right */}
|
|
||||||
<div style={{ marginLeft: "auto" }}>
|
|
||||||
<UserAvatar />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
|
|
||||||
<div style={{ display: "flex", flex: 1 }}>
|
|
||||||
<AnalysisSidebar />
|
|
||||||
|
|
||||||
{/* Main scrollable content */}
|
|
||||||
<article
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
padding: "40px 24px",
|
||||||
padding: "24px 32px",
|
textAlign: "center",
|
||||||
overflowY: "auto",
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
maxWidth: 1000,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Section 1 — A5: Инфо об участке */}
|
Загрузка анализа…
|
||||||
<Section1ParcelInfo cad={cad} />
|
</div>
|
||||||
|
}
|
||||||
{/* Section 2 — A6: Сети и точки подключения */}
|
>
|
||||||
<Section2NetworksUtilities cad={cad} />
|
<AnalysisPageContent cad={cad} />
|
||||||
|
</Suspense>
|
||||||
{/* Section 3 — TODO A7/A8/A9 */}
|
|
||||||
<SectionPlaceholder
|
|
||||||
id="section-3"
|
|
||||||
title="3. Рынок"
|
|
||||||
note="TODO A7: WeightProfilePanel (настройки выборки) · A8: BestLayoutsBlock / Pipeline24moBlock / VelocityBlock · A9: CompetitorTable с drawer drill-in"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sub-section anchor targets (invisible) for scrollspy / direct links */}
|
|
||||||
<div
|
|
||||||
id="section-3-1"
|
|
||||||
style={{ height: 0, overflow: "hidden" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
id="section-3-2"
|
|
||||||
style={{ height: 0, overflow: "hidden" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
id="section-3-3"
|
|
||||||
style={{ height: 0, overflow: "hidden" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Section 4 — TODO A10 */}
|
|
||||||
<SectionPlaceholder
|
|
||||||
id="section-4"
|
|
||||||
title="4. Инфраструктура"
|
|
||||||
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Section 5 — TODO A11 */}
|
|
||||||
<SectionPlaceholder
|
|
||||||
id="section-5"
|
|
||||||
title="5. Свежесть"
|
|
||||||
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||||
|
import {
|
||||||
|
POI_DEFAULT_WEIGHTS,
|
||||||
|
type PoiCategoryKey,
|
||||||
|
} from "@/lib/api/weightProfiles";
|
||||||
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Cadastral number — passed for context/future backend pass-through. */
|
||||||
|
cad: string;
|
||||||
|
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||||
|
data: ParcelAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
/** Radius in km, 1–5. */
|
||||||
|
radiusKm: number;
|
||||||
|
/** Show only buildings under construction. */
|
||||||
|
onlyUnderConstruction: boolean;
|
||||||
|
/** Completion deadline ≥ 3 months from now. */
|
||||||
|
minDeadline3mo: boolean;
|
||||||
|
/** Project started ≤ 6 months ago. */
|
||||||
|
maxAge6mo: boolean;
|
||||||
|
/** Apartments only (excludes commercial / parking-only). */
|
||||||
|
onlyApartments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ChipProps {
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ label, selected, onToggle }: ChipProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: selected
|
||||||
|
? "1px solid var(--accent, #1D4ED8)"
|
||||||
|
: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
background: selected
|
||||||
|
? "var(--accent-soft, #DBEAFE)"
|
||||||
|
: "var(--bg-card, #FFFFFF)",
|
||||||
|
color: selected
|
||||||
|
? "var(--accent, #1D4ED8)"
|
||||||
|
: "var(--fg-secondary, #5B6066)",
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||||||
|
}}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section31Settings({
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
}: {
|
||||||
|
filters: FilterState;
|
||||||
|
onFiltersChange: (f: FilterState) => void;
|
||||||
|
}) {
|
||||||
|
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||||||
|
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||||
|
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWeightsChange(
|
||||||
|
newWeights: Record<PoiCategoryKey, number>,
|
||||||
|
_profileId: number | null,
|
||||||
|
) {
|
||||||
|
setWeights(newWeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chips: Array<{
|
||||||
|
key: keyof Omit<FilterState, "radiusKm">;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "onlyUnderConstruction", label: "Только строящиеся" },
|
||||||
|
{ key: "minDeadline3mo", label: "+3 мес срок" },
|
||||||
|
{ key: "maxAge6mo", label: "−6 мес от сегодня" },
|
||||||
|
{ key: "onlyApartments", label: "Только квартиры" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="section-3-1" style={{ scrollMarginTop: 72 }}>
|
||||||
|
{/* Sub-section title */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3.1 Настройки выборки
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||||||
|
бэкенду
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "16px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Radius slider */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor="radius-slider"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Радиус выборки
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filters.radiusKm} км
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="radius-slider"
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={0.5}
|
||||||
|
value={filters.radiusKm}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
radiusKm: parseFloat(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
accentColor: "var(--accent, #1D4ED8)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--fg-tertiary, #73767E)",
|
||||||
|
marginTop: 4,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>1 км</span>
|
||||||
|
<span>5 км</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Фильтры объектов
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{chips.map(({ key, label }) => (
|
||||||
|
<FilterChip
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
selected={filters[key]}
|
||||||
|
onToggle={() => toggleChip(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight profile — reuse existing panel */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Профиль весов POI
|
||||||
|
</div>
|
||||||
|
<WeightProfilePanel
|
||||||
|
currentWeights={weights}
|
||||||
|
onWeightsChange={handleWeightsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 3.2 placeholder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section32Placeholder() {
|
||||||
|
return (
|
||||||
|
<div id="section-3-2" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3.2 Планировки конкурентов
|
||||||
|
</h3>
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Раздел появится в A8 — планировки конкурентов (BestLayoutsBlock)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 3.3 placeholder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section33Placeholder() {
|
||||||
|
return (
|
||||||
|
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary, #111111)",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3.3 Остатки и скорость продаж
|
||||||
|
</h3>
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Раздел появится в A8 — Pipeline24mo / Velocity / MarketTrend
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived: filter competitors client-side ───────────────────────────────────
|
||||||
|
|
||||||
|
function applyFilters(
|
||||||
|
data: ParcelAnalysis,
|
||||||
|
filters: FilterState,
|
||||||
|
): ParcelAnalysis["competitors"] {
|
||||||
|
let result = [...data.competitors];
|
||||||
|
|
||||||
|
// Radius filter — distance_m is in metres from the parcel centroid
|
||||||
|
result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
|
||||||
|
|
||||||
|
// Note: the competitor type doesn't carry status/date fields yet (B6 extension).
|
||||||
|
// The chips are wired to state but can't filter until backend enriches data.
|
||||||
|
// They're kept in state so backend pass-through (Wave 4) can use them.
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
radiusKm: 2,
|
||||||
|
onlyUnderConstruction: false,
|
||||||
|
minDeadline3mo: false,
|
||||||
|
maxAge6mo: false,
|
||||||
|
onlyApartments: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCompetitors = applyFilters(data, filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="section-3" style={{ scrollMarginTop: 72 }}>
|
||||||
|
{/* Section headline bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-headline, #0F172A)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "14px 18px",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-on-dark, #E2E8F0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3. Продажи конкурентов
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-on-dark-muted, #94A3B8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.competitors.length > 0
|
||||||
|
? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км`
|
||||||
|
: "Конкуренты не найдены в радиусе анализа"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-sections */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||||
|
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||||||
|
<Section32Placeholder />
|
||||||
|
<Section33Placeholder />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered competitors count hint */}
|
||||||
|
{data.competitors.length > 0 &&
|
||||||
|
filteredCompetitors.length !== data.competitors.length && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--warn-soft, #FEF3C7)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--warn, #9A6700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
|
||||||
|
{data.competitors.length} конкурентов. Разделы 3.2 и 3.3 отобразят
|
||||||
|
данные с учётом выборки после A8.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue