diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
new file mode 100644
index 00000000..2a4f762b
--- /dev/null
+++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
@@ -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 (
+
+
+
+ Анализируем участок {cad}…
+
+
+ );
+ }
+
+ // ── Error ──────────────────────────────────────────────────────────────────
+
+ if (error || !data) {
+ const msg =
+ error instanceof Error ? error.message : "Ошибка загрузки анализа";
+ return (
+
+
+
+ {msg}
+
+
+
+ Вернуться к Site Finder
+
+
+
+ );
+ }
+
+ // ── 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 (
+
+ {/* Breadcrumb */}
+
+
+ {/* Page layout: sidebar (240px) + main scroll */}
+
+ {/* ── Sidebar nav ───────────────────────────────────────────────── */}
+
+
+ {/* ── Main scroll area ──────────────────────────────────────────── */}
+
+ {/* Section 1 — IMPLEMENTED in A5 */}
+
+
+ {/* Section 2 — IMPLEMENTED in A6 */}
+
+
+ {/* Section 3 — IMPLEMENTED in A7 */}
+
+
+ {/* Section 4 placeholder — filled by A10 */}
+
+
+ {/* Section 5 placeholder — filled by A11 */}
+
+
+
+
+ );
+}
+
+// ── 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 (
+
+
+ Site Finder
+
+ /
+ {parts.map((part, i) => (
+
+ {i > 0 && (
+
+ ·
+
+ )}
+ {part}
+
+ ))}
+
+ ·
+
+ анализ
+
+ );
+}
+
+// ── 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_ITEMS.map((item) => (
+
+ 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}
+
+ {"children" in item &&
+ item.children?.map((child) => (
+ 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}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+// ── Section placeholder ────────────────────────────────────────────────────────
+
+function SectionPlaceholder({
+ number,
+ title,
+ note,
+}: {
+ number: string;
+ title: string;
+ note: string;
+}) {
+ return (
+ <>
+
+
+ {number}. {title}
+
+
+
+ {note}
+
+ >
+ );
+}
diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx
index a8698823..c3c8a854 100644
--- a/frontend/src/app/site-finder/analysis/[cad]/page.tsx
+++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx
@@ -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 (
-
-
- {title}
-
- {note && (
-
- {note}
-
- )}
-
-
- Заполняется в Wave 2–4 (A5–A11)
-
-
- );
-}
-
-// ── 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 {
+ const { cad } = await params;
+ return {
+ title: `Анализ ${cad} — Site Finder`,
+ description: `Анализ инвестиционного участка ${cad}`,
+ };
+}
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export default async function AnalysisPage({ params }: PageProps) {
+ const { cad } = await params;
return (
-
- {/* ── Header ─────────────────────────────────────────────────────────── */}
-
-
-
- {/* Push avatar to the right */}
-
-
-
-
-
- {/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
-
-
-
- {/* Main scrollable content */}
-
- {/* Section 1 — A5: Инфо об участке */}
-
-
- {/* Section 2 — A6: Сети и точки подключения */}
-
-
- {/* Section 3 — TODO A7/A8/A9 */}
-
-
- {/* Sub-section anchor targets (invisible) for scrollspy / direct links */}
-
-
-
-
- {/* Section 4 — TODO A10 */}
-
-
- {/* Section 5 — TODO A11 */}
-
-
-
-
+ Загрузка анализа…
+
+ }
+ >
+
+
);
}
diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
new file mode 100644
index 00000000..bb1818ac
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
@@ -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 (
+
+ {label}
+
+ );
+}
+
+// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
+
+function Section31Settings({
+ filters,
+ onFiltersChange,
+}: {
+ filters: FilterState;
+ onFiltersChange: (f: FilterState) => void;
+}) {
+ const [weights, setWeights] = useState>(
+ () => ({ ...POI_DEFAULT_WEIGHTS }),
+ );
+
+ function toggleChip(key: keyof Omit) {
+ onFiltersChange({ ...filters, [key]: !filters[key] });
+ }
+
+ function handleWeightsChange(
+ newWeights: Record,
+ _profileId: number | null,
+ ) {
+ setWeights(newWeights);
+ }
+
+ const chips: Array<{
+ key: keyof Omit;
+ label: string;
+ }> = [
+ { key: "onlyUnderConstruction", label: "Только строящиеся" },
+ { key: "minDeadline3mo", label: "+3 мес срок" },
+ { key: "maxAge6mo", label: "−6 мес от сегодня" },
+ { key: "onlyApartments", label: "Только квартиры" },
+ ];
+
+ return (
+
+ {/* Sub-section title */}
+
+
+ 3.1 Настройки выборки
+
+
+ Фильтры применяются к конкурентам локально — без повторного запроса к
+ бэкенду
+
+
+
+
+ {/* Radius slider */}
+
+
+
+ Радиус выборки
+
+
+ {filters.radiusKm} км
+
+
+
+ onFiltersChange({
+ ...filters,
+ radiusKm: parseFloat(e.target.value),
+ })
+ }
+ style={{
+ width: "100%",
+ accentColor: "var(--accent, #1D4ED8)",
+ }}
+ />
+
+ 1 км
+ 5 км
+
+
+
+ {/* Filter chips */}
+
+
+ Фильтры объектов
+
+
+ {chips.map(({ key, label }) => (
+ toggleChip(key)}
+ />
+ ))}
+
+
+
+ {/* Weight profile — reuse existing panel */}
+
+
+ Профиль весов POI
+
+
+
+
+
+ );
+}
+
+// ── Section 3.2 placeholder ────────────────────────────────────────────────────
+
+function Section32Placeholder() {
+ return (
+
+
+ 3.2 Планировки конкурентов
+
+
+ Раздел появится в A8 — планировки конкурентов (BestLayoutsBlock)
+
+
+ );
+}
+
+// ── Section 3.3 placeholder ────────────────────────────────────────────────────
+
+function Section33Placeholder() {
+ return (
+
+
+ 3.3 Остатки и скорость продаж
+
+
+ Раздел появится в A8 — Pipeline24mo / Velocity / MarketTrend
+
+
+ );
+}
+
+// ── 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({
+ radiusKm: 2,
+ onlyUnderConstruction: false,
+ minDeadline3mo: false,
+ maxAge6mo: false,
+ onlyApartments: false,
+ });
+
+ const filteredCompetitors = applyFilters(data, filters);
+
+ return (
+
+ {/* Section headline bar */}
+
+
+ 3. Продажи конкурентов
+
+
+ {data.competitors.length > 0
+ ? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км`
+ : "Конкуренты не найдены в радиусе анализа"}
+
+
+
+ {/* Sub-sections */}
+
+
+
+
+
+
+ {/* Filtered competitors count hint */}
+ {data.competitors.length > 0 &&
+ filteredCompetitors.length !== data.competitors.length && (
+
+ Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
+ {data.competitors.length} конкурентов. Разделы 3.2 и 3.3 отобразят
+ данные с учётом выборки после A8.
+
+ )}
+
+ );
+}