"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: () => (
),
},
);
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("share_desc");
// Выбранный дом (drawer) + наведённый дом (sync со списком/картой).
const [selected, setSelected] = useState(null);
const [hoveredHouseId, setHoveredHouseId] = useState(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(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();
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 (
<>
Главная › Мера › Доля квартир в продаже
Доля квартир дома в продаже
Дома вторичного рынка, где доля квартир, выставленных на продажу, превышает
порог. Высокая доля — сигнал девелоперу: расселение, массовый инвест-выход
или проблемный дом. Выберите порог % и изучите адреса на карте и в списке.
{/* Coverage-баннер */}
{summary && (
{noDenominator ? (
<>
Данные о количестве квартир ещё загружаются (реестр ГАР). Доля в
продаже считается как «активные объявления ÷ число квартир дома» — список
появится, когда загрузчик реестра отработает.
>
) : (
<>
Процент посчитан для {summary.buildings_with_denominator} из{" "}
{summary.total_secondary_buildings} домов (
{summary.coverage_pct.toFixed(1)}%). По остальным ещё не загружен реестр
квартир (ГАР).
>
)}
)}
{selected && (
setSelected(null)} />
)}
>
);
}