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 { LayoutDashboard } from "lucide-react";
|
||||
import { AnalysisPageContent } from "./AnalysisPageContent";
|
||||
|
||||
import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar";
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ cad: string }>;
|
||||
}
|
||||
|
||||
export default function AnalysisPage({ params }: PageProps) {
|
||||
// React 19: use() unwraps Promise params (app router pattern).
|
||||
// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once here so
|
||||
// downstream hooks/components see the canonical "66:41:0204016:10" and can
|
||||
// re-encode exactly once when building API URLs / hrefs (prevents double-
|
||||
// encode that made backend regex reject the cad with HTTP 400).
|
||||
const { cad: cadRaw } = use(params);
|
||||
const cad = decodeURIComponent(cadRaw);
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const { cad } = await params;
|
||||
return {
|
||||
title: `Анализ ${cad} — Site Finder`,
|
||||
description: `Анализ инвестиционного участка ${cad}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default async function AnalysisPage({ params }: PageProps) {
|
||||
const { cad } = await params;
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
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
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "24px 32px",
|
||||
overflowY: "auto",
|
||||
maxWidth: 1000,
|
||||
padding: "40px 24px",
|
||||
textAlign: "center",
|
||||
color: "var(--fg-tertiary, #73767E)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{/* Section 1 — A5: Инфо об участке */}
|
||||
<Section1ParcelInfo cad={cad} />
|
||||
|
||||
{/* Section 2 — A6: Сети и точки подключения */}
|
||||
<Section2NetworksUtilities cad={cad} />
|
||||
|
||||
{/* 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>
|
||||
Загрузка анализа…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent cad={cad} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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