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 =
|
||||
| "estimate"
|
||||
| "sale-share"
|
||||
| "history"
|
||||
| "cache"
|
||||
| "scrapers"
|
||||
|
|
@ -58,6 +59,13 @@ const NAV_ITEMS: Array<{
|
|||
label: string;
|
||||
}> = [
|
||||
{ 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: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
|
||||
// Скраперы — 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; }
|
||||
.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