feat(tradein/sale-share): тумблер «Сейчас / За 45 дней» + фильтр «мин. объявлений» #2092

Merged
lekss361 merged 1 commit from feat/tradein-sale-share-45d-toggle into main 2026-06-28 23:13:14 +00:00
5 changed files with 150 additions and 13 deletions

View file

@ -49,6 +49,10 @@ export default function SaleSharePage() {
const [yearMax, setYearMax] = useState("");
const [houseType, setHouseType] = useState("");
const [sort, setSort] = useState<SaleShareSort>("share_desc");
// Окно метрики: «сейчас» (активные сейчас) или «за 45 дней».
const [window, setWindow] = useState<"now" | "45d">("now");
// Клиентский фильтр «мин. объявлений в доме» (см. filteredBuildings ниже).
const [minCount, setMinCount] = useState<number>(0);
// Выбранный дом (drawer) + наведённый дом (sync со списком/картой).
const [selected, setSelected] = useState<BuildingSaleShare | null>(null);
@ -62,6 +66,15 @@ export default function SaleSharePage() {
const debYearMin = useDebouncedValue(yearMin, 400);
const debYearMax = useDebouncedValue(yearMax, 400);
// Сорт, уходящий в API, зависит от окна: при «45 дней» share/active мапятся
// на 45-дневные метрики (остальные — цена/экспозиция — без изменений).
const apiSort: SaleShareSort = useMemo(() => {
if (window !== "45d") return sort;
if (sort === "share_desc") return "share_45d_desc";
if (sort === "active_desc") return "count_45d_desc";
return sort;
}, [sort, window]);
const query: SaleShareQuery = useMemo(
() => ({
min_pct: debMinPct,
@ -71,19 +84,30 @@ export default function SaleSharePage() {
year_min: toNumOrNull(debYearMin),
year_max: toNumOrNull(debYearMax),
house_type: houseType || null,
sort,
sort: apiSort,
limit: 200,
}),
[debMinPct, debCity, debPriceMin, debPriceMax, debYearMin, debYearMax, houseType, sort],
[debMinPct, debCity, debPriceMin, debPriceMax, debYearMin, debYearMax, houseType, apiSort],
);
const summaryQ = useSaleShareSummary();
const buildingsQ = useSaleShareBuildings(query);
const buildings = useMemo(() => buildingsQ.data ?? [], [buildingsQ.data]);
// Клиентский фильтр «мин. объявлений в доме» — намеренно НЕ серверный:
// не трогаем query-key/бэкенд, фильтруем уже загруженный список по окну.
// Счётчик null трактуем как 0; minCount === 0 → фильтр выключен.
const filteredBuildings = useMemo(() => {
if (minCount <= 0) return buildings;
return buildings.filter((b) => {
const count = (window === "45d" ? b.listings_45d : b.active_secondary) ?? 0;
return count >= minCount;
});
}, [buildings, minCount, window]);
// Стабильные колбэки (маркеры карты замыкают их при отрисовке).
const buildingsRef = useRef<BuildingSaleShare[]>(buildings);
buildingsRef.current = buildings;
const buildingsRef = useRef<BuildingSaleShare[]>(filteredBuildings);
buildingsRef.current = filteredBuildings;
const handleSelect = useCallback((houseId: number) => {
setSelected(buildingsRef.current.find((b) => b.house_id === houseId) ?? null);
}, []);
@ -164,6 +188,10 @@ export default function SaleSharePage() {
houseTypeOptions={houseTypeOptions}
sort={sort}
onSort={setSort}
window={window}
onWindow={setWindow}
minCount={minCount}
onMinCount={setMinCount}
/>
<article className="card">
@ -181,7 +209,7 @@ export default function SaleSharePage() {
</div>
</div>
<SaleShareMap
buildings={buildings}
buildings={filteredBuildings}
selectedHouseId={selectedHouseId}
hoveredHouseId={hoveredHouseId}
onSelect={handleSelect}
@ -190,7 +218,7 @@ export default function SaleSharePage() {
</article>
<SaleShareList
buildings={buildings}
buildings={filteredBuildings}
sort={sort}
onSort={setSort}
selectedHouseId={selectedHouseId}
@ -200,6 +228,7 @@ export default function SaleSharePage() {
isLoading={buildingsQ.isPending}
isError={buildingsQ.isError}
minPct={minPct}
window={window}
/>
</div>
</main>

View file

@ -81,6 +81,10 @@ interface Props {
houseTypeOptions: string[];
sort: SaleShareSort;
onSort: (v: SaleShareSort) => void;
window: "now" | "45d";
onWindow: (v: "now" | "45d") => void;
minCount: number;
onMinCount: (v: number) => void;
}
export function SaleShareControls({
@ -102,6 +106,10 @@ export function SaleShareControls({
houseTypeOptions,
sort,
onSort,
window,
onWindow,
minCount,
onMinCount,
}: Props) {
const sliderMax =
summary && summary.max_pct != null
@ -118,6 +126,30 @@ export function SaleShareControls({
</div>
<div className="card-body">
<div className="ss-window-row">
<span id="ss-window-label" className="ss-window-label">
Окно метрики
</span>
<div className="ss-seg" role="group" aria-labelledby="ss-window-label">
<button
type="button"
className={`ss-seg-btn${window === "now" ? " is-active" : ""}`}
aria-pressed={window === "now"}
onClick={() => onWindow("now")}
>
Сейчас
</button>
<button
type="button"
className={`ss-seg-btn${window === "45d" ? " is-active" : ""}`}
aria-pressed={window === "45d"}
onClick={() => onWindow("45d")}
>
За 45 дней
</button>
</div>
</div>
<Histogram summary={summary} minPct={minPct} />
<div className="ss-slider-row">
@ -216,6 +248,22 @@ export function SaleShareControls({
))}
</select>
</div>
<div className="ss-field">
<label htmlFor="ss-min-count">Мин. объявлений в доме</label>
<input
id="ss-min-count"
type="number"
inputMode="numeric"
min={0}
step={1}
value={minCount === 0 ? "" : minCount}
onChange={(e) => {
const n = Math.floor(Number(e.target.value));
onMinCount(Number.isFinite(n) && n > 0 ? n : 0);
}}
placeholder="0"
/>
</div>
<div className="ss-field">
<label htmlFor="ss-sort">Сортировка</label>
<select

View file

@ -26,6 +26,7 @@ interface Props {
isLoading: boolean;
isError: boolean;
minPct: number;
window: "now" | "45d";
}
/** Стрелка-индикатор активной сортировки. */
@ -45,6 +46,7 @@ export function SaleShareList({
isLoading,
isError,
minPct,
window,
}: Props) {
const togglePrice = () =>
onSort(sort === "price_asc" ? "price_desc" : "price_asc");
@ -140,6 +142,7 @@ export function SaleShareList({
<BuildingRow
key={b.house_id}
b={b}
window={window}
selected={b.house_id === selectedHouseId}
hovered={b.house_id === hoveredHouseId}
onSelect={onSelect}
@ -156,22 +159,30 @@ export function SaleShareList({
function BuildingRow({
b,
window,
selected,
hovered,
onSelect,
onHover,
}: {
b: BuildingSaleShare;
window: "now" | "45d";
selected: boolean;
hovered: boolean;
onSelect: (houseId: number) => void;
onHover: (houseId: number | null) => void;
}) {
// Метрики окна: при «45 дней» — 45-дневные поля (с фолбэком на «сейчас»),
// иначе текущие. Цвет/процент/бейдж «>100%» считаются по выбранному окну.
const pct = window === "45d" ? (b.sale_share_pct_45d ?? b.sale_share_pct) : b.sale_share_pct;
const count = window === "45d" ? b.listings_45d : b.active_secondary;
const overHundred = pct !== null && pct > 100;
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}`
count !== null && b.flat_count_effective !== null
? `${count} из ${b.flat_count_effective}`
: count !== null
? `${count}`
: "—";
const houseMeta = [
@ -195,7 +206,7 @@ function BuildingRow({
<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 && (
{overHundred && (
<span className="ss-badge ss-badge--warn">возможно, несколько корпусов</span>
)}
</span>
@ -204,9 +215,9 @@ function BuildingRow({
<td className="num">
<span
className="ss-pct"
style={{ color: "#fff", background: heatColor(b.sale_share_pct) }}
style={{ color: "#fff", background: heatColor(pct) }}
>
{fmtPct(b.sale_share_pct)}
{fmtPct(pct)}
</span>
</td>
<td className="num">{denom}</td>

View file

@ -2400,6 +2400,49 @@ html, body { overflow-x: clip; }
white-space: nowrap;
}
/* Window toggle (сейчас / за 45 дней) */
.ss-window-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.ss-window-label {
font-size: 13px;
color: var(--fg-2);
white-space: nowrap;
}
.ss-seg {
display: inline-flex;
gap: 4px;
background: var(--surface-2);
padding: 4px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.ss-seg-btn {
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 4px);
background: transparent;
color: var(--fg-2);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.ss-seg-btn:hover { background: var(--surface); }
.ss-seg-btn.is-active {
background: var(--accent);
color: var(--surface);
border-color: var(--accent);
}
.ss-seg-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Slider */
.ss-slider-row {
display: grid;

View file

@ -6,6 +6,8 @@
export type SaleShareSort =
| "share_desc"
| "active_desc"
| "share_45d_desc"
| "count_45d_desc"
| "exposure_desc"
| "price_asc"
| "price_desc";
@ -19,6 +21,10 @@ export interface BuildingSaleShare {
// Доля квартир дома, выставленных на вторичную продажу. null, если по дому
// ещё не загружен реестр квартир (ГАР) → знаменатель неизвестен.
sale_share_pct: number | null;
// Те же метрики, но за окно последних 45 дней (доля квартир, которые были
// в продаже хотя бы раз за 45д, и число таких объявлений). null — нет данных.
sale_share_pct_45d: number | null;
listings_45d: number | null;
// active_secondary > знаменателя — implausible-матч (коллизия адреса при
// ГАР-матче). Не прячем, а маркируем бейджем «возможно, несколько корпусов».
over_100: boolean;