feat(tradein): страница «доля квартир дома в продаже» — % слайдер, адреса, карта (#2058)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
This commit is contained in:
parent
c97dd57e1c
commit
f8112b420d
11 changed files with 1611 additions and 0 deletions
218
tradein-mvp/frontend/src/app/sale-share/page.tsx
Normal file
218
tradein-mvp/frontend/src/app/sale-share/page.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /trade-in/sale-share — «доля квартир дома в продаже».
|
||||||
|
* Порог % → дома вторички, где доля квартир, выставленных на продажу, ≥ порога.
|
||||||
|
* Сигнал для девелопера: расселение / инвест-выход / проблемный дом.
|
||||||
|
*
|
||||||
|
* Доступ: pilot + admin (RBAC roles.yaml: pilot paths `/trade-in/**`).
|
||||||
|
*/
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import "@/components/trade-in/trade-in.css";
|
||||||
|
import { Topbar } from "@/components/trade-in/Topbar";
|
||||||
|
import { SaleShareControls } from "@/components/trade-in/SaleShareControls";
|
||||||
|
import { SaleShareList } from "@/components/trade-in/SaleShareList";
|
||||||
|
import { BuildingListingsDrawer } from "@/components/trade-in/BuildingListingsDrawer";
|
||||||
|
import { useSaleShareBuildings, useSaleShareSummary } from "@/lib/sale-share-api";
|
||||||
|
import { useDebouncedValue } from "@/lib/useDebouncedValue";
|
||||||
|
import type { BuildingSaleShare, SaleShareQuery, SaleShareSort } from "@/types/sale-share";
|
||||||
|
|
||||||
|
// Карта тянет Leaflet с CDN (window.L) → грузим только в браузере.
|
||||||
|
const SaleShareMap = dynamic(
|
||||||
|
() => import("@/components/trade-in/SaleShareMap").then((m) => m.SaleShareMap),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="ss-map-wrap">
|
||||||
|
<div className="ss-map-empty">Загрузка карты…</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function toNumOrNull(s: string): number | null {
|
||||||
|
const t = s.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SaleSharePage() {
|
||||||
|
// Порог + фильтры (raw controlled state).
|
||||||
|
const [minPct, setMinPct] = useState(5);
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [priceMin, setPriceMin] = useState("");
|
||||||
|
const [priceMax, setPriceMax] = useState("");
|
||||||
|
const [yearMin, setYearMin] = useState("");
|
||||||
|
const [yearMax, setYearMax] = useState("");
|
||||||
|
const [houseType, setHouseType] = useState("");
|
||||||
|
const [sort, setSort] = useState<SaleShareSort>("share_desc");
|
||||||
|
|
||||||
|
// Выбранный дом (drawer) + наведённый дом (sync со списком/картой).
|
||||||
|
const [selected, setSelected] = useState<BuildingSaleShare | null>(null);
|
||||||
|
const [hoveredHouseId, setHoveredHouseId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Дебаунс текстовых/числовых вводов и слайдера — не дёргаем API на keystroke.
|
||||||
|
const debMinPct = useDebouncedValue(minPct, 250);
|
||||||
|
const debCity = useDebouncedValue(city, 400);
|
||||||
|
const debPriceMin = useDebouncedValue(priceMin, 400);
|
||||||
|
const debPriceMax = useDebouncedValue(priceMax, 400);
|
||||||
|
const debYearMin = useDebouncedValue(yearMin, 400);
|
||||||
|
const debYearMax = useDebouncedValue(yearMax, 400);
|
||||||
|
|
||||||
|
const query: SaleShareQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
min_pct: debMinPct,
|
||||||
|
city: debCity.trim() || null,
|
||||||
|
price_min: toNumOrNull(debPriceMin),
|
||||||
|
price_max: toNumOrNull(debPriceMax),
|
||||||
|
year_min: toNumOrNull(debYearMin),
|
||||||
|
year_max: toNumOrNull(debYearMax),
|
||||||
|
house_type: houseType || null,
|
||||||
|
sort,
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
[debMinPct, debCity, debPriceMin, debPriceMax, debYearMin, debYearMax, houseType, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryQ = useSaleShareSummary();
|
||||||
|
const buildingsQ = useSaleShareBuildings(query);
|
||||||
|
const buildings = useMemo(() => buildingsQ.data ?? [], [buildingsQ.data]);
|
||||||
|
|
||||||
|
// Стабильные колбэки (маркеры карты замыкают их при отрисовке).
|
||||||
|
const buildingsRef = useRef<BuildingSaleShare[]>(buildings);
|
||||||
|
buildingsRef.current = buildings;
|
||||||
|
const handleSelect = useCallback((houseId: number) => {
|
||||||
|
setSelected(buildingsRef.current.find((b) => b.house_id === houseId) ?? null);
|
||||||
|
}, []);
|
||||||
|
const handleHover = useCallback((houseId: number | null) => {
|
||||||
|
setHoveredHouseId(houseId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// distinct house_type из текущей выборки (+ выбранный, чтобы не исчезал).
|
||||||
|
const houseTypeOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const b of buildings) if (b.house_type) set.add(b.house_type);
|
||||||
|
if (houseType) set.add(houseType);
|
||||||
|
return [...set].sort();
|
||||||
|
}, [buildings, houseType]);
|
||||||
|
|
||||||
|
const summary = summaryQ.data;
|
||||||
|
const noDenominator = summary !== undefined && summary.buildings_with_denominator === 0;
|
||||||
|
const selectedHouseId = selected?.house_id ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Topbar active="sale-share" />
|
||||||
|
|
||||||
|
<main className="page">
|
||||||
|
<div className="crumbs">
|
||||||
|
<em>Главная</em> <span>›</span> <em>Мера</em> <span>›</span> Доля квартир в продаже
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-title">
|
||||||
|
<div>
|
||||||
|
<h1>Доля квартир дома в продаже</h1>
|
||||||
|
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
||||||
|
Дома вторичного рынка, где доля квартир, выставленных на продажу, превышает
|
||||||
|
порог. Высокая доля — сигнал девелоперу: расселение, массовый инвест-выход
|
||||||
|
или проблемный дом. Выберите порог % и изучите адреса на карте и в списке.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage-баннер */}
|
||||||
|
{summary && (
|
||||||
|
<div className={`ss-banner${noDenominator ? " is-warn" : ""}`} role="status">
|
||||||
|
{noDenominator ? (
|
||||||
|
<>
|
||||||
|
<b>Данные о количестве квартир ещё загружаются</b> (реестр ГАР). Доля в
|
||||||
|
продаже считается как «активные объявления ÷ число квартир дома» — список
|
||||||
|
появится, когда загрузчик реестра отработает.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Процент посчитан для <b>{summary.buildings_with_denominator}</b> из{" "}
|
||||||
|
<b>{summary.total_secondary_buildings}</b> домов (
|
||||||
|
{summary.coverage_pct.toFixed(1)}%). По остальным ещё не загружен реестр
|
||||||
|
квартир (ГАР).
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="result-col" style={{ marginTop: 22 }}>
|
||||||
|
<SaleShareControls
|
||||||
|
summary={summary}
|
||||||
|
minPct={minPct}
|
||||||
|
onMinPct={setMinPct}
|
||||||
|
city={city}
|
||||||
|
onCity={setCity}
|
||||||
|
priceMin={priceMin}
|
||||||
|
onPriceMin={setPriceMin}
|
||||||
|
priceMax={priceMax}
|
||||||
|
onPriceMax={setPriceMax}
|
||||||
|
yearMin={yearMin}
|
||||||
|
onYearMin={setYearMin}
|
||||||
|
yearMax={yearMax}
|
||||||
|
onYearMax={setYearMax}
|
||||||
|
houseType={houseType}
|
||||||
|
onHouseType={setHouseType}
|
||||||
|
houseTypeOptions={houseTypeOptions}
|
||||||
|
sort={sort}
|
||||||
|
onSort={setSort}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">Карта</div>
|
||||||
|
<h2>Дома на карте</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
<div className="ss-legend">
|
||||||
|
<span>низкая</span>
|
||||||
|
<span className="ss-legend-bar" aria-hidden="true" />
|
||||||
|
<span>высокая доля</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SaleShareMap
|
||||||
|
buildings={buildings}
|
||||||
|
selectedHouseId={selectedHouseId}
|
||||||
|
hoveredHouseId={hoveredHouseId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={handleHover}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<SaleShareList
|
||||||
|
buildings={buildings}
|
||||||
|
sort={sort}
|
||||||
|
onSort={setSort}
|
||||||
|
selectedHouseId={selectedHouseId}
|
||||||
|
hoveredHouseId={hoveredHouseId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHover={handleHover}
|
||||||
|
isLoading={buildingsQ.isPending}
|
||||||
|
isError={buildingsQ.isError}
|
||||||
|
minPct={minPct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<BuildingListingsDrawer building={selected} onClose={() => setSelected(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="page-foot">
|
||||||
|
<div>
|
||||||
|
Мера · MVP ·{" "}
|
||||||
|
<span className="mono">доля квартир в продаже · вторичный рынок ЕКБ</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BuildingListingsDrawer — выезжающая справа панель с активными вторичными
|
||||||
|
* объявлениями выбранного дома (GET /buildings/{id}/listings). Ссылка на
|
||||||
|
* оригинал — через safeUrl (только http/https, защита от javascript:/data:).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
import { useBuildingListings } from "@/lib/sale-share-api";
|
||||||
|
import type { BuildingListing, BuildingSaleShare } from "@/types/sale-share";
|
||||||
|
import { fmtPct, fmtRub, heatColor, sourceLabel } from "./saleShareUtils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
building: BuildingSaleShare;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomsLabel(rooms: number | null): string {
|
||||||
|
if (rooms === null) return "—";
|
||||||
|
return rooms === 0 ? "студия" : `${rooms}-к`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BuildingListingsDrawer({ building, onClose }: Props) {
|
||||||
|
const { data, isPending, isError } = useBuildingListings(building.house_id);
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
// Esc закрывает + initial focus на крестик, restore focus при размонтировании.
|
||||||
|
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
closeBtnRef.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
previouslyFocused.current?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const listings: BuildingListing[] = data ?? [];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="ss-drawer-overlay" onClick={onClose}>
|
||||||
|
<aside
|
||||||
|
className="ss-drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="ss-drawer-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="ss-drawer-head">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="section-kicker">Объявления в продаже</div>
|
||||||
|
<h2 id="ss-drawer-title" style={{ fontSize: 16 }}>
|
||||||
|
{building.address ?? "Адрес не указан"}
|
||||||
|
</h2>
|
||||||
|
<div style={{ marginTop: 6, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<span
|
||||||
|
className="ss-pct"
|
||||||
|
style={{ color: "#fff", background: heatColor(building.sale_share_pct) }}
|
||||||
|
>
|
||||||
|
{fmtPct(building.sale_share_pct)} в продаже
|
||||||
|
</span>
|
||||||
|
{building.active_secondary !== null && building.flat_count_effective !== null && (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted)" }}>
|
||||||
|
{building.active_secondary} из {building.flat_count_effective} квартир
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={closeBtnRef}
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="ss-drawer-close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ss-drawer-body">
|
||||||
|
{isPending ? (
|
||||||
|
<p style={{ color: "var(--muted)", padding: "24px 0", textAlign: "center" }}>
|
||||||
|
Загрузка объявлений…
|
||||||
|
</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p style={{ color: "var(--danger, #b3261e)", padding: "24px 0", textAlign: "center" }}>
|
||||||
|
Не удалось загрузить объявления дома.
|
||||||
|
</p>
|
||||||
|
) : listings.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--muted)", padding: "24px 0", textAlign: "center" }}>
|
||||||
|
Активных вторичных объявлений по дому не найдено.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="ss-listing-list">
|
||||||
|
{listings.map((l) => {
|
||||||
|
const url = safeUrl(l.source_url);
|
||||||
|
const floorTxt =
|
||||||
|
l.floor !== null
|
||||||
|
? l.total_floors !== null
|
||||||
|
? `этаж ${l.floor}/${l.total_floors}`
|
||||||
|
: `этаж ${l.floor}`
|
||||||
|
: null;
|
||||||
|
const meta = [
|
||||||
|
roomsLabel(l.rooms),
|
||||||
|
l.area_m2 !== null ? `${l.area_m2.toFixed(1)} м²` : null,
|
||||||
|
floorTxt,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
return (
|
||||||
|
<li key={l.listing_id} className="ss-listing">
|
||||||
|
<div className="ss-listing-main">
|
||||||
|
<div className="ss-listing-price">
|
||||||
|
{l.price_rub !== null ? `${fmtRub(l.price_rub)} ₽` : "цена не указана"}
|
||||||
|
</div>
|
||||||
|
<div className="ss-listing-meta">{meta || "—"}</div>
|
||||||
|
<div className="ss-listing-sub">
|
||||||
|
{sourceLabel(l.source)}
|
||||||
|
{l.price_per_m2 !== null ? ` · ${fmtRub(l.price_per_m2)} ₽/м²` : ""}
|
||||||
|
{l.days_on_market !== null ? ` · ${l.days_on_market} дн в продаже` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{url && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ss-listing-link"
|
||||||
|
aria-label="Открыть оригинал объявления"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
<polyline points="15 3 21 3 21 9" />
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaleShareControls — порог % (слайдер + гистограмма распределения) и фильтры.
|
||||||
|
* Controlled: всё состояние живёт в page.tsx, сюда приходит через props.
|
||||||
|
*/
|
||||||
|
import type { SaleShareSort, SaleShareSummary } from "@/types/sale-share";
|
||||||
|
import { heatColor, houseTypeLabel } from "./saleShareUtils";
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{ value: SaleShareSort; label: string }> = [
|
||||||
|
{ value: "share_desc", label: "Доля в продаже ↓" },
|
||||||
|
{ value: "active_desc", label: "Кол-во в продаже ↓" },
|
||||||
|
{ value: "exposure_desc", label: "Срок экспозиции ↓" },
|
||||||
|
{ value: "price_asc", label: "Цена ↑" },
|
||||||
|
{ value: "price_desc", label: "Цена ↓" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Верхняя граница корзины: "10-20" → 20, "100+" → Infinity. */
|
||||||
|
function bucketUpperBound(label: string): number {
|
||||||
|
if (label.endsWith("+")) return Infinity;
|
||||||
|
const parts = label.split("-");
|
||||||
|
return parts.length === 2 ? Number(parts[1]) : Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нижняя граница корзины (для выбора цвета): "10-20" → 10, "100+" → 100. */
|
||||||
|
function bucketLowerBound(label: string): number {
|
||||||
|
const m = label.match(/^(\d+)/);
|
||||||
|
return m ? Number(m[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistogramProps {
|
||||||
|
summary: SaleShareSummary | undefined;
|
||||||
|
minPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Histogram({ summary, minPct }: HistogramProps) {
|
||||||
|
if (!summary || summary.histogram.length === 0) return null;
|
||||||
|
const maxCount = Math.max(1, ...summary.histogram.map((b) => b.count));
|
||||||
|
return (
|
||||||
|
<div className="ss-hist" role="img" aria-label="Распределение домов по доле квартир в продаже">
|
||||||
|
{summary.histogram.map((b) => {
|
||||||
|
const inRange = bucketUpperBound(b.bucket) > minPct;
|
||||||
|
const h = Math.round((b.count / maxCount) * 100);
|
||||||
|
return (
|
||||||
|
<div className="ss-hist-col" key={b.bucket} title={`${b.bucket}% — ${b.count} домов`}>
|
||||||
|
<span className="ss-hist-count">{b.count}</span>
|
||||||
|
<span className="ss-hist-track">
|
||||||
|
<span
|
||||||
|
className="ss-hist-bar"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(b.count > 0 ? 4 : 0, h)}%`,
|
||||||
|
background: heatColor(bucketLowerBound(b.bucket) + 0.5),
|
||||||
|
opacity: inRange ? 1 : 0.28,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="ss-hist-label">{b.bucket}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
summary: SaleShareSummary | undefined;
|
||||||
|
minPct: number;
|
||||||
|
onMinPct: (v: number) => void;
|
||||||
|
city: string;
|
||||||
|
onCity: (v: string) => void;
|
||||||
|
priceMin: string;
|
||||||
|
onPriceMin: (v: string) => void;
|
||||||
|
priceMax: string;
|
||||||
|
onPriceMax: (v: string) => void;
|
||||||
|
yearMin: string;
|
||||||
|
onYearMin: (v: string) => void;
|
||||||
|
yearMax: string;
|
||||||
|
onYearMax: (v: string) => void;
|
||||||
|
houseType: string;
|
||||||
|
onHouseType: (v: string) => void;
|
||||||
|
houseTypeOptions: string[];
|
||||||
|
sort: SaleShareSort;
|
||||||
|
onSort: (v: SaleShareSort) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaleShareControls({
|
||||||
|
summary,
|
||||||
|
minPct,
|
||||||
|
onMinPct,
|
||||||
|
city,
|
||||||
|
onCity,
|
||||||
|
priceMin,
|
||||||
|
onPriceMin,
|
||||||
|
priceMax,
|
||||||
|
onPriceMax,
|
||||||
|
yearMin,
|
||||||
|
onYearMin,
|
||||||
|
yearMax,
|
||||||
|
onYearMax,
|
||||||
|
houseType,
|
||||||
|
onHouseType,
|
||||||
|
houseTypeOptions,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
}: Props) {
|
||||||
|
const sliderMax =
|
||||||
|
summary && summary.max_pct != null
|
||||||
|
? Math.max(10, Math.ceil(summary.max_pct))
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">Порог и фильтры</div>
|
||||||
|
<h2>Доля квартир дома в продаже ≥ {minPct}%</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<Histogram summary={summary} minPct={minPct} />
|
||||||
|
|
||||||
|
<div className="ss-slider-row">
|
||||||
|
<label htmlFor="ss-minpct" className="ss-slider-label">
|
||||||
|
Порог доли, %
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ss-minpct"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={sliderMax}
|
||||||
|
step={1}
|
||||||
|
value={Math.min(minPct, sliderMax)}
|
||||||
|
onChange={(e) => onMinPct(Number(e.target.value))}
|
||||||
|
className="ss-slider"
|
||||||
|
aria-valuetext={`${minPct}%`}
|
||||||
|
/>
|
||||||
|
<output className="ss-slider-out" htmlFor="ss-minpct">
|
||||||
|
{minPct}%
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ss-filters">
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-city">Город</label>
|
||||||
|
<input
|
||||||
|
id="ss-city"
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => onCity(e.target.value)}
|
||||||
|
placeholder="Екатеринбург"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-price-min">Цена от, ₽</label>
|
||||||
|
<input
|
||||||
|
id="ss-price-min"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
value={priceMin}
|
||||||
|
onChange={(e) => onPriceMin(e.target.value)}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-price-max">Цена до, ₽</label>
|
||||||
|
<input
|
||||||
|
id="ss-price-max"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
value={priceMax}
|
||||||
|
onChange={(e) => onPriceMax(e.target.value)}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-year-min">Год от</label>
|
||||||
|
<input
|
||||||
|
id="ss-year-min"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1800}
|
||||||
|
max={2100}
|
||||||
|
value={yearMin}
|
||||||
|
onChange={(e) => onYearMin(e.target.value)}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-year-max">Год до</label>
|
||||||
|
<input
|
||||||
|
id="ss-year-max"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1800}
|
||||||
|
max={2100}
|
||||||
|
value={yearMax}
|
||||||
|
onChange={(e) => onYearMax(e.target.value)}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-house-type">Тип дома</label>
|
||||||
|
<select
|
||||||
|
id="ss-house-type"
|
||||||
|
value={houseType}
|
||||||
|
onChange={(e) => onHouseType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Любой</option>
|
||||||
|
{houseTypeOptions.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{houseTypeLabel(t)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="ss-field">
|
||||||
|
<label htmlFor="ss-sort">Сортировка</label>
|
||||||
|
<select
|
||||||
|
id="ss-sort"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => onSort(e.target.value as SaleShareSort)}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
tradein-mvp/frontend/src/components/trade-in/SaleShareList.tsx
Normal file
241
tradein-mvp/frontend/src/components/trade-in/SaleShareList.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaleShareList — сортируемый список домов: адрес, доля в продаже (акцент),
|
||||||
|
* N из M квартир, медиана цены + ₽/м², срок экспозиции, год/тип/серия, бейджи.
|
||||||
|
* Hover строки ↔ подсветка маркера на карте; клик → drawer с объявлениями.
|
||||||
|
* Сортировка серверная — клик по заголовку меняет `sort` (драйвит query).
|
||||||
|
*/
|
||||||
|
import type { BuildingSaleShare, SaleShareSort } from "@/types/sale-share";
|
||||||
|
import {
|
||||||
|
fmtDays,
|
||||||
|
fmtPct,
|
||||||
|
fmtRub,
|
||||||
|
heatColor,
|
||||||
|
houseTypeLabel,
|
||||||
|
} from "./saleShareUtils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
buildings: BuildingSaleShare[];
|
||||||
|
sort: SaleShareSort;
|
||||||
|
onSort: (s: SaleShareSort) => void;
|
||||||
|
selectedHouseId: number | null;
|
||||||
|
hoveredHouseId: number | null;
|
||||||
|
onSelect: (houseId: number) => void;
|
||||||
|
onHover: (houseId: number | null) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
minPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стрелка-индикатор активной сортировки. */
|
||||||
|
function sortArrow(active: boolean, dir: "asc" | "desc"): string {
|
||||||
|
if (!active) return "";
|
||||||
|
return dir === "asc" ? " ↑" : " ↓";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaleShareList({
|
||||||
|
buildings,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
selectedHouseId,
|
||||||
|
hoveredHouseId,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
minPct,
|
||||||
|
}: Props) {
|
||||||
|
const togglePrice = () =>
|
||||||
|
onSort(sort === "price_asc" ? "price_desc" : "price_asc");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">Дома</div>
|
||||||
|
<h2>Адреса с долей в продаже ≥ {minPct}%</h2>
|
||||||
|
</div>
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<div className="card-meta">
|
||||||
|
<div>
|
||||||
|
Найдено: <b>{buildings.length}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
|
||||||
|
Загрузка домов…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ color: "var(--danger, #b3261e)", textAlign: "center", padding: "24px 0" }}>
|
||||||
|
Не удалось загрузить список домов. Обновите страницу или попробуйте позже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : buildings.length === 0 ? (
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
|
||||||
|
Нет домов с долей в продаже ≥ {minPct}% при текущих фильтрах.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ss-table-scroll">
|
||||||
|
<table className="dt ss-table" aria-label="Дома по доле квартир в продаже">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th className="num">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ss-th-btn"
|
||||||
|
onClick={() => onSort("share_desc")}
|
||||||
|
>
|
||||||
|
Доля{sortArrow(sort === "share_desc", "desc")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="num">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ss-th-btn"
|
||||||
|
onClick={() => onSort("active_desc")}
|
||||||
|
>
|
||||||
|
В продаже{sortArrow(sort === "active_desc", "desc")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="num">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ss-th-btn"
|
||||||
|
onClick={togglePrice}
|
||||||
|
>
|
||||||
|
Медиана
|
||||||
|
{sortArrow(
|
||||||
|
sort === "price_asc" || sort === "price_desc",
|
||||||
|
sort === "price_asc" ? "asc" : "desc",
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="num">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ss-th-btn"
|
||||||
|
onClick={() => onSort("exposure_desc")}
|
||||||
|
>
|
||||||
|
Экспозиция{sortArrow(sort === "exposure_desc", "desc")}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>Дом</th>
|
||||||
|
<th>
|
||||||
|
<span className="sr-only">Действия</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{buildings.map((b) => (
|
||||||
|
<BuildingRow
|
||||||
|
key={b.house_id}
|
||||||
|
b={b}
|
||||||
|
selected={b.house_id === selectedHouseId}
|
||||||
|
hovered={b.house_id === hoveredHouseId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHover={onHover}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildingRow({
|
||||||
|
b,
|
||||||
|
selected,
|
||||||
|
hovered,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: {
|
||||||
|
b: BuildingSaleShare;
|
||||||
|
selected: boolean;
|
||||||
|
hovered: boolean;
|
||||||
|
onSelect: (houseId: number) => void;
|
||||||
|
onHover: (houseId: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const denom =
|
||||||
|
b.active_secondary !== null && b.flat_count_effective !== null
|
||||||
|
? `${b.active_secondary} из ${b.flat_count_effective}`
|
||||||
|
: b.active_secondary !== null
|
||||||
|
? `${b.active_secondary}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const houseMeta = [
|
||||||
|
b.year_built !== null ? `${b.year_built}` : null,
|
||||||
|
houseTypeLabel(b.house_type) !== "—" ? houseTypeLabel(b.house_type) : null,
|
||||||
|
b.total_floors !== null ? `${b.total_floors} эт.` : null,
|
||||||
|
b.series_name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={`ss-row${selected ? " is-selected" : ""}${hovered ? " is-hovered" : ""}`}
|
||||||
|
onMouseEnter={() => onHover(b.house_id)}
|
||||||
|
onMouseLeave={() => onHover(null)}
|
||||||
|
onClick={() => onSelect(b.house_id)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div className="addr">
|
||||||
|
<span className="a-main">{b.address ?? "Адрес не указан"}</span>
|
||||||
|
<span className="a-sub" style={{ display: "inline-flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{b.is_emergency && <span className="ss-badge ss-badge--danger">аварийный</span>}
|
||||||
|
{b.over_100 && (
|
||||||
|
<span className="ss-badge ss-badge--warn">возможно, несколько корпусов</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span
|
||||||
|
className="ss-pct"
|
||||||
|
style={{ color: "#fff", background: heatColor(b.sale_share_pct) }}
|
||||||
|
>
|
||||||
|
{fmtPct(b.sale_share_pct)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{denom}</td>
|
||||||
|
<td className="num">
|
||||||
|
{b.median_price_rub !== null ? (
|
||||||
|
<>
|
||||||
|
{fmtRub(b.median_price_rub)} ₽
|
||||||
|
<div style={{ fontSize: 11, color: "var(--muted)" }}>
|
||||||
|
{b.median_price_per_m2 !== null ? `${fmtRub(b.median_price_per_m2)} ₽/м²` : "—"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">{fmtDays(b.avg_days_on_market)}</td>
|
||||||
|
<td style={{ fontSize: 12, color: "var(--fg-2)" }}>{houseMeta || "—"}</td>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost ss-row-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(b.house_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Объявления →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx
Normal file
233
tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в
|
||||||
|
* продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера
|
||||||
|
* тоже растёт с долей. Reuses the same CDN-loaded Leaflet + OSM approach as
|
||||||
|
* MapCard.tsx / MapPicker.tsx (no npm dep). Дома без координат пропускаем.
|
||||||
|
*
|
||||||
|
* Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен
|
||||||
|
* только в браузере.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { BuildingSaleShare } from "@/types/sale-share";
|
||||||
|
import { fmtPct, heatColor, markerRadius } from "./saleShareUtils";
|
||||||
|
|
||||||
|
const LEAFLET_VER = "1.9.4";
|
||||||
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||||
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||||
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
|
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||||
|
|
||||||
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard) */
|
||||||
|
function loadLeaflet(): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const w = window as any;
|
||||||
|
if (w.L) {
|
||||||
|
resolve(w.L);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!document.querySelector(`link[data-leaflet]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = LEAFLET_CSS;
|
||||||
|
link.integrity = LEAFLET_CSS_SRI;
|
||||||
|
link.crossOrigin = "anonymous";
|
||||||
|
link.setAttribute("data-leaflet", "1");
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(w.L));
|
||||||
|
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = LEAFLET_JS;
|
||||||
|
script.integrity = LEAFLET_JS_SRI;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
|
script.setAttribute("data-leaflet", "1");
|
||||||
|
script.onload = () => resolve(w.L);
|
||||||
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Безопасное экранирование для вставки в HTML popup. */
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupHtml(b: BuildingSaleShare): string {
|
||||||
|
const badges: string[] = [];
|
||||||
|
if (b.is_emergency) {
|
||||||
|
badges.push(
|
||||||
|
`<span style="background:#fef2f2;color:#b91c1c;border-radius:4px;padding:1px 6px;font-size:11px">аварийный</span>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (b.over_100) {
|
||||||
|
badges.push(
|
||||||
|
`<span style="background:#fef9c3;color:#854d0e;border-radius:4px;padding:1px 6px;font-size:11px">возможно, несколько корпусов</span>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const denom =
|
||||||
|
b.active_secondary !== null && b.flat_count_effective !== null
|
||||||
|
? `${b.active_secondary} из ${b.flat_count_effective} квартир`
|
||||||
|
: b.active_secondary !== null
|
||||||
|
? `${b.active_secondary} объявлений`
|
||||||
|
: "";
|
||||||
|
return `<div style="font-size:12px;line-height:1.5;min-width:180px">
|
||||||
|
<b>${esc(b.address ?? "Адрес не указан")}</b><br/>
|
||||||
|
<span style="color:${heatColor(b.sale_share_pct)};font-weight:600">${esc(fmtPct(b.sale_share_pct))}</span> в продаже${denom ? ` · ${esc(denom)}` : ""}
|
||||||
|
${badges.length ? `<br/><span style="display:inline-flex;gap:4px;flex-wrap:wrap;margin-top:4px">${badges.join(" ")}</span>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
buildings: BuildingSaleShare[];
|
||||||
|
selectedHouseId: number | null;
|
||||||
|
hoveredHouseId: number | null;
|
||||||
|
onSelect: (houseId: number) => void;
|
||||||
|
onHover: (houseId: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaleShareMap({
|
||||||
|
buildings,
|
||||||
|
selectedHouseId,
|
||||||
|
hoveredHouseId,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: Props) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapObj = useRef<any>(null);
|
||||||
|
const LRef = useRef<any>(null);
|
||||||
|
const layerRef = useRef<any>(null);
|
||||||
|
const markers = useRef<Map<number, any>>(new Map());
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [mapError, setMapError] = useState(false);
|
||||||
|
|
||||||
|
const geoBuildings = useMemo(
|
||||||
|
() =>
|
||||||
|
buildings.filter(
|
||||||
|
(b) => typeof b.lat === "number" && typeof b.lon === "number",
|
||||||
|
),
|
||||||
|
[buildings],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Init map once.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadLeaflet()
|
||||||
|
.then((L) => {
|
||||||
|
if (cancelled || !mapRef.current) return;
|
||||||
|
LRef.current = L;
|
||||||
|
const map = L.map(mapRef.current).setView(EKB_CENTER, 11);
|
||||||
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: "© OpenStreetMap",
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
layerRef.current = L.layerGroup().addTo(map);
|
||||||
|
mapObj.current = map;
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
setReady(true);
|
||||||
|
})
|
||||||
|
.catch(() => setMapError(true));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (mapObj.current) {
|
||||||
|
mapObj.current.remove();
|
||||||
|
mapObj.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// (Re)draw markers when the building set changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const L = LRef.current;
|
||||||
|
const layer = layerRef.current;
|
||||||
|
const map = mapObj.current;
|
||||||
|
if (!L || !layer || !map) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
markers.current.clear();
|
||||||
|
const bounds: [number, number][] = [];
|
||||||
|
for (const b of geoBuildings) {
|
||||||
|
const ll: [number, number] = [b.lat as number, b.lon as number];
|
||||||
|
bounds.push(ll);
|
||||||
|
const marker = L.circleMarker(ll, {
|
||||||
|
radius: markerRadius(b.sale_share_pct),
|
||||||
|
color: "#ffffff",
|
||||||
|
weight: 1.5,
|
||||||
|
fillColor: heatColor(b.sale_share_pct),
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
});
|
||||||
|
marker.bindPopup(popupHtml(b));
|
||||||
|
marker.on("click", () => onSelect(b.house_id));
|
||||||
|
marker.on("mouseover", () => onHover(b.house_id));
|
||||||
|
marker.on("mouseout", () => onHover(null));
|
||||||
|
marker.addTo(layer);
|
||||||
|
markers.current.set(b.house_id, marker);
|
||||||
|
}
|
||||||
|
if (bounds.length > 1) {
|
||||||
|
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
|
||||||
|
} else if (bounds.length === 1) {
|
||||||
|
map.setView(bounds[0], 14);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- onSelect/onHover стабильны (useCallback в page)
|
||||||
|
}, [ready, geoBuildings]);
|
||||||
|
|
||||||
|
// Focus the selected building: pan + open popup.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || selectedHouseId === null) return;
|
||||||
|
const marker = markers.current.get(selectedHouseId);
|
||||||
|
const map = mapObj.current;
|
||||||
|
if (marker && map) {
|
||||||
|
map.panTo(marker.getLatLng(), { animate: true, duration: 0.3 });
|
||||||
|
marker.openPopup();
|
||||||
|
}
|
||||||
|
}, [ready, selectedHouseId]);
|
||||||
|
|
||||||
|
// Highlight the hovered building's marker (sync with list row hover).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
markers.current.forEach((marker, id) => {
|
||||||
|
const b = geoBuildings.find((x) => x.house_id === id);
|
||||||
|
if (!b) return;
|
||||||
|
const isHover = id === hoveredHouseId;
|
||||||
|
marker.setStyle({ weight: isHover ? 3 : 1.5, fillOpacity: isHover ? 1 : 0.85 });
|
||||||
|
marker.setRadius(
|
||||||
|
isHover ? markerRadius(b.sale_share_pct) + 3 : markerRadius(b.sale_share_pct),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [ready, hoveredHouseId, geoBuildings]);
|
||||||
|
|
||||||
|
if (mapError) {
|
||||||
|
return (
|
||||||
|
<div className="ss-map-wrap">
|
||||||
|
<div className="ss-map-empty" style={{ color: "var(--danger, #b3261e)" }}>
|
||||||
|
Не удалось загрузить карту. Проверьте интернет-соединение.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ss-map-wrap">
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
className="ss-map"
|
||||||
|
aria-label="Карта домов по доле квартир в продаже"
|
||||||
|
/>
|
||||||
|
{ready && geoBuildings.length === 0 && (
|
||||||
|
<div className="ss-map-empty">
|
||||||
|
Нет домов с координатами для отображения на карте.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ function MeraMark() {
|
||||||
|
|
||||||
export type ActiveTab =
|
export type ActiveTab =
|
||||||
| "estimate"
|
| "estimate"
|
||||||
|
| "sale-share"
|
||||||
| "history"
|
| "history"
|
||||||
| "cache"
|
| "cache"
|
||||||
| "scrapers"
|
| "scrapers"
|
||||||
|
|
@ -58,6 +59,13 @@ const NAV_ITEMS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
|
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
|
||||||
|
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
|
||||||
|
{
|
||||||
|
key: "sale-share",
|
||||||
|
href: "/sale-share",
|
||||||
|
scopePath: "/trade-in/sale-share",
|
||||||
|
label: "Доля в продаже",
|
||||||
|
},
|
||||||
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
|
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
|
||||||
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
|
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
|
||||||
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
|
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Утилиты страницы sale-share: цвет/размер тепловых маркеров + форматтеры.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цвет «тепла» доли в продаже: низкая (норма) → зелёный, высокая (сигнал
|
||||||
|
* расселения / инвест-выхода / проблемного дома) → красный. null → серый.
|
||||||
|
* Пороги согласованы с корзинами гистограммы (HISTOGRAM_BUCKETS на бэкенде).
|
||||||
|
*/
|
||||||
|
export function heatColor(pct: number | null): string {
|
||||||
|
if (pct === null) return "#9ca3af"; // gray-400 — знаменатель неизвестен
|
||||||
|
if (pct < 5) return "#16a34a"; // green-600
|
||||||
|
if (pct < 10) return "#65a30d"; // lime-600
|
||||||
|
if (pct < 20) return "#ca8a04"; // yellow-600
|
||||||
|
if (pct < 30) return "#ea580c"; // orange-600
|
||||||
|
if (pct < 50) return "#dc2626"; // red-600
|
||||||
|
if (pct <= 100) return "#b91c1c"; // red-700
|
||||||
|
return "#7f1d1d"; // red-900 — over_100
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Радиус маркера на карте: 5..14 px, растёт с долей. null → минимум. */
|
||||||
|
export function markerRadius(pct: number | null): number {
|
||||||
|
if (pct === null) return 5;
|
||||||
|
const clamped = Math.min(Math.max(pct, 0), 100);
|
||||||
|
return 5 + (clamped / 100) * 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRub(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? "—" : v.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPct(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
return v >= 100 ? `${Math.round(v)}%` : `${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDays(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? "—" : `${Math.round(v)} дн`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUSE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
panel: "Панель",
|
||||||
|
brick: "Кирпич",
|
||||||
|
monolith: "Монолит",
|
||||||
|
monolith_brick: "Монолит-кирпич",
|
||||||
|
block: "Блочный",
|
||||||
|
wood: "Дерево",
|
||||||
|
stalin: "Сталинка",
|
||||||
|
other: "Другое",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Гуманизация типа дома; неизвестное значение отдаём как есть. */
|
||||||
|
export function houseTypeLabel(t: string | null | undefined): string {
|
||||||
|
if (!t) return "—";
|
||||||
|
return HOUSE_TYPE_LABELS[t] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
cian: "Циан",
|
||||||
|
avito: "Avito",
|
||||||
|
yandex: "Я.Недв",
|
||||||
|
domklik: "ДомКлик",
|
||||||
|
rosreestr: "Росреестр",
|
||||||
|
n1: "N1.ru",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sourceLabel(s: string | null | undefined): string {
|
||||||
|
if (!s) return "—";
|
||||||
|
return SOURCE_LABELS[s] ?? s;
|
||||||
|
}
|
||||||
|
|
@ -2338,3 +2338,261 @@ html, body { overflow-x: clip; }
|
||||||
.top-nav { flex-wrap: wrap; min-width: 0; margin-left: auto; }
|
.top-nav { flex-wrap: wrap; min-width: 0; margin-left: auto; }
|
||||||
.topbar-inner { max-width: 100%; }
|
.topbar-inner { max-width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SALE SHARE — «доля квартир дома в продаже» (#2055 UI)
|
||||||
|
============================================================ */
|
||||||
|
.ss-banner {
|
||||||
|
max-width: var(--container);
|
||||||
|
margin: 16px auto 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ss-banner.is-warn {
|
||||||
|
background: var(--accent-2-soft);
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.ss-banner b { color: var(--fg); }
|
||||||
|
|
||||||
|
/* Histogram strip */
|
||||||
|
.ss-hist {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
height: 96px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.ss-hist-col {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.ss-hist-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ss-hist-track {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ss-hist-bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 38px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.ss-hist-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider */
|
||||||
|
.ss-slider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.ss-slider-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ss-slider { width: 100%; accent-color: var(--accent); }
|
||||||
|
.ss-slider-out {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters grid */
|
||||||
|
.ss-filters {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ss-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.ss-field label { font-size: 12px; color: var(--muted); }
|
||||||
|
.ss-field input,
|
||||||
|
.ss-field select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ss-field input:focus,
|
||||||
|
.ss-field select:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map */
|
||||||
|
.ss-map-wrap { position: relative; width: 100%; }
|
||||||
|
.ss-map { height: 460px; width: 100%; background: var(--surface-2); }
|
||||||
|
.ss-map-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 460px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ss-legend {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ss-legend-bar {
|
||||||
|
width: 90px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#16a34a 0%,
|
||||||
|
#ca8a04 40%,
|
||||||
|
#ea580c 65%,
|
||||||
|
#dc2626 85%,
|
||||||
|
#7f1d1d 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List table */
|
||||||
|
.ss-table-scroll { width: 100%; overflow-x: auto; }
|
||||||
|
.ss-table { width: 100%; }
|
||||||
|
.ss-th-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-2);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ss-th-btn:hover { color: var(--accent); }
|
||||||
|
.ss-row { cursor: pointer; transition: background 0.12s ease; }
|
||||||
|
.ss-row:hover,
|
||||||
|
.ss-row.is-hovered { background: var(--surface-2); }
|
||||||
|
.ss-row.is-selected { background: var(--accent-soft); }
|
||||||
|
.ss-pct {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ss-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ss-badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.ss-badge--warn { background: var(--accent-2-soft); color: var(--accent-2); }
|
||||||
|
.ss-row-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer */
|
||||||
|
.ss-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ss-drawer {
|
||||||
|
background: var(--surface);
|
||||||
|
width: min(460px, 100%);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -8px 0 24px -8px rgba(16, 24, 40, 0.25);
|
||||||
|
}
|
||||||
|
.ss-drawer-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ss-drawer-close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.ss-drawer-close:hover { color: var(--fg); }
|
||||||
|
.ss-drawer-body { flex: 1; overflow-y: auto; padding: 12px 18px 24px; }
|
||||||
|
.ss-listing-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.ss-listing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ss-listing-main { min-width: 0; }
|
||||||
|
.ss-listing-price {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ss-listing-meta { font-size: 13px; color: var(--fg-2); margin-top: 2px; }
|
||||||
|
.ss-listing-sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.ss-listing-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.ss-listing-link:hover { background: var(--surface-2); text-decoration: none; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ss-slider-row { grid-template-columns: 1fr auto; }
|
||||||
|
.ss-slider-label { grid-column: 1 / -1; }
|
||||||
|
.ss-hist { height: 76px; }
|
||||||
|
.ss-map, .ss-map-empty { height: 360px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
81
tradein-mvp/frontend/src/lib/sale-share-api.ts
Normal file
81
tradein-mvp/frontend/src/lib/sale-share-api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "./api";
|
||||||
|
import type {
|
||||||
|
BuildingListing,
|
||||||
|
BuildingSaleShare,
|
||||||
|
SaleShareQuery,
|
||||||
|
SaleShareSummary,
|
||||||
|
} from "@/types/sale-share";
|
||||||
|
|
||||||
|
const BASE = "/api/v1/buildings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/buildings/sale-share/summary
|
||||||
|
* Сводка распределения sale_share_pct: coverage, max/p95, гистограмма.
|
||||||
|
* Нужна для слайдера % и баннера покрытия — кэшируем подольше.
|
||||||
|
*/
|
||||||
|
export function useSaleShareSummary() {
|
||||||
|
return useQuery<SaleShareSummary>({
|
||||||
|
queryKey: ["sale-share", "summary"],
|
||||||
|
queryFn: () => apiFetch<SaleShareSummary>(`${BASE}/sale-share/summary`),
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(q: SaleShareQuery): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("min_pct", String(q.min_pct));
|
||||||
|
if (q.max_pct != null) p.set("max_pct", String(q.max_pct));
|
||||||
|
if (q.city) p.set("city", q.city);
|
||||||
|
if (q.price_min != null) p.set("price_min", String(q.price_min));
|
||||||
|
if (q.price_max != null) p.set("price_max", String(q.price_max));
|
||||||
|
if (q.year_min != null) p.set("year_min", String(q.year_min));
|
||||||
|
if (q.year_max != null) p.set("year_max", String(q.year_max));
|
||||||
|
if (q.house_type) p.set("house_type", q.house_type);
|
||||||
|
p.set("sort", q.sort);
|
||||||
|
if (q.limit != null) p.set("limit", String(q.limit));
|
||||||
|
return p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/buildings/sale-share
|
||||||
|
* Список домов вторички с долей квартир в продаже >= min_pct (+ фильтры).
|
||||||
|
*/
|
||||||
|
export function useSaleShareBuildings(query: SaleShareQuery) {
|
||||||
|
return useQuery<BuildingSaleShare[]>({
|
||||||
|
queryKey: [
|
||||||
|
"sale-share",
|
||||||
|
"buildings",
|
||||||
|
query.min_pct,
|
||||||
|
query.max_pct ?? null,
|
||||||
|
query.city ?? null,
|
||||||
|
query.price_min ?? null,
|
||||||
|
query.price_max ?? null,
|
||||||
|
query.year_min ?? null,
|
||||||
|
query.year_max ?? null,
|
||||||
|
query.house_type ?? null,
|
||||||
|
query.sort,
|
||||||
|
query.limit ?? 200,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<BuildingSaleShare[]>(`${BASE}/sale-share?${buildParams(query)}`),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/buildings/{house_id}/listings
|
||||||
|
* Активные вторичные объявления одного дома (для drawer). Лениво —
|
||||||
|
* enabled только когда выбран дом.
|
||||||
|
*/
|
||||||
|
export function useBuildingListings(houseId: number | null) {
|
||||||
|
return useQuery<BuildingListing[]>({
|
||||||
|
queryKey: ["sale-share", "listings", houseId],
|
||||||
|
queryFn: () => apiFetch<BuildingListing[]>(`${BASE}/${houseId}/listings`),
|
||||||
|
enabled: houseId !== null,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
tradein-mvp/frontend/src/lib/useDebouncedValue.ts
Normal file
15
tradein-mvp/frontend/src/lib/useDebouncedValue.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает значение, обновляемое с задержкой `delayMs` после последнего
|
||||||
|
* изменения `value`. Используется чтобы не дёргать API на каждый keystroke /
|
||||||
|
* каждый тик слайдера. Таймер — не HTTP, поэтому useEffect здесь уместен.
|
||||||
|
*/
|
||||||
|
export function useDebouncedValue<T>(value: T, delayMs = 350): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
82
tradein-mvp/frontend/src/types/sale-share.ts
Normal file
82
tradein-mvp/frontend/src/types/sale-share.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Sale-share — типы страницы «доля квартир дома в продаже».
|
||||||
|
// Hand-written, зеркалит Pydantic-схемы tradein-mvp/backend/app/schemas/buildings.py.
|
||||||
|
// (В tradein нет OpenAPI-codegen — типы поддерживаем вручную, как trade-in.ts.)
|
||||||
|
|
||||||
|
// Допустимые значения сортировки (бэкенд валидирует тем же паттерном).
|
||||||
|
export type SaleShareSort =
|
||||||
|
| "share_desc"
|
||||||
|
| "active_desc"
|
||||||
|
| "exposure_desc"
|
||||||
|
| "price_asc"
|
||||||
|
| "price_desc";
|
||||||
|
|
||||||
|
/** Один дом вторичного рынка из v_building_sale_share. */
|
||||||
|
export interface BuildingSaleShare {
|
||||||
|
house_id: number;
|
||||||
|
address: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
// Доля квартир дома, выставленных на вторичную продажу. null, если по дому
|
||||||
|
// ещё не загружен реестр квартир (ГАР) → знаменатель неизвестен.
|
||||||
|
sale_share_pct: number | null;
|
||||||
|
// active_secondary > знаменателя — implausible-матч (коллизия адреса при
|
||||||
|
// ГАР-матче). Не прячем, а маркируем бейджем «возможно, несколько корпусов».
|
||||||
|
over_100: boolean;
|
||||||
|
active_secondary: number | null;
|
||||||
|
flat_count_effective: number | null;
|
||||||
|
gar_match_method: string | null;
|
||||||
|
median_price_rub: number | null;
|
||||||
|
median_price_per_m2: number | null;
|
||||||
|
avg_days_on_market: number | null;
|
||||||
|
year_built: number | null;
|
||||||
|
house_type: string | null;
|
||||||
|
total_floors: number | null;
|
||||||
|
series_name: string | null;
|
||||||
|
is_emergency: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активное вторичное объявление в доме (click-through drawer). */
|
||||||
|
export interface BuildingListing {
|
||||||
|
listing_id: number;
|
||||||
|
source: string;
|
||||||
|
source_url: string | null;
|
||||||
|
price_rub: number | null;
|
||||||
|
price_per_m2: number | null;
|
||||||
|
rooms: number | null;
|
||||||
|
area_m2: number | null;
|
||||||
|
floor: number | null;
|
||||||
|
total_floors: number | null;
|
||||||
|
days_on_market: number | null;
|
||||||
|
listing_date: string | null; // ISO date
|
||||||
|
address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Корзина гистограммы распределения sale_share_pct. */
|
||||||
|
export interface HistogramBucket {
|
||||||
|
bucket: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сводка для слайдера % + баннера покрытия. */
|
||||||
|
export interface SaleShareSummary {
|
||||||
|
total_secondary_buildings: number;
|
||||||
|
buildings_with_denominator: number;
|
||||||
|
coverage_pct: number;
|
||||||
|
max_pct: number | null;
|
||||||
|
p95_pct: number | null;
|
||||||
|
histogram: HistogramBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Параметры запроса списка домов (драйвят query-key). */
|
||||||
|
export interface SaleShareQuery {
|
||||||
|
min_pct: number;
|
||||||
|
max_pct?: number | null;
|
||||||
|
city?: string | null;
|
||||||
|
price_min?: number | null;
|
||||||
|
price_max?: number | null;
|
||||||
|
year_min?: number | null;
|
||||||
|
year_max?: number | null;
|
||||||
|
house_type?: string | null;
|
||||||
|
sort: SaleShareSort;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue