feat(sf-fe-a1): routes shell + _legacy/ backup + mock toggle wiring

- Move tab-based page.tsx to _legacy/site-finder-page-tabs.tsx (reference for Wave 2-4 section refactor)
- New /site-finder/page.tsx: карта-first entry shell with MapPlaceholder + SidebarPlaceholder (TODO A2: EntryMap / MapFilterBar / RecentParcels / ParcelLegend / ParcelDrawer)
- New /site-finder/analysis/[cad]/page.tsx: analysis shell with React 19 use(params), AnalysisSidebar placeholder, 5 SectionPlaceholder blocks (TODO A4-A11)
- New src/lib/mock-toggle.ts: USE_MOCKS master switch + per-endpoint flags (MOCK_PARCELS_BBOX / MOCK_RECENT_PARCELS / MOCK_LANDING_STATS / MOCK_ANALYZE / MOCK_POI_SCORE) for B1-B6 progressive rollout
This commit is contained in:
lekss361 2026-05-18 00:57:11 +03:00
parent bb7fbd5bc0
commit 87e6009f06
4 changed files with 991 additions and 531 deletions

View file

@ -0,0 +1,570 @@
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { FeatureCollection } from "geojson";
import { CadInput } from "@/components/site-finder/CadInput";
import { FetchingState } from "@/components/site-finder/FetchingState";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { OverviewTab } from "@/components/site-finder/OverviewTab";
import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
() => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap),
{
ssr: false,
loading: () => (
<div
style={{
height: 420,
border: "1px solid #e5e7eb",
borderRadius: 12,
background: "#f9fafb",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
Загрузка карты
</div>
),
},
);
type TabId = "overview" | "env" | "land" | "market";
const TABS: Array<{ id: TabId; label: string }> = [
{ id: "overview", label: "Обзор" },
{ id: "env", label: "Окружение" },
{ id: "land", label: "Земля" },
{ id: "market", label: "Рынок" },
];
const TAB_IDS = TABS.map((t) => t.id);
function isTabId(v: string | null | undefined): v is TabId {
return !!v && (TAB_IDS as readonly string[]).includes(v);
}
// ── KPI helpers ───────────────────────────────────────────────────────────────
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
function trendColor(pct: number): KpiColor {
if (pct > 5) return "green";
if (pct > 0) return "blue";
if (pct > -5) return "amber";
return "red";
}
function noiseColor(db: number): KpiColor {
if (db < 45) return "green";
if (db < 60) return "amber";
return "red";
}
function scoreKpiColor(score: number, max: number): KpiColor {
const ratio = score / max;
if (ratio >= 0.65) return "green";
if (ratio >= 0.35) return "amber";
return "red";
}
// ── Page ──────────────────────────────────────────────────────────────────────
function SiteFinderContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { mutate, data, isPending, error, isIdle, fetchingState, cancel } =
useSiteAnalysis();
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
undefined,
);
// Fetch connection points whenever a parcel is loaded
const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
// Fetch custom POIs for current parcel
const { data: customPois } = useCustomPois(data?.cad_num);
// Weight profile state — lifted here so it survives tab switches.
// userId + adminToken allow the panel to load/save named profiles.
const [currentWeights, setCurrentWeights] = useState<
Record<PoiCategoryKey, number>
>(() => ({ ...POI_DEFAULT_WEIGHTS }));
// Profile selection for the analyze call (null = system defaults).
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
// Pending weights change — set immediately when user clicks "Применить".
// Debounced to 300ms before triggering re-analyze, so rapid clicks collapse
// into a single request (#201 Phase 2).
const [pendingWeightsChange, setPendingWeightsChange] = useState<{
weights: Record<PoiCategoryKey, number>;
profileId: number | null;
} | null>(null);
const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300);
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
const weightsChangeInitializedRef = useRef(false);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => {
const t = searchParams.get("tab");
return isTabId(t) ? t : "overview";
});
// Sync tab → URL query (?tab=env). overview = default, не пишем в URL.
function setTab(next: TabId) {
setTabState(next);
const params = new URLSearchParams(searchParams.toString());
if (next === "overview") {
params.delete("tab");
} else {
params.set("tab", next);
}
const qs = params.toString();
// Используем pathname вместо голого "?" — Next 15 App Router не всегда
// чисто чистит query при `router.replace("?")` (dangling `?` остаётся).
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
// Listen to back/forward navigation — reflect URL tab in state.
// setState с тем же значением React сам бэйлит через Object.is, поэтому
// guard по tab не нужен (и не вводит stale-closure через eslint-disable).
useEffect(() => {
const urlTab = searchParams.get("tab");
setTabState(isTabId(urlTab) ? urlTab : "overview");
}, [searchParams]);
// Debounced re-analyze trigger: fires 300ms after the last "Применить" click.
// Skip initial mount (weightsChangeInitializedRef guards the first effect run).
// dataRef lets the effect read current data without listing it as a dep
// (avoids re-registering the timeout on every analyze completion).
const dataRef = useRef(data);
dataRef.current = data;
const profileUserIdRef = useRef(profileUserId);
profileUserIdRef.current = profileUserId;
useEffect(() => {
if (!weightsChangeInitializedRef.current) {
weightsChangeInitializedRef.current = true;
return;
}
if (!debouncedWeightsChange) return;
const currentData = dataRef.current;
if (!currentData?.cad_num) return;
const { weights, profileId } = debouncedWeightsChange;
const currentProfileUserId = profileUserIdRef.current;
setIsochrones(undefined);
mutate({
cad: currentData.cad_num,
options:
profileId != null
? { profileId }
: currentProfileUserId
? { profileUserId: currentProfileUserId, weights }
: { weights },
});
// mutate is stable from useMutation — safe to omit from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedWeightsChange]);
function handleAnalyze(cadNum: string) {
setIsochrones(undefined);
setTab("overview");
// Priority: named profile → user default profile → inline draft weights.
// When activeProfileId is set, backend uses that profile (ignores inline).
// When no profile is selected, pass currentWeights as inline so draft
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
});
}
function handleWeightsChange(
weights: Record<PoiCategoryKey, number>,
profileId: number | null,
) {
setCurrentWeights(weights);
setActiveProfileId(profileId);
// Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2).
// The actual mutate fires after 300ms of silence via debouncedWeightsChange.
if (data?.cad_num) {
setPendingWeightsChange({ weights, profileId });
}
}
// Derive KPI values from data
const scoreMax = data?.score_max_reference ?? 40;
const scoreValue = data
? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}`
: "—";
const scoreLabel = data?.score_label
? `Score · ${data.score_label}`
: "Score";
const scoreCardColor: KpiColor = data
? scoreKpiColor(data.score, scoreMax)
: "neutral";
const districtValue = data?.district ? data.district.district_name : "—";
const districtLabel = data?.district
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра`
: "Район";
const trendPct = data?.market_trend?.delta_6m_pct;
const trendValue =
trendPct != null
? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%`
: "—";
const trendLabel = data?.market_trend
? `Тренд за 6 мес · ${data.market_trend.label}`
: "Тренд рынка";
const trendKpiColor: KpiColor =
trendPct != null ? trendColor(trendPct) : "neutral";
const noiseDb = data?.noise?.estimated_db;
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
const noiseKpiColor: KpiColor =
noiseDb != null ? noiseColor(noiseDb) : "neutral";
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
{/* Breadcrumb + header */}
<div style={{ marginBottom: 12 }}>
<Link
href="/"
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
>
Главная
</Link>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
flexWrap: "wrap",
gap: 12,
}}
>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
Site Finder
</h1>
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 0" }}>
Введите кадастровый номер участка или квартала для анализа локации
</p>
</div>
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "14px 20px",
minWidth: 320,
}}
>
<CadInput onSubmit={handleAnalyze} loading={isPending} />
</div>
</div>
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}
{isPending && !!data && (
<p
style={{
fontSize: 12,
color: "#6b7280",
margin: "6px 0 0",
}}
>
Пересчёт
</p>
)}
</div>
{/* Error state */}
{error && (
<div
style={{
padding: "12px 20px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 10,
color: "#dc2626",
fontSize: 13,
marginBottom: 20,
}}
>
{error instanceof Error ? error.message : "Ошибка анализа участка"}
</div>
)}
{/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */}
{fetchingState && (
<FetchingState
cadNum={fetchingState.cadNum}
etaSeconds={fetchingState.etaSeconds}
onCancel={cancel}
/>
)}
{/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */}
{isPending && !fetchingState && (
<div
style={{
padding: 40,
textAlign: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
Анализируем участок
</div>
)}
{/* Empty initial state */}
{isIdle && !error && (
<div
style={{
padding: 40,
textAlign: "center",
color: "#d1d5db",
fontSize: 14,
border: "1px dashed #e5e7eb",
borderRadius: 12,
}}
>
Введите кадастровый номер и нажмите «Анализировать»
</div>
)}
{/* Results */}
{data && (
<>
{/* Cad label */}
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 10,
}}
>
{data.cad_num}
</div>
{/* Hero band — sticky KPI cards */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "#fff",
borderBottom: "1px solid #e5e7eb",
padding: "12px 0 14px",
marginBottom: 0,
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 12,
}}
>
<KpiCard
value={scoreValue}
label={scoreLabel}
color={scoreCardColor}
/>
<KpiCard
value={districtValue}
label={districtLabel}
color="neutral"
/>
<KpiCard
value={trendValue}
label={trendLabel}
color={trendKpiColor}
/>
<KpiCard
value={noiseValue}
label={noiseLabel}
color={noiseKpiColor}
/>
</div>
{/* Tab buttons */}
<div
style={{
display: "flex",
gap: 0,
borderBottom: "1px solid #e5e7eb",
marginBottom: 20,
background: "#fff",
position: "sticky",
top: 86,
zIndex: 19,
}}
>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
padding: "10px 20px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 14,
fontWeight: 500,
borderBottom:
tab === t.id
? "2px solid #1d4ed8"
: "2px solid transparent",
color: tab === t.id ? "#1d4ed8" : "#6b7280",
transition: "color 0.15s, border-color 0.15s",
}}
>
{t.label}
</button>
))}
</div>
{/* 2-column grid: tab content + sticky map */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 480px",
gap: 24,
alignItems: "start",
}}
>
{/* Tab content */}
<div>
{tab === "overview" && (
<OverviewTab data={data} onIsochronesResult={setIsochrones} />
)}
{tab === "env" && <EnvironmentTab data={data} />}
{tab === "land" && <LandTab data={data} />}
{tab === "market" && <MarketTab data={data} />}
</div>
{/* Sticky right sidebar — map always visible */}
<aside
style={{
position: "sticky",
top: 140,
alignSelf: "start",
maxHeight: "calc(100vh - 156px)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<SiteMap
data={data}
isochrones={isochrones}
connectionPoints={connectionPoints}
customPois={customPois}
parcelCad={data.cad_num}
/>
</aside>
</div>
</>
)}
</main>
);
}
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
// <Suspense> boundary — поэтому оборачиваем page-level.
export default function SiteFinderPage() {
return (
<Suspense fallback={null}>
<SiteFinderContent />
</Suspense>
);
}

View file

@ -0,0 +1,241 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { LayoutDashboard } from "lucide-react";
// ── Placeholder components ─────────────────────────────────────────────────
// Replaced in Wave 24 with real sections
function SidebarPlaceholder() {
return (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 8,
minHeight: "calc(100vh - 56px)",
}}
>
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: 0,
}}
>
TODO A4: AnalysisSidebar
</p>
{/* Numbered nav placeholders */}
{[
"1. Инфо об участке",
"2. Сети и точки подключения",
"3. Конкуренты",
"4. Оценка участка",
"5. Атмосфера / воздух",
].map((label) => (
<div
key={label}
style={{
padding: "8px 12px",
borderRadius: 8,
background: "var(--bg-card-alt)",
color: "var(--fg-secondary)",
fontSize: 13,
}}
>
{label}
</div>
))}
<div
style={{
marginTop: "auto",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
TODO A4: «Источники» внизу sidebar
</div>
</aside>
);
}
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 24 (A5A11)
</div>
</section>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
interface PageProps {
params: Promise<{ cad: string }>;
}
export default function AnalysisPage({ params }: PageProps) {
// React 19: use() unwraps Promise params (app router pattern)
const { cad } = use(params);
return (
<main
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header / breadcrumb */}
<header
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
padding: "12px 24px",
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap" as const,
}}
>
{/* TODO A4: replace with <AnalysisBreadcrumb cad={cad}/> */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
К карте
</Link>
<span style={{ color: "var(--border-soft)" }}>·</span>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
}}
>
{cad}
</span>
<span style={{ color: "var(--border-soft)" }}>·</span>
<span style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
TODO A4: район · площадь · анализ свежий N мин
</span>
{/* TODO A4: <UserAvatar/> справа */}
</header>
{/* Body: sidebar + scrollable sections */}
<div style={{ display: "flex", flex: 1 }}>
{/* TODO A4: replace with <AnalysisSidebar/> */}
<SidebarPlaceholder />
{/* Main scrollable content */}
<article
style={{
flex: 1,
padding: "24px 32px",
overflowY: "auto",
maxWidth: 1000,
}}
>
{/* TODO A5: Section 1 — HeadlineBar + 4 KPI + mini-map + EgrnPropertyTable + PoiList2Gis */}
<SectionPlaceholder
id="section-1"
title="1. Инфо об участке"
note="TODO A5: HeadlineBar вердикт + 4 KPI (Площадь / Район / Медиана / Score) + mini-map + EGRN table + POI list 2GIS"
/>
{/* TODO A6: Section 2 — NspdEngineeringNearbyBlock */}
<SectionPlaceholder
id="section-2"
title="2. Сети и точки подключения"
note="TODO A6: reuse NspdEngineeringNearbyBlock в новом layout headline+map"
/>
{/* TODO A7/A8/A9: Section 3 — sub-sections 3.1 / 3.2 / 3.3 + CompetitorTable */}
<SectionPlaceholder
id="section-3"
title="3. Конкуренты"
note="TODO A7: WeightProfilePanel (настройки выборки) · A8: BestLayoutsBlock / Pipeline24moBlock / VelocityBlock · A9: CompetitorTable с drawer drill-in"
/>
{/* TODO A10: Section 4 — ScoreBreakdownPanel + GateVerdictBanner + geology/hydro/risk */}
<SectionPlaceholder
id="section-4"
title="4. Оценка участка"
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
/>
{/* TODO A11: Section 5 — SeasonalWeatherBlock + air quality */}
<SectionPlaceholder
id="section-5"
title="5. Атмосфера / воздух"
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
/>
</article>
</div>
</main>
);
}

View file

@ -1,570 +1,180 @@
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { FeatureCollection } from "geojson";
import { MapPin } from "lucide-react";
import { CadInput } from "@/components/site-finder/CadInput";
import { FetchingState } from "@/components/site-finder/FetchingState";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { OverviewTab } from "@/components/site-finder/OverviewTab";
import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
// ── Placeholder components ─────────────────────────────────────────────────
// Replaced in A2 with <EntryMap/>, <MapFilterBar/>, <RecentParcels/>
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
() => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap),
{
ssr: false,
loading: () => (
function MapPlaceholder() {
return (
<div
style={{
flex: 1,
minHeight: 480,
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 12,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: "var(--fg-tertiary)",
fontSize: 14,
}}
>
<MapPin
size={32}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)" }}
/>
<span>TODO A2: EntryMap Leaflet full-screen ЕКБ + parcel layers</span>
<span style={{ fontSize: 12 }}>
Данные: GET /api/v1/parcels/by-bbox (B1 ready)
</span>
</div>
);
}
function SidebarPlaceholder() {
return (
<aside
style={{
width: 360,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{/* TODO A2: <MapFilterBar/> */}
<div
style={{
height: 420,
border: "1px solid #e5e7eb",
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
background: "#f9fafb",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#9ca3af",
fontSize: 14,
padding: "12px 16px",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка карты
TODO A2: MapFilterBar chips free / area / vri / district + counter
</div>
),
},
);
type TabId = "overview" | "env" | "land" | "market";
{/* TODO A2: <RecentParcels/> */}
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "12px 16px",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
TODO A2: RecentParcels 3 строки из localStorage gd_recent_parcels
</div>
const TABS: Array<{ id: TabId; label: string }> = [
{ id: "overview", label: "Обзор" },
{ id: "env", label: "Окружение" },
{ id: "land", label: "Земля" },
{ id: "market", label: "Рынок" },
];
const TAB_IDS = TABS.map((t) => t.id);
function isTabId(v: string | null | undefined): v is TabId {
return !!v && (TAB_IDS as readonly string[]).includes(v);
}
// ── KPI helpers ───────────────────────────────────────────────────────────────
type KpiColor = "neutral" | "amber" | "green" | "red" | "blue";
function trendColor(pct: number): KpiColor {
if (pct > 5) return "green";
if (pct > 0) return "blue";
if (pct > -5) return "amber";
return "red";
}
function noiseColor(db: number): KpiColor {
if (db < 45) return "green";
if (db < 60) return "amber";
return "red";
}
function scoreKpiColor(score: number, max: number): KpiColor {
const ratio = score / max;
if (ratio >= 0.65) return "green";
if (ratio >= 0.35) return "amber";
return "red";
{/* TODO A2: <ParcelLegend/> */}
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "12px 16px",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
TODO A2: ParcelLegend расраска статусов (свободные / в работе / ...)
</div>
</aside>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
function SiteFinderContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { mutate, data, isPending, error, isIdle, fetchingState, cancel } =
useSiteAnalysis();
const [isochrones, setIsochrones] = useState<FeatureCollection | undefined>(
undefined,
);
// Fetch connection points whenever a parcel is loaded
const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
// Fetch custom POIs for current parcel
const { data: customPois } = useCustomPois(data?.cad_num);
// Weight profile state — lifted here so it survives tab switches.
// userId + adminToken allow the panel to load/save named profiles.
const [currentWeights, setCurrentWeights] = useState<
Record<PoiCategoryKey, number>
>(() => ({ ...POI_DEFAULT_WEIGHTS }));
// Profile selection for the analyze call (null = system defaults).
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
// Pending weights change — set immediately when user clicks "Применить".
// Debounced to 300ms before triggering re-analyze, so rapid clicks collapse
// into a single request (#201 Phase 2).
const [pendingWeightsChange, setPendingWeightsChange] = useState<{
weights: Record<PoiCategoryKey, number>;
profileId: number | null;
} | null>(null);
const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300);
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
const weightsChangeInitializedRef = useRef(false);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => {
const t = searchParams.get("tab");
return isTabId(t) ? t : "overview";
});
// Sync tab → URL query (?tab=env). overview = default, не пишем в URL.
function setTab(next: TabId) {
setTabState(next);
const params = new URLSearchParams(searchParams.toString());
if (next === "overview") {
params.delete("tab");
} else {
params.set("tab", next);
}
const qs = params.toString();
// Используем pathname вместо голого "?" — Next 15 App Router не всегда
// чисто чистит query при `router.replace("?")` (dangling `?` остаётся).
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}
// Listen to back/forward navigation — reflect URL tab in state.
// setState с тем же значением React сам бэйлит через Object.is, поэтому
// guard по tab не нужен (и не вводит stale-closure через eslint-disable).
useEffect(() => {
const urlTab = searchParams.get("tab");
setTabState(isTabId(urlTab) ? urlTab : "overview");
}, [searchParams]);
// Debounced re-analyze trigger: fires 300ms after the last "Применить" click.
// Skip initial mount (weightsChangeInitializedRef guards the first effect run).
// dataRef lets the effect read current data without listing it as a dep
// (avoids re-registering the timeout on every analyze completion).
const dataRef = useRef(data);
dataRef.current = data;
const profileUserIdRef = useRef(profileUserId);
profileUserIdRef.current = profileUserId;
useEffect(() => {
if (!weightsChangeInitializedRef.current) {
weightsChangeInitializedRef.current = true;
return;
}
if (!debouncedWeightsChange) return;
const currentData = dataRef.current;
if (!currentData?.cad_num) return;
const { weights, profileId } = debouncedWeightsChange;
const currentProfileUserId = profileUserIdRef.current;
setIsochrones(undefined);
mutate({
cad: currentData.cad_num,
options:
profileId != null
? { profileId }
: currentProfileUserId
? { profileUserId: currentProfileUserId, weights }
: { weights },
});
// mutate is stable from useMutation — safe to omit from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedWeightsChange]);
function handleAnalyze(cadNum: string) {
setIsochrones(undefined);
setTab("overview");
// Priority: named profile → user default profile → inline draft weights.
// When activeProfileId is set, backend uses that profile (ignores inline).
// When no profile is selected, pass currentWeights as inline so draft
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
});
}
function handleWeightsChange(
weights: Record<PoiCategoryKey, number>,
profileId: number | null,
) {
setCurrentWeights(weights);
setActiveProfileId(profileId);
// Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2).
// The actual mutate fires after 300ms of silence via debouncedWeightsChange.
if (data?.cad_num) {
setPendingWeightsChange({ weights, profileId });
}
}
// Derive KPI values from data
const scoreMax = data?.score_max_reference ?? 40;
const scoreValue = data
? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}`
: "—";
const scoreLabel = data?.score_label
? `Score · ${data.score_label}`
: "Score";
const scoreCardColor: KpiColor = data
? scoreKpiColor(data.score, scoreMax)
: "neutral";
const districtValue = data?.district ? data.district.district_name : "—";
const districtLabel = data?.district
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра`
: "Район";
const trendPct = data?.market_trend?.delta_6m_pct;
const trendValue =
trendPct != null
? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%`
: "—";
const trendLabel = data?.market_trend
? `Тренд за 6 мес · ${data.market_trend.label}`
: "Тренд рынка";
const trendKpiColor: KpiColor =
trendPct != null ? trendColor(trendPct) : "neutral";
const noiseDb = data?.noise?.estimated_db;
const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—";
const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум";
const noiseKpiColor: KpiColor =
noiseDb != null ? noiseColor(noiseDb) : "neutral";
export default function SiteFinderPage() {
return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
{/* Breadcrumb + header */}
<div style={{ marginBottom: 12 }}>
<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: 16,
}}
>
<Link
href="/"
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Главная
</Link>
</div>
<span style={{ color: "var(--border-soft)" }}>·</span>
<h1
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
SiteFinder · карта участков
</h1>
{/* TODO A4: <UserAvatar/> справа */}
</header>
{/* TODO A2: MapFilterBar top-bar с chips (free/area/vri/district) + counter «148 / 34 подходят» */}
{/* Main layout: карта слева, sidebar справа */}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
flexWrap: "wrap",
gap: 12,
gap: 0,
overflow: "hidden",
}}
>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
Site Finder
</h1>
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 0" }}>
Введите кадастровый номер участка или квартала для анализа локации
</p>
</div>
{/* Map area */}
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 12,
padding: "14px 20px",
minWidth: 320,
flex: 1,
display: "flex",
padding: 16,
}}
>
<CadInput onSubmit={handleAnalyze} loading={isPending} />
{/* TODO A2: replace with <EntryMap/> */}
<MapPlaceholder />
</div>
{/* Right sidebar */}
<div
style={{
padding: "16px 16px 16px 0",
}}
>
{/* TODO A2: <MapFilterBar/> + <RecentParcels/> + <ParcelLegend/> */}
<SidebarPlaceholder />
</div>
</div>
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}
{isPending && !!data && (
<p
style={{
fontSize: 12,
color: "#6b7280",
margin: "6px 0 0",
}}
>
Пересчёт
</p>
)}
</div>
{/* Error state */}
{error && (
<div
style={{
padding: "12px 20px",
background: "#fef2f2",
border: "1px solid #fca5a5",
borderRadius: 10,
color: "#dc2626",
fontSize: 13,
marginBottom: 20,
}}
>
{error instanceof Error ? error.message : "Ошибка анализа участка"}
</div>
)}
{/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */}
{fetchingState && (
<FetchingState
cadNum={fetchingState.cadNum}
etaSeconds={fetchingState.etaSeconds}
onCancel={cancel}
/>
)}
{/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */}
{isPending && !fetchingState && (
<div
style={{
padding: 40,
textAlign: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
Анализируем участок
</div>
)}
{/* Empty initial state */}
{isIdle && !error && (
<div
style={{
padding: 40,
textAlign: "center",
color: "#d1d5db",
fontSize: 14,
border: "1px dashed #e5e7eb",
borderRadius: 12,
}}
>
Введите кадастровый номер и нажмите «Анализировать»
</div>
)}
{/* Results */}
{data && (
<>
{/* Cad label */}
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 10,
}}
>
{data.cad_num}
</div>
{/* Hero band — sticky KPI cards */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "#fff",
borderBottom: "1px solid #e5e7eb",
padding: "12px 0 14px",
marginBottom: 0,
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 12,
}}
>
<KpiCard
value={scoreValue}
label={scoreLabel}
color={scoreCardColor}
/>
<KpiCard
value={districtValue}
label={districtLabel}
color="neutral"
/>
<KpiCard
value={trendValue}
label={trendLabel}
color={trendKpiColor}
/>
<KpiCard
value={noiseValue}
label={noiseLabel}
color={noiseKpiColor}
/>
</div>
{/* Tab buttons */}
<div
style={{
display: "flex",
gap: 0,
borderBottom: "1px solid #e5e7eb",
marginBottom: 20,
background: "#fff",
position: "sticky",
top: 86,
zIndex: 19,
}}
>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
padding: "10px 20px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 14,
fontWeight: 500,
borderBottom:
tab === t.id
? "2px solid #1d4ed8"
: "2px solid transparent",
color: tab === t.id ? "#1d4ed8" : "#6b7280",
transition: "color 0.15s, border-color 0.15s",
}}
>
{t.label}
</button>
))}
</div>
{/* 2-column grid: tab content + sticky map */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 480px",
gap: 24,
alignItems: "start",
}}
>
{/* Tab content */}
<div>
{tab === "overview" && (
<OverviewTab data={data} onIsochronesResult={setIsochrones} />
)}
{tab === "env" && <EnvironmentTab data={data} />}
{tab === "land" && <LandTab data={data} />}
{tab === "market" && <MarketTab data={data} />}
</div>
{/* Sticky right sidebar — map always visible */}
<aside
style={{
position: "sticky",
top: 140,
alignSelf: "start",
maxHeight: "calc(100vh - 156px)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<SiteMap
data={data}
isochrones={isochrones}
connectionPoints={connectionPoints}
customPois={customPois}
parcelCad={data.cad_num}
/>
</aside>
</div>
</>
)}
{/* TODO A2: <ParcelDrawer/> — slide-in справа при клике на парцель (query ?selected=cad) */}
</main>
);
}
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
// <Suspense> boundary — поэтому оборачиваем page-level.
export default function SiteFinderPage() {
return (
<Suspense fallback={null}>
<SiteFinderContent />
</Suspense>
);
}

View file

@ -0,0 +1,39 @@
/**
* Mock toggle controls whether hooks return fixture data or call real backend.
*
* Usage in hooks:
* import { USE_MOCKS, MOCK_PARCELS_BBOX } from "@/lib/mock-toggle";
*
* // Coarse toggle: all mocks on/off
* if (USE_MOCKS) return fixtureParcels;
*
* // Fine-grained: per-endpoint (disable individually as B1-B6 ship to prod)
* if (MOCK_PARCELS_BBOX) return fixtureParcels;
*
* .env.local (dev):
* NEXT_PUBLIC_USE_MOCKS=true
*
* Production: unset or NEXT_PUBLIC_USE_MOCKS=false
*
* TODO: per-hook fine-grained flags below flip to false as each backend
* endpoint (B1-B6) is confirmed deployed and smoke-tested in prod.
*/
/** Master switch — set NEXT_PUBLIC_USE_MOCKS=true in .env.local for dev */
export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true";
/**
* Fine-grained feature flags (all derive from USE_MOCKS initially).
* Flip each to `false` once the corresponding backend endpoint is live.
*
* B1 GET /api/v1/parcels/by-bbox used by A2 EntryMap
* B2 GET /api/v1/users/me/recent-parcels used by A2 RecentParcels
* B4 GET /api/v1/landing/stats used by A12 Landing
* B5 POST /api/v1/parcels/{cad}/analyze used by A5A11 sections
* B6 GET /api/v1/parcels/{cad}/poi-score used by A5 PoiList2Gis
*/
export const MOCK_PARCELS_BBOX = USE_MOCKS; // B1
export const MOCK_RECENT_PARCELS = USE_MOCKS; // B2
export const MOCK_LANDING_STATS = USE_MOCKS; // B4
export const MOCK_ANALYZE = USE_MOCKS; // B5
export const MOCK_POI_SCORE = USE_MOCKS; // B6