feat(tradein/sale-share): тумблер «Сейчас / За 45 дней» + фильтр «мин. объявлений» #2092
5 changed files with 150 additions and 13 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue