feat(sf-fe-a1): routes shell — entry / analysis + _legacy/ backup + mock toggle (#340)
This commit is contained in:
parent
96b03872ac
commit
3aaaaba5f9
4 changed files with 991 additions and 531 deletions
570
frontend/src/app/_legacy/site-finder-page-tabs.tsx
Normal file
570
frontend/src/app/_legacy/site-finder-page-tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
frontend/src/app/site-finder/analysis/[cad]/page.tsx
Normal file
241
frontend/src/app/site-finder/analysis/[cad]/page.tsx
Normal 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 2–4 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 2–4 (A5–A11)
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,570 +1,180 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useEffect, useRef, useState } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { MapPin } from "lucide-react";
|
||||||
import type { FeatureCollection } from "geojson";
|
|
||||||
|
|
||||||
import { CadInput } from "@/components/site-finder/CadInput";
|
// ── Placeholder components ─────────────────────────────────────────────────
|
||||||
import { FetchingState } from "@/components/site-finder/FetchingState";
|
// Replaced in A2 with <EntryMap/>, <MapFilterBar/>, <RecentParcels/>
|
||||||
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
|
function MapPlaceholder() {
|
||||||
const SiteMap = dynamic(
|
return (
|
||||||
() => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap),
|
<div
|
||||||
{
|
style={{
|
||||||
ssr: false,
|
flex: 1,
|
||||||
loading: () => (
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 420,
|
background: "var(--bg-card)",
|
||||||
border: "1px solid #e5e7eb",
|
border: "1px solid var(--border-card)",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
background: "#f9fafb",
|
padding: "12px 16px",
|
||||||
display: "flex",
|
color: "var(--fg-tertiary)",
|
||||||
alignItems: "center",
|
fontSize: 13,
|
||||||
justifyContent: "center",
|
|
||||||
color: "#9ca3af",
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Загрузка карты…
|
TODO A2: MapFilterBar — chips free / area / vri / district + counter
|
||||||
</div>
|
</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 }> = [
|
{/* TODO A2: <ParcelLegend/> */}
|
||||||
{ id: "overview", label: "Обзор" },
|
<div
|
||||||
{ id: "env", label: "Окружение" },
|
style={{
|
||||||
{ id: "land", label: "Земля" },
|
background: "var(--bg-card)",
|
||||||
{ id: "market", label: "Рынок" },
|
border: "1px solid var(--border-card)",
|
||||||
];
|
borderRadius: 12,
|
||||||
|
padding: "12px 16px",
|
||||||
const TAB_IDS = TABS.map((t) => t.id);
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 13,
|
||||||
function isTabId(v: string | null | undefined): v is TabId {
|
}}
|
||||||
return !!v && (TAB_IDS as readonly string[]).includes(v);
|
>
|
||||||
}
|
TODO A2: ParcelLegend — расраска статусов (свободные / в работе / ...)
|
||||||
|
</div>
|
||||||
// ── KPI helpers ───────────────────────────────────────────────────────────────
|
</aside>
|
||||||
|
);
|
||||||
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 ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SiteFinderContent() {
|
export default function SiteFinderPage() {
|
||||||
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 (
|
return (
|
||||||
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>
|
<main
|
||||||
{/* Breadcrumb + header */}
|
style={{
|
||||||
<div style={{ marginBottom: 12 }}>
|
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
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
style={{ fontSize: 13, color: "#6b7280", textDecoration: "none" }}
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
← Главная
|
← Главная
|
||||||
</Link>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
flex: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
gap: 0,
|
||||||
justifyContent: "space-between",
|
overflow: "hidden",
|
||||||
marginBottom: 20,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
{/* Map area */}
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>
|
|
||||||
Site Finder
|
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: 13, color: "#6b7280", margin: "4px 0 0" }}>
|
|
||||||
Введите кадастровый номер участка или квартала для анализа локации
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
flex: 1,
|
||||||
border: "1px solid #e5e7eb",
|
display: "flex",
|
||||||
borderRadius: 12,
|
padding: 16,
|
||||||
padding: "14px 20px",
|
|
||||||
minWidth: 320,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weight profile panel — collapsible, below header */}
|
{/* TODO A2: <ParcelDrawer/> — slide-in справа при клике на парцель (query ?selected=cad) */}
|
||||||
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
|
|
||||||
// <Suspense> boundary — поэтому оборачиваем page-level.
|
|
||||||
export default function SiteFinderPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteFinderContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
39
frontend/src/lib/mock-toggle.ts
Normal file
39
frontend/src/lib/mock-toggle.ts
Normal 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 A5–A11 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
|
||||||
Loading…
Add table
Reference in a new issue