gendesign/tradein-mvp/frontend/src/app/sale-share/page.tsx
lekss361 f8112b420d
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
feat(tradein): страница «доля квартир дома в продаже» — % слайдер, адреса, карта (#2058)
2026-06-28 14:53:38 +00:00

218 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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