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