feat(site-finder): multi-cad сравнение (#50) + analytics поиск/cross-links (#65)
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 47s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 47s
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 47s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 47s
#50: /site-finder/compare — shortlist (localStorage, SSR-guard, cap 2-5) + CompareTable (per-row winner-highlight, reuse useParcelAnalyzeQuery per cad, hook-order stable в .map, cache-shared). Cross-link с site-finder. #65: debounced search (300ms, client-side filter top-15) в DeveloperLeaderboard + cross-link объект→Site Finder analyze. Existing developers compare не дублирован. Closes #50 Closes #65
This commit is contained in:
parent
3bbbf25412
commit
7d69364e5f
8 changed files with 839 additions and 5 deletions
|
|
@ -1,13 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense, useState } from "react";
|
||||||
|
|
||||||
import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard";
|
import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard";
|
||||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
|
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
|
||||||
import { Section } from "@/components/analytics/Section";
|
import { Section } from "@/components/analytics/Section";
|
||||||
import { VelocityScatter } from "@/components/analytics/VelocityScatter";
|
import { VelocityScatter } from "@/components/analytics/VelocityScatter";
|
||||||
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||||
import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
|
import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
|
||||||
|
|
||||||
function DevelopersInner() {
|
function DevelopersInner() {
|
||||||
|
|
@ -16,6 +18,11 @@ function DevelopersInner() {
|
||||||
const top = useTopDevelopers(15);
|
const top = useTopDevelopers(15);
|
||||||
const focus = useDeveloperDetail(focusedId ?? "");
|
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 focused = focusedId ? focus.data : null;
|
||||||
const topNames: Record<string, string> = Object.fromEntries(
|
const topNames: Record<string, string> = Object.fromEntries(
|
||||||
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
|
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
|
||||||
|
|
@ -67,8 +74,43 @@ function DevelopersInner() {
|
||||||
<Section
|
<Section
|
||||||
title="Топ-15 девелоперов Свердл"
|
title="Топ-15 девелоперов Свердл"
|
||||||
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
|
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
|
||||||
|
right={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
border: "1px solid var(--border-strong)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color="var(--fg-tertiary)"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Найти девелопера…"
|
||||||
|
aria-label="Поиск девелопера по названию"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontSize: 14,
|
||||||
|
width: 200,
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DeveloperLeaderboard highlight={focusedId} />
|
<DeveloperLeaderboard highlight={focusedId} query={debouncedSearch} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
@ -90,6 +91,13 @@ export default function ObjectDrillInPage() {
|
||||||
|
|
||||||
const d = fullDetail.data;
|
const d = fullDetail.data;
|
||||||
|
|
||||||
|
// Cross-link target (#65): first building's cadastral number — a real parcel
|
||||||
|
// Site Finder can analyze. Объект сам по себе не несёт cad_quarter, но корпуса
|
||||||
|
// из NSPD несут cad_num, что и есть точка входа в анализ участка.
|
||||||
|
const siteFinderCad = buildings.data?.find(
|
||||||
|
(b) => b.cad_num != null && b.cad_num !== "",
|
||||||
|
)?.cad_num;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -331,6 +339,27 @@ export default function ObjectDrillInPage() {
|
||||||
<Section
|
<Section
|
||||||
title="Корпуса ЖК"
|
title="Корпуса ЖК"
|
||||||
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
|
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
|
||||||
|
right={
|
||||||
|
siteFinderCad ? (
|
||||||
|
<Link
|
||||||
|
href={`/site-finder/analysis/${encodeURIComponent(siteFinderCad)}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#1d4ed8",
|
||||||
|
textDecoration: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={`Анализ участка ${siteFinderCad} в Site Finder`}
|
||||||
|
>
|
||||||
|
Посмотреть участок в Site Finder
|
||||||
|
<ArrowUpRight size={16} strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table
|
<table
|
||||||
|
|
|
||||||
257
frontend/src/app/site-finder/compare/page.tsx
Normal file
257
frontend/src/app/site-finder/compare/page.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { CompareTable } from "@/components/site-finder/compare/CompareTable";
|
||||||
|
import type { CompareColumn } from "@/components/site-finder/compare/CompareTable";
|
||||||
|
import { ParcelCompareLoader } from "@/components/site-finder/compare/ParcelCompareLoader";
|
||||||
|
import {
|
||||||
|
COMPARE_MAX,
|
||||||
|
COMPARE_MIN,
|
||||||
|
getShortlist,
|
||||||
|
isValidCad,
|
||||||
|
saveShortlist,
|
||||||
|
} from "@/lib/compare-shortlist";
|
||||||
|
|
||||||
|
export default function ComparePage() {
|
||||||
|
// Shortlist of cads. Hydrated from localStorage after mount (SSR-safe: the
|
||||||
|
// first client render matches the server's empty list, then the effect fills
|
||||||
|
// it — avoids a hydration mismatch).
|
||||||
|
const [shortlist, setShortlist] = useState<string[]>([]);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Per-cad column state, keyed by cad. Lifted up from each ParcelCompareLoader.
|
||||||
|
const [columns, setColumns] = useState<Record<string, CompareColumn>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "var(--bg-app)",
|
||||||
|
padding: "24px 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 1280, margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/site-finder"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site Finder
|
||||||
|
</Link>
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>/</span>
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>сравнение участков</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: "8px 0 4px",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сравнение участков
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 20px",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавьте от {COMPARE_MIN} до {COMPARE_MAX} кадастровых номеров — метрики
|
||||||
|
анализа (вердикт МКД, инвест-балл, скорость продаж, цена, пайплайн)
|
||||||
|
встанут рядом. Лучшее значение в строке подсвечено. Список сохраняется
|
||||||
|
между сессиями.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
<form
|
||||||
|
onSubmit={addCad}
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, maxWidth: 420 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "6px 0 0",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inputError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || shortlist.length >= COMPARE_MAX}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
background:
|
||||||
|
!input.trim() || shortlist.length >= COMPARE_MAX
|
||||||
|
? "var(--fg-tertiary)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color: "var(--fg-on-dark)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor:
|
||||||
|
!input.trim() || shortlist.length >= COMPARE_MAX
|
||||||
|
? "default"
|
||||||
|
: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Headless loaders — one per cad, lift analyze metrics up */}
|
||||||
|
{shortlist.map((cad) => (
|
||||||
|
<ParcelCompareLoader key={cad} cad={cad} onResult={handleResult} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!canCompare ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "32px 24px",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shortlist.length === 0
|
||||||
|
? "Список пуст. Добавьте участки выше, чтобы начать сравнение."
|
||||||
|
: `Добавьте ещё минимум ${COMPARE_MIN - shortlist.length} участок для сравнения.`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CompareTable columns={orderedColumns} onRemove={removeCad} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -146,6 +146,18 @@ export default function SiteFinderPage() {
|
||||||
>
|
>
|
||||||
SiteFinder · карта участков
|
SiteFinder · карта участков
|
||||||
</h1>
|
</h1>
|
||||||
|
<Link
|
||||||
|
href="/site-finder/compare"
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--accent)",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сравнить участки
|
||||||
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
|
|
|
||||||
|
|
@ -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 { data, isLoading } = useTopDevelopers(15);
|
||||||
const [sortBy, setSortBy] = useState<SortKey>("sverdl_sqm_th");
|
const [sortBy, setSortBy] = useState<SortKey>("sverdl_sqm_th");
|
||||||
const [desc, setDesc] = useState(true);
|
const [desc, setDesc] = useState(true);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
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) => {
|
arr.sort((a, b) => {
|
||||||
const av = (a[sortBy] ?? -Infinity) as number;
|
const av = (a[sortBy] ?? -Infinity) as number;
|
||||||
const bv = (b[sortBy] ?? -Infinity) as number;
|
const bv = (b[sortBy] ?? -Infinity) as number;
|
||||||
return desc ? bv - av : av - bv;
|
return desc ? bv - av : av - bv;
|
||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
}, [data, sortBy, desc]);
|
}, [data, sortBy, desc, query]);
|
||||||
|
|
||||||
const click = (k: SortKey) => {
|
const click = (k: SortKey) => {
|
||||||
if (k === sortBy) setDesc(!desc);
|
if (k === sortBy) setDesc(!desc);
|
||||||
|
|
@ -72,6 +82,15 @@ export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка…</div>;
|
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка…</div>;
|
||||||
|
|
||||||
|
if (rows.length === 0)
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, color: "#5b6066", fontSize: 14 }}>
|
||||||
|
{query?.trim()
|
||||||
|
? `Ничего не найдено по запросу «${query.trim()}».`
|
||||||
|
: "Данные пока недоступны."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table
|
<table
|
||||||
|
|
|
||||||
351
frontend/src/components/site-finder/compare/CompareTable.tsx
Normal file
351
frontend/src/components/site-finder/compare/CompareTable.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
|
import type { GateVerdictLabel } from "@/types/site-finder";
|
||||||
|
|
||||||
|
// ── Per-column state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One parcel column. `status` mirrors the TanStack Query lifecycle of the
|
||||||
|
* per-cad `useParcelAnalyzeQuery` lifted up by the page. Metrics are flattened
|
||||||
|
* to primitives here so CompareTable stays a pure presentational component.
|
||||||
|
*/
|
||||||
|
export interface CompareColumn {
|
||||||
|
cad: string;
|
||||||
|
status: "loading" | "error" | "ready";
|
||||||
|
errorMsg?: string;
|
||||||
|
districtName?: string | null;
|
||||||
|
verdictLabel?: GateVerdictLabel | null;
|
||||||
|
score?: number | null;
|
||||||
|
scoreLabel?: string | null;
|
||||||
|
velocityScore?: number | null; // 0..1
|
||||||
|
medianPricePerM2?: number | null;
|
||||||
|
pipeline24mo?: number | null; // total flats in 24-month pipeline
|
||||||
|
confidenceLabel?: "high" | "medium" | "low" | null;
|
||||||
|
confidenceValue?: number | null; // 0..1
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: CompareColumn[];
|
||||||
|
onRemove: (cad: string) => 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<GateVerdictLabel, BadgeVariant> = {
|
||||||
|
Можно: "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 ? (
|
||||||
|
<Badge variant={VERDICT_BADGE[c.verdictLabel]} size="sm">
|
||||||
|
{c.verdictLabel}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "score",
|
||||||
|
label: "Инвест-балл",
|
||||||
|
winner: "high",
|
||||||
|
value: (c) => c.score ?? null,
|
||||||
|
render: (c) => (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{fmtScore(c.score)}
|
||||||
|
{c.scoreLabel ? (
|
||||||
|
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
||||||
|
{" "}
|
||||||
|
· {c.scoreLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "velocity",
|
||||||
|
label: "Скорость продаж",
|
||||||
|
winner: "high",
|
||||||
|
value: (c) => c.velocityScore ?? null,
|
||||||
|
render: (c) =>
|
||||||
|
c.velocityScore != null && Number.isFinite(c.velocityScore) ? (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{Math.round(c.velocityScore * 100)}
|
||||||
|
<span style={{ color: "var(--fg-tertiary)", fontSize: 12 }}>
|
||||||
|
{" "}
|
||||||
|
/ 100
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
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) => (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{fmtPrice(c.medianPricePerM2)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pipeline",
|
||||||
|
label: "Пайплайн 24 мес",
|
||||||
|
// More competing pipeline = more future supply = worse. Lower wins.
|
||||||
|
winner: "low",
|
||||||
|
value: (c) => c.pipeline24mo ?? null,
|
||||||
|
render: (c) => (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{fmtPipeline(c.pipeline24mo)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "confidence",
|
||||||
|
label: "Достоверность",
|
||||||
|
winner: "high",
|
||||||
|
value: (c) => c.confidenceValue ?? null,
|
||||||
|
render: (c) =>
|
||||||
|
c.confidenceLabel ? (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{c.confidenceValue != null
|
||||||
|
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||||
|
: ""}
|
||||||
|
{CONFIDENCE_RU[c.confidenceLabel]}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
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<string> {
|
||||||
|
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<string, Set<string>>(
|
||||||
|
METRIC_ROWS.map((row) => [row.key, winnersFor(row, columns)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 14,
|
||||||
|
minWidth: 120 + columns.length * 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={cornerTh}>Фактор</th>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c.cad} style={colTh}>
|
||||||
|
<div style={colHeadInner}>
|
||||||
|
<Link
|
||||||
|
href={`/site-finder/analysis/${encodeURIComponent(c.cad)}`}
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
title="Открыть полный анализ участка"
|
||||||
|
>
|
||||||
|
{c.cad}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(c.cad)}
|
||||||
|
aria-label={`Убрать ${c.cad} из сравнения`}
|
||||||
|
style={removeBtn}
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{c.districtName && c.districtName !== DASH ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.districtName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{METRIC_ROWS.map((row) => {
|
||||||
|
const winners = winnerByRow.get(row.key) ?? new Set<string>();
|
||||||
|
return (
|
||||||
|
<tr key={row.key} style={{ borderTop: "1px solid var(--border-soft)" }}>
|
||||||
|
<th style={rowTh}>{row.label}</th>
|
||||||
|
{columns.map((c) => {
|
||||||
|
const isWinner = winners.has(c.cad);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={c.cad}
|
||||||
|
style={{
|
||||||
|
...cell,
|
||||||
|
background: isWinner ? "var(--success-soft)" : undefined,
|
||||||
|
color: isWinner ? "var(--success)" : "var(--fg-primary)",
|
||||||
|
fontWeight: isWinner ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.status === "loading" ? (
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>…</span>
|
||||||
|
) : c.status === "error" ? (
|
||||||
|
<span
|
||||||
|
style={{ color: "var(--danger)", fontSize: 12 }}
|
||||||
|
title={c.errorMsg ?? "Ошибка анализа"}
|
||||||
|
>
|
||||||
|
ошибка
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
row.render(c)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
60
frontend/src/lib/compare-shortlist.ts
Normal file
60
frontend/src/lib/compare-shortlist.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue