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

This commit is contained in:
lekss361 2026-06-28 14:53:38 +00:00
parent c97dd57e1c
commit f8112b420d
11 changed files with 1611 additions and 0 deletions

View 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>
</>
);
}

View file

@ -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,
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
);
}
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>
);
}

View file

@ -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 их не видел.

View file

@ -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;
}

View file

@ -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; }
}

View 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,
});
}

View 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;
}

View 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;
}