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
218 lines
8.6 KiB
TypeScript
218 lines
8.6 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|