From 318d30a881bc483983ae910b8e25e5471ebf971b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 03:36:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(sf-fe-a7):=20Section=203.1=20=C2=AB=D0=9D?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=C2=BB=20=E2=80=94=20radius/chips?= =?UTF-8?q?/weights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/AnalysisPageContent.tsx | 360 +++++++++++++++ .../app/site-finder/analysis/[cad]/page.tsx | 198 ++------- .../Section3SettingsAndCompetitors.tsx | 414 ++++++++++++++++++ frontend/src/hooks/useParcelAnalyzeQuery.ts | 32 ++ 4 files changed, 837 insertions(+), 167 deletions(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx create mode 100644 frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx create mode 100644 frontend/src/hooks/useParcelAnalyzeQuery.ts 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..47a61ef3 --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -0,0 +1,360 @@ +"use client"; + +import Link from "next/link"; + +import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; +import { useParcelAnalyzeQuery } from "@/hooks/useParcelAnalyzeQuery"; + +// ── 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 ───────────────────────────────────────────────────────── + + const districtName = data.district?.district_name ?? "—"; + const areaHa = data.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 placeholder — filled by A5 */} +
+ +
+ + {/* Section 2 placeholder — filled by 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 ( + + ); +} + +// ── 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 */} -
-
-
+ Загрузка анализа… + + } + > + + ); } 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 ( + + ); +} + +// ── 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. +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useParcelAnalyzeQuery.ts b/frontend/src/hooks/useParcelAnalyzeQuery.ts new file mode 100644 index 00000000..99444095 --- /dev/null +++ b/frontend/src/hooks/useParcelAnalyzeQuery.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; +import type { ParcelAnalysis } from "@/types/site-finder"; + +/** + * TanStack Query-based hook for analysis page route `/site-finder/analysis/[cad]`. + * + * Unlike `useSiteAnalysis` (mutation-based, triggered on form submit), this hook + * drives the analysis page where `cad` is a URL param — so we use `useQuery` + * for automatic fetch + caching. + * + * Note: the 202 Accepted on-demand fetch flow is NOT handled here — the analysis + * page assumes geometry is already available (the legacy page handles on-demand + * fetching and can redirect to this route once done). For now, a 202 response + * will surface as an error state. + */ +export function useParcelAnalyzeQuery(cad: string) { + return useQuery({ + queryKey: ["parcel-analyze", cad], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`, + { method: "POST" }, + ), + enabled: !!cad, + staleTime: 5 * 60 * 1000, // 5 min — backend analysis is expensive + retry: 1, + }); +}