diff --git a/frontend/src/app/analytics/developers/page.tsx b/frontend/src/app/analytics/developers/page.tsx index e61d011d..6074eb4d 100644 --- a/frontend/src/app/analytics/developers/page.tsx +++ b/frontend/src/app/analytics/developers/page.tsx @@ -1,13 +1,15 @@ "use client"; +import { Search } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { Suspense } from "react"; +import { Suspense, useState } from "react"; import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard"; import { KpiCard } from "@/components/analytics/KpiCard"; import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart"; import { Section } from "@/components/analytics/Section"; import { VelocityScatter } from "@/components/analytics/VelocityScatter"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api"; function DevelopersInner() { @@ -16,6 +18,11 @@ function DevelopersInner() { const top = useTopDevelopers(15); const focus = useDeveloperDetail(focusedId ?? ""); + // Debounced 300ms search over the leaderboard (client-side filter — the + // top-15 list is already in memory, no extra request). + const [searchInput, setSearchInput] = useState(""); + const debouncedSearch = useDebouncedValue(searchInput, 300); + const focused = focusedId ? focus.data : null; const topNames: Record = Object.fromEntries( (top.data ?? []).map((r) => [r.developer_id, r.developer_name]), @@ -67,8 +74,43 @@ function DevelopersInner() {
+ + setSearchInput(e.target.value)} + placeholder="Найти девелопера…" + aria-label="Поиск девелопера по названию" + style={{ + border: "none", + outline: "none", + fontSize: 14, + width: 200, + background: "transparent", + color: "var(--fg-primary)", + }} + /> + + } > - +
b.cad_num != null && b.cad_num !== "", + )?.cad_num; + return ( <> + Посмотреть участок в Site Finder + + + ) : undefined + } >
([]); + const [hydrated, setHydrated] = useState(false); + const [input, setInput] = useState(""); + const [inputError, setInputError] = useState(null); + + // Per-cad column state, keyed by cad. Lifted up from each ParcelCompareLoader. + const [columns, setColumns] = useState>({}); + + useEffect(() => { + setShortlist(getShortlist()); + setHydrated(true); + }, []); + + // Persist to localStorage on every change (after hydration so the initial + // empty state never clobbers a saved list). + useEffect(() => { + if (hydrated) saveShortlist(shortlist); + }, [shortlist, hydrated]); + + const handleResult = useCallback((col: CompareColumn) => { + setColumns((prev) => { + const existing = prev[col.cad]; + // Avoid a needless state update (and re-render loop) when nothing changed. + if ( + existing && + existing.status === col.status && + existing.score === col.score && + existing.verdictLabel === col.verdictLabel && + existing.velocityScore === col.velocityScore && + existing.pipeline24mo === col.pipeline24mo && + existing.confidenceValue === col.confidenceValue + ) { + return prev; + } + return { ...prev, [col.cad]: col }; + }); + }, []); + + function addCad(e: React.FormEvent) { + e.preventDefault(); + const cad = input.trim(); + if (!isValidCad(cad)) { + setInputError("Неверный формат. Ожидается 66:41:0204016:10 или 66:41:0204016."); + return; + } + if (shortlist.includes(cad)) { + setInputError("Этот участок уже в сравнении."); + return; + } + if (shortlist.length >= COMPARE_MAX) { + setInputError(`Можно сравнить не более ${COMPARE_MAX} участков.`); + return; + } + setShortlist((prev) => [...prev, cad]); + setInput(""); + setInputError(null); + } + + const removeCad = useCallback((cad: string) => { + setShortlist((prev) => prev.filter((c) => c !== cad)); + setColumns((prev) => { + const next = { ...prev }; + delete next[cad]; + return next; + }); + }, []); + + // Build ordered columns from the shortlist; default to a loading column so + // the table renders immediately while each loader resolves. + const orderedColumns: CompareColumn[] = useMemo( + () => + shortlist.map( + (cad) => columns[cad] ?? { cad, status: "loading" as const }, + ), + [shortlist, columns], + ); + + const canCompare = shortlist.length >= COMPARE_MIN; + + return ( +
+
+ {/* Header */} +
+ + Site Finder + + / + сравнение участков +
+ +

+ Сравнение участков +

+

+ Добавьте от {COMPARE_MIN} до {COMPARE_MAX} кадастровых номеров — метрики + анализа (вердикт МКД, инвест-балл, скорость продаж, цена, пайплайн) + встанут рядом. Лучшее значение в строке подсвечено. Список сохраняется + между сессиями. +

+ + {/* Add form */} +
+
+ { + setInput(e.target.value); + setInputError(null); + }} + placeholder="66:41:0204016:10" + aria-label="Кадастровый номер для сравнения" + disabled={shortlist.length >= COMPARE_MAX} + style={{ + width: "100%", + padding: "8px 12px", + fontSize: 14, + fontFamily: "monospace", + border: inputError + ? "1px solid var(--danger)" + : "1px solid var(--border-strong)", + borderRadius: 8, + outline: "none", + background: "var(--bg-card)", + }} + /> + {inputError ? ( +

+ {inputError} +

+ ) : null} +
+ + + + {/* Headless loaders — one per cad, lift analyze metrics up */} + {shortlist.map((cad) => ( + + ))} + + {/* Body */} +
+ {!canCompare ? ( +
+ {shortlist.length === 0 + ? "Список пуст. Добавьте участки выше, чтобы начать сравнение." + : `Добавьте ещё минимум ${COMPARE_MIN - shortlist.length} участок для сравнения.`} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index f2a98d73..ae79441e 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -146,6 +146,18 @@ export default function SiteFinderPage() { > SiteFinder · карта участков + + Сравнить участки + {/* Filter bar */} diff --git a/frontend/src/components/analytics/DeveloperLeaderboard.tsx b/frontend/src/components/analytics/DeveloperLeaderboard.tsx index dc9ef654..5fd7173b 100644 --- a/frontend/src/components/analytics/DeveloperLeaderboard.tsx +++ b/frontend/src/components/analytics/DeveloperLeaderboard.tsx @@ -46,20 +46,30 @@ const COLS: { }, ]; -export function DeveloperLeaderboard({ highlight }: { highlight?: string }) { +export function DeveloperLeaderboard({ + highlight, + query, +}: { + highlight?: string; + /** Case-insensitive substring filter on developer_name (debounced upstream). */ + query?: string; +}) { const { data, isLoading } = useTopDevelopers(15); const [sortBy, setSortBy] = useState("sverdl_sqm_th"); const [desc, setDesc] = useState(true); const rows = useMemo(() => { - const arr = [...(data ?? [])]; + const needle = (query ?? "").trim().toLocaleLowerCase("ru"); + const arr = (data ?? []).filter((r) => + needle ? r.developer_name.toLocaleLowerCase("ru").includes(needle) : true, + ); arr.sort((a, b) => { const av = (a[sortBy] ?? -Infinity) as number; const bv = (b[sortBy] ?? -Infinity) as number; return desc ? bv - av : av - bv; }); return arr; - }, [data, sortBy, desc]); + }, [data, sortBy, desc, query]); const click = (k: SortKey) => { if (k === sortBy) setDesc(!desc); @@ -72,6 +82,15 @@ export function DeveloperLeaderboard({ highlight }: { highlight?: string }) { if (isLoading) return
Загрузка…
; + if (rows.length === 0) + return ( +
+ {query?.trim() + ? `Ничего не найдено по запросу «${query.trim()}».` + : "Данные пока недоступны."} +
+ ); + return (
void; +} + +// ── Metric row definitions ────────────────────────────────────────────────── + +type WinnerDir = "high" | "low" | "none"; + +interface MetricRow { + key: string; + label: string; + /** Which direction wins, for per-row highlight. "none" → not comparable. */ + winner: WinnerDir; + /** Numeric value used for winner comparison (null = no data, never wins). */ + value: (c: CompareColumn) => number | null; + /** Rendered cell content. */ + render: (c: CompareColumn) => React.ReactNode; +} + +const VERDICT_BADGE: Record = { + Можно: "success", + Нельзя: "danger", + "С ограничениями": "warning", + "Нужна проверка": "neutral", +}; + +const CONFIDENCE_RU: Record<"high" | "medium" | "low", string> = { + high: "высокая", + medium: "средняя", + low: "низкая", +}; + +const DASH = "—"; + +function fmtScore(v: number | null | undefined): string { + return v != null && Number.isFinite(v) ? v.toFixed(1) : DASH; +} + +function fmtPrice(v: number | null | undefined): string { + // Median ₽/м² — точное число с разделителями тысяч (ru), e.g. «145 320 ₽». + return v != null && Number.isFinite(v) + ? `${Math.round(v).toLocaleString("ru")} ₽` + : DASH; +} + +function fmtPipeline(v: number | null | undefined): string { + return v != null && Number.isFinite(v) + ? `${Math.round(v).toLocaleString("ru")} кв.` + : DASH; +} + +const METRIC_ROWS: MetricRow[] = [ + { + key: "verdict", + label: "Вердикт МКД", + winner: "none", + value: () => null, + render: (c) => + c.verdictLabel ? ( + + {c.verdictLabel} + + ) : ( + DASH + ), + }, + { + key: "score", + label: "Инвест-балл", + winner: "high", + value: (c) => c.score ?? null, + render: (c) => ( + + {fmtScore(c.score)} + {c.scoreLabel ? ( + + {" "} + · {c.scoreLabel} + + ) : null} + + ), + }, + { + key: "velocity", + label: "Скорость продаж", + winner: "high", + value: (c) => c.velocityScore ?? null, + render: (c) => + c.velocityScore != null && Number.isFinite(c.velocityScore) ? ( + + {Math.round(c.velocityScore * 100)} + + {" "} + / 100 + + + ) : ( + DASH + ), + }, + { + key: "price", + label: "Медиана ₽ / м²", + // Lower median price = cheaper land context. Valence is ambiguous for a + // developer (cheaper district ≠ better), so it is NOT highlighted. + winner: "none", + value: (c) => c.medianPricePerM2 ?? null, + render: (c) => ( + + {fmtPrice(c.medianPricePerM2)} + + ), + }, + { + key: "pipeline", + label: "Пайплайн 24 мес", + // More competing pipeline = more future supply = worse. Lower wins. + winner: "low", + value: (c) => c.pipeline24mo ?? null, + render: (c) => ( + + {fmtPipeline(c.pipeline24mo)} + + ), + }, + { + key: "confidence", + label: "Достоверность", + winner: "high", + value: (c) => c.confidenceValue ?? null, + render: (c) => + c.confidenceLabel ? ( + + {c.confidenceValue != null + ? `${Math.round(c.confidenceValue * 100)}% · ` + : ""} + {CONFIDENCE_RU[c.confidenceLabel]} + + ) : ( + DASH + ), + }, +]; + +/** + * Returns the set of column cads that win a given row, or an empty set when the + * metric is not comparable / has fewer than two data points. Ties win jointly. + */ +function winnersFor(row: MetricRow, columns: CompareColumn[]): Set { + if (row.winner === "none") return new Set(); + const vals = columns + .map((c) => ({ cad: c.cad, v: row.value(c) })) + .filter((x): x is { cad: string; v: number } => x.v != null); + if (vals.length < 2) return new Set(); + const best = + row.winner === "high" + ? Math.max(...vals.map((x) => x.v)) + : Math.min(...vals.map((x) => x.v)); + return new Set(vals.filter((x) => x.v === best).map((x) => x.cad)); +} + +// ── Component ─────────────────────────────────────────────────────────────── + +export function CompareTable({ columns, onRemove }: Props) { + const winnerByRow = new Map>( + METRIC_ROWS.map((row) => [row.key, winnersFor(row, columns)]), + ); + + return ( +
+
+ + + + {columns.map((c) => ( + + ))} + + + + {METRIC_ROWS.map((row) => { + const winners = winnerByRow.get(row.key) ?? new Set(); + return ( + + + {columns.map((c) => { + const isWinner = winners.has(c.cad); + return ( + + ); + })} + + ); + })} + +
Фактор +
+ + {c.cad} + + +
+ {c.districtName && c.districtName !== DASH ? ( +
+ {c.districtName} +
+ ) : null} +
{row.label} + {c.status === "loading" ? ( + + ) : c.status === "error" ? ( + + ошибка + + ) : ( + row.render(c) + )} +
+
+ ); +} + +// ── Styles ──────────────────────────────────────────────────────────────── + +const baseTh: React.CSSProperties = { + padding: "10px 12px", + borderBottom: "1px solid var(--border-card)", + fontWeight: 600, + verticalAlign: "top", +}; + +const cornerTh: React.CSSProperties = { + ...baseTh, + textAlign: "left", + background: "var(--bg-app)", + width: 180, + minWidth: 160, +}; + +const colTh: React.CSSProperties = { + ...baseTh, + textAlign: "left", + background: "var(--bg-app)", +}; + +const colHeadInner: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, +}; + +const rowTh: React.CSSProperties = { + padding: "10px 12px", + textAlign: "left", + fontWeight: 500, + color: "var(--fg-secondary)", + background: "var(--bg-card-alt)", +}; + +const cell: React.CSSProperties = { + padding: "10px 12px", + textAlign: "left", +}; + +const removeBtn: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "none", + border: "none", + padding: 2, + cursor: "pointer", + color: "var(--fg-tertiary)", + flexShrink: 0, +}; diff --git a/frontend/src/components/site-finder/compare/ParcelCompareLoader.tsx b/frontend/src/components/site-finder/compare/ParcelCompareLoader.tsx new file mode 100644 index 00000000..01051d5c --- /dev/null +++ b/frontend/src/components/site-finder/compare/ParcelCompareLoader.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect } from "react"; + +import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import type { CompareColumn } from "./CompareTable"; + +interface Props { + cad: string; + onResult: (col: CompareColumn) => void; +} + +/** + * Headless loader: runs `useParcelAnalyzeQuery` for a single cad and lifts the + * flattened metrics up to the compare page via `onResult`. Rendered one-per-cad + * inside a `.map`, so each instance owns a stable hook order (rules-of-hooks + * safe) — the page itself never calls the hook in a loop. Reuses the exact same + * analyze endpoint + 202-poll logic as the single-parcel analysis page, so a + * parcel already analyzed there is served warm from the shared query cache. + * + * Renders nothing; `useEffect` here only mirrors query state into parent state, + * it does not perform HTTP (the hook does, via TanStack Query). + */ +export function ParcelCompareLoader({ cad, onResult }: Props) { + const { data, isLoading, error } = useParcelAnalyzeQuery(cad); + + useEffect(() => { + if (isLoading) { + onResult({ cad, status: "loading" }); + return; + } + if (error || !data) { + onResult({ + cad, + status: "error", + errorMsg: + error instanceof Error ? error.message : "Ошибка загрузки анализа", + }); + return; + } + + // ParcelAnalyzeResponse is a structural subset of ParcelAnalysis (same + // /analyze endpoint); the richer fields (velocity, gate_verdict, confidence, + // success_recommendation) are optional and may be absent on thin parcels. + const a = data as unknown as ParcelAnalysis; + + onResult({ + cad, + status: "ready", + districtName: a.district?.district_name ?? null, + verdictLabel: a.gate_verdict?.verdict_label ?? null, + score: a.score ?? null, + scoreLabel: a.score_label ?? null, + velocityScore: a.velocity?.velocity_score ?? null, + medianPricePerM2: a.district?.median_price_per_m2 ?? null, + pipeline24mo: a.pipeline_24mo?.flats_total ?? null, + confidenceLabel: a.confidence_label ?? null, + confidenceValue: a.confidence ?? null, + }); + }, [cad, data, isLoading, error, onResult]); + + return null; +} diff --git a/frontend/src/lib/compare-shortlist.ts b/frontend/src/lib/compare-shortlist.ts new file mode 100644 index 00000000..3454af51 --- /dev/null +++ b/frontend/src/lib/compare-shortlist.ts @@ -0,0 +1,60 @@ +/** + * Site Finder multi-cad compare — localStorage-backed shortlist (issue #50). + * + * The compare page lets a developer pin 2–5 cadastral numbers and view their + * analyze metrics side-by-side. The shortlist survives reload via localStorage + * (per browser, not per server session) — mirroring `gd_recent_parcels` in + * site-finder-api.ts. All access is SSR-guarded (`typeof window`). + */ + +const STORAGE_KEY = "gd_compare_shortlist"; + +/** Hard cap from the issue: batch analyze runs max 5 parcels in parallel. */ +export const COMPARE_MAX = 5; +/** Need at least two parcels for a meaningful side-by-side. */ +export const COMPARE_MIN = 2; + +/** + * Validates cadastral number formats (same regex as CadInput): + * 3-part: 66:41:0204016 (quarter) + * 4-part: 66:41:0204016:10 (parcel) + */ +const CAD_REGEX = /^\d+:\d+:\d+(?::\d+)?$/; + +export function isValidCad(cad: string): boolean { + return CAD_REGEX.test(cad.trim()); +} + +export function getShortlist(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + // Narrow to a clean, de-duplicated, capped string[] — defends against a + // hand-edited / stale localStorage payload. + const seen = new Set(); + const out: string[] = []; + for (const item of parsed) { + if (typeof item !== "string") continue; + const cad = item.trim(); + if (!isValidCad(cad) || seen.has(cad)) continue; + seen.add(cad); + out.push(cad); + if (out.length >= COMPARE_MAX) break; + } + return out; + } catch { + return []; + } +} + +export function saveShortlist(cads: string[]): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cads.slice(0, COMPARE_MAX))); + } catch { + // ignore quota / disabled-storage errors — shortlist is best-effort + } +}