diff --git a/tradein-mvp/frontend/src/app/sale-share/page.tsx b/tradein-mvp/frontend/src/app/sale-share/page.tsx new file mode 100644 index 00000000..9c7b7bfd --- /dev/null +++ b/tradein-mvp/frontend/src/app/sale-share/page.tsx @@ -0,0 +1,218 @@ +"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)} /> + )} + +
+
+ Мера · MVP ·{" "} + доля квартир в продаже · вторичный рынок ЕКБ +
+
+ + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/BuildingListingsDrawer.tsx b/tradein-mvp/frontend/src/components/trade-in/BuildingListingsDrawer.tsx new file mode 100644 index 00000000..15822e85 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/BuildingListingsDrawer.tsx @@ -0,0 +1,170 @@ +"use client"; + +/** + * BuildingListingsDrawer — выезжающая справа панель с активными вторичными + * объявлениями выбранного дома (GET /buildings/{id}/listings). Ссылка на + * оригинал — через safeUrl (только http/https, защита от javascript:/data:). + */ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { safeUrl } from "@/lib/safeUrl"; +import { useBuildingListings } from "@/lib/sale-share-api"; +import type { BuildingListing, BuildingSaleShare } from "@/types/sale-share"; +import { fmtPct, fmtRub, heatColor, sourceLabel } from "./saleShareUtils"; + +interface Props { + building: BuildingSaleShare; + onClose: () => void; +} + +function roomsLabel(rooms: number | null): string { + if (rooms === null) return "—"; + return rooms === 0 ? "студия" : `${rooms}-к`; +} + +export function BuildingListingsDrawer({ building, onClose }: Props) { + const { data, isPending, isError } = useBuildingListings(building.house_id); + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + // Esc закрывает + initial focus на крестик, restore focus при размонтировании. + const closeBtnRef = useRef(null); + const previouslyFocused = useRef(null); + useEffect(() => { + previouslyFocused.current = document.activeElement as HTMLElement | null; + closeBtnRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + previouslyFocused.current?.focus(); + }; + }, [onClose]); + + if (!mounted) return null; + + const listings: BuildingListing[] = data ?? []; + + return createPortal( +
+ +
, + document.body, + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/SaleShareControls.tsx b/tradein-mvp/frontend/src/components/trade-in/SaleShareControls.tsx new file mode 100644 index 00000000..bb1f0932 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/SaleShareControls.tsx @@ -0,0 +1,237 @@ +"use client"; + +/** + * SaleShareControls — порог % (слайдер + гистограмма распределения) и фильтры. + * Controlled: всё состояние живёт в page.tsx, сюда приходит через props. + */ +import type { SaleShareSort, SaleShareSummary } from "@/types/sale-share"; +import { heatColor, houseTypeLabel } from "./saleShareUtils"; + +const SORT_OPTIONS: Array<{ value: SaleShareSort; label: string }> = [ + { value: "share_desc", label: "Доля в продаже ↓" }, + { value: "active_desc", label: "Кол-во в продаже ↓" }, + { value: "exposure_desc", label: "Срок экспозиции ↓" }, + { value: "price_asc", label: "Цена ↑" }, + { value: "price_desc", label: "Цена ↓" }, +]; + +/** Верхняя граница корзины: "10-20" → 20, "100+" → Infinity. */ +function bucketUpperBound(label: string): number { + if (label.endsWith("+")) return Infinity; + const parts = label.split("-"); + return parts.length === 2 ? Number(parts[1]) : Infinity; +} + +/** Нижняя граница корзины (для выбора цвета): "10-20" → 10, "100+" → 100. */ +function bucketLowerBound(label: string): number { + const m = label.match(/^(\d+)/); + return m ? Number(m[1]) : 0; +} + +interface HistogramProps { + summary: SaleShareSummary | undefined; + minPct: number; +} + +function Histogram({ summary, minPct }: HistogramProps) { + if (!summary || summary.histogram.length === 0) return null; + const maxCount = Math.max(1, ...summary.histogram.map((b) => b.count)); + return ( +
+ {summary.histogram.map((b) => { + const inRange = bucketUpperBound(b.bucket) > minPct; + const h = Math.round((b.count / maxCount) * 100); + return ( +
+ {b.count} + + 0 ? 4 : 0, h)}%`, + background: heatColor(bucketLowerBound(b.bucket) + 0.5), + opacity: inRange ? 1 : 0.28, + }} + /> + + {b.bucket} +
+ ); + })} +
+ ); +} + +interface Props { + summary: SaleShareSummary | undefined; + minPct: number; + onMinPct: (v: number) => void; + city: string; + onCity: (v: string) => void; + priceMin: string; + onPriceMin: (v: string) => void; + priceMax: string; + onPriceMax: (v: string) => void; + yearMin: string; + onYearMin: (v: string) => void; + yearMax: string; + onYearMax: (v: string) => void; + houseType: string; + onHouseType: (v: string) => void; + houseTypeOptions: string[]; + sort: SaleShareSort; + onSort: (v: SaleShareSort) => void; +} + +export function SaleShareControls({ + summary, + minPct, + onMinPct, + city, + onCity, + priceMin, + onPriceMin, + priceMax, + onPriceMax, + yearMin, + onYearMin, + yearMax, + onYearMax, + houseType, + onHouseType, + houseTypeOptions, + sort, + onSort, +}: Props) { + const sliderMax = + summary && summary.max_pct != null + ? Math.max(10, Math.ceil(summary.max_pct)) + : 50; + + return ( +
+
+
+
Порог и фильтры
+

Доля квартир дома в продаже ≥ {minPct}%

+
+
+ +
+ + +
+ + onMinPct(Number(e.target.value))} + className="ss-slider" + aria-valuetext={`${minPct}%`} + /> + + {minPct}% + +
+ +
+
+ + onCity(e.target.value)} + placeholder="Екатеринбург" + /> +
+
+ + onPriceMin(e.target.value)} + placeholder="—" + /> +
+
+ + onPriceMax(e.target.value)} + placeholder="—" + /> +
+
+ + onYearMin(e.target.value)} + placeholder="—" + /> +
+
+ + onYearMax(e.target.value)} + placeholder="—" + /> +
+
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/SaleShareList.tsx b/tradein-mvp/frontend/src/components/trade-in/SaleShareList.tsx new file mode 100644 index 00000000..a1a1f98d --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/SaleShareList.tsx @@ -0,0 +1,241 @@ +"use client"; + +/** + * SaleShareList — сортируемый список домов: адрес, доля в продаже (акцент), + * N из M квартир, медиана цены + ₽/м², срок экспозиции, год/тип/серия, бейджи. + * Hover строки ↔ подсветка маркера на карте; клик → drawer с объявлениями. + * Сортировка серверная — клик по заголовку меняет `sort` (драйвит query). + */ +import type { BuildingSaleShare, SaleShareSort } from "@/types/sale-share"; +import { + fmtDays, + fmtPct, + fmtRub, + heatColor, + houseTypeLabel, +} from "./saleShareUtils"; + +interface Props { + buildings: BuildingSaleShare[]; + sort: SaleShareSort; + onSort: (s: SaleShareSort) => void; + selectedHouseId: number | null; + hoveredHouseId: number | null; + onSelect: (houseId: number) => void; + onHover: (houseId: number | null) => void; + isLoading: boolean; + isError: boolean; + minPct: number; +} + +/** Стрелка-индикатор активной сортировки. */ +function sortArrow(active: boolean, dir: "asc" | "desc"): string { + if (!active) return ""; + return dir === "asc" ? " ↑" : " ↓"; +} + +export function SaleShareList({ + buildings, + sort, + onSort, + selectedHouseId, + hoveredHouseId, + onSelect, + onHover, + isLoading, + isError, + minPct, +}: Props) { + const togglePrice = () => + onSort(sort === "price_asc" ? "price_desc" : "price_asc"); + + return ( +
+
+
+
Дома
+

Адреса с долей в продаже ≥ {minPct}%

+
+ {!isLoading && !isError && ( +
+
+ Найдено: {buildings.length} +
+
+ )} +
+ + {isLoading ? ( +
+

+ Загрузка домов… +

+
+ ) : isError ? ( +
+

+ Не удалось загрузить список домов. Обновите страницу или попробуйте позже. +

+
+ ) : buildings.length === 0 ? ( +
+

+ Нет домов с долей в продаже ≥ {minPct}% при текущих фильтрах. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {buildings.map((b) => ( + + ))} + +
Адрес + + + + + + + + Дом + Действия +
+
+ )} +
+ ); +} + +function BuildingRow({ + b, + selected, + hovered, + onSelect, + onHover, +}: { + b: BuildingSaleShare; + selected: boolean; + hovered: boolean; + onSelect: (houseId: number) => void; + onHover: (houseId: number | null) => void; +}) { + 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}` + : "—"; + + const houseMeta = [ + b.year_built !== null ? `${b.year_built}` : null, + houseTypeLabel(b.house_type) !== "—" ? houseTypeLabel(b.house_type) : null, + b.total_floors !== null ? `${b.total_floors} эт.` : null, + b.series_name, + ] + .filter(Boolean) + .join(" · "); + + return ( + onHover(b.house_id)} + onMouseLeave={() => onHover(null)} + onClick={() => onSelect(b.house_id)} + > + +
+ {b.address ?? "Адрес не указан"} + + {b.is_emergency && аварийный} + {b.over_100 && ( + возможно, несколько корпусов + )} + +
+ + + + {fmtPct(b.sale_share_pct)} + + + {denom} + + {b.median_price_rub !== null ? ( + <> + {fmtRub(b.median_price_rub)} ₽ +
+ {b.median_price_per_m2 !== null ? `${fmtRub(b.median_price_per_m2)} ₽/м²` : "—"} +
+ + ) : ( + "—" + )} + + {fmtDays(b.avg_days_on_market)} + {houseMeta || "—"} + + + + + ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx new file mode 100644 index 00000000..6a64dd44 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx @@ -0,0 +1,233 @@ +"use client"; + +/** + * SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в + * продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера + * тоже растёт с долей. Reuses the same CDN-loaded Leaflet + OSM approach as + * MapCard.tsx / MapPicker.tsx (no npm dep). Дома без координат пропускаем. + * + * Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен + * только в браузере. + */ +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { BuildingSaleShare } from "@/types/sale-share"; +import { fmtPct, heatColor, markerRadius } from "./saleShareUtils"; + +const LEAFLET_VER = "1.9.4"; +const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; +const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; +const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; +const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; +const EKB_CENTER: [number, number] = [56.8389, 60.6057]; + +/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard) */ +function loadLeaflet(): Promise { + return new Promise((resolve, reject) => { + const w = window as any; + if (w.L) { + resolve(w.L); + return; + } + if (!document.querySelector(`link[data-leaflet]`)) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = LEAFLET_CSS; + link.integrity = LEAFLET_CSS_SRI; + link.crossOrigin = "anonymous"; + link.setAttribute("data-leaflet", "1"); + document.head.appendChild(link); + } + const existing = document.querySelector(`script[data-leaflet]`); + if (existing) { + existing.addEventListener("load", () => resolve(w.L)); + existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); + return; + } + const script = document.createElement("script"); + script.src = LEAFLET_JS; + script.integrity = LEAFLET_JS_SRI; + script.crossOrigin = "anonymous"; + script.setAttribute("data-leaflet", "1"); + script.onload = () => resolve(w.L); + script.onerror = () => reject(new Error("leaflet load failed")); + document.body.appendChild(script); + }); +} + +/** Безопасное экранирование для вставки в HTML popup. */ +function esc(s: string): string { + return s.replace(/[&<>"']/g, (c) => + c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : "'", + ); +} + +function popupHtml(b: BuildingSaleShare): string { + const badges: string[] = []; + if (b.is_emergency) { + badges.push( + `аварийный`, + ); + } + if (b.over_100) { + badges.push( + `возможно, несколько корпусов`, + ); + } + 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} объявлений` + : ""; + return `
+ ${esc(b.address ?? "Адрес не указан")}
+ ${esc(fmtPct(b.sale_share_pct))} в продаже${denom ? ` · ${esc(denom)}` : ""} + ${badges.length ? `
${badges.join(" ")}` : ""} +
`; +} + +interface Props { + buildings: BuildingSaleShare[]; + selectedHouseId: number | null; + hoveredHouseId: number | null; + onSelect: (houseId: number) => void; + onHover: (houseId: number | null) => void; +} + +export function SaleShareMap({ + buildings, + selectedHouseId, + hoveredHouseId, + onSelect, + onHover, +}: Props) { + const mapRef = useRef(null); + const mapObj = useRef(null); + const LRef = useRef(null); + const layerRef = useRef(null); + const markers = useRef>(new Map()); + const [ready, setReady] = useState(false); + const [mapError, setMapError] = useState(false); + + const geoBuildings = useMemo( + () => + buildings.filter( + (b) => typeof b.lat === "number" && typeof b.lon === "number", + ), + [buildings], + ); + + // Init map once. + useEffect(() => { + let cancelled = false; + loadLeaflet() + .then((L) => { + if (cancelled || !mapRef.current) return; + LRef.current = L; + const map = L.map(mapRef.current).setView(EKB_CENTER, 11); + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap", + maxZoom: 19, + }).addTo(map); + layerRef.current = L.layerGroup().addTo(map); + mapObj.current = map; + setTimeout(() => map && map.invalidateSize(), 120); + setReady(true); + }) + .catch(() => setMapError(true)); + return () => { + cancelled = true; + if (mapObj.current) { + mapObj.current.remove(); + mapObj.current = null; + } + }; + }, []); + + // (Re)draw markers when the building set changes. + useEffect(() => { + if (!ready) return; + const L = LRef.current; + const layer = layerRef.current; + const map = mapObj.current; + if (!L || !layer || !map) return; + layer.clearLayers(); + markers.current.clear(); + const bounds: [number, number][] = []; + for (const b of geoBuildings) { + const ll: [number, number] = [b.lat as number, b.lon as number]; + bounds.push(ll); + const marker = L.circleMarker(ll, { + radius: markerRadius(b.sale_share_pct), + color: "#ffffff", + weight: 1.5, + fillColor: heatColor(b.sale_share_pct), + fillOpacity: 0.85, + }); + marker.bindPopup(popupHtml(b)); + marker.on("click", () => onSelect(b.house_id)); + marker.on("mouseover", () => onHover(b.house_id)); + marker.on("mouseout", () => onHover(null)); + marker.addTo(layer); + markers.current.set(b.house_id, marker); + } + if (bounds.length > 1) { + map.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 }); + } else if (bounds.length === 1) { + map.setView(bounds[0], 14); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- onSelect/onHover стабильны (useCallback в page) + }, [ready, geoBuildings]); + + // Focus the selected building: pan + open popup. + useEffect(() => { + if (!ready || selectedHouseId === null) return; + const marker = markers.current.get(selectedHouseId); + const map = mapObj.current; + if (marker && map) { + map.panTo(marker.getLatLng(), { animate: true, duration: 0.3 }); + marker.openPopup(); + } + }, [ready, selectedHouseId]); + + // Highlight the hovered building's marker (sync with list row hover). + useEffect(() => { + if (!ready) return; + markers.current.forEach((marker, id) => { + const b = geoBuildings.find((x) => x.house_id === id); + if (!b) return; + const isHover = id === hoveredHouseId; + marker.setStyle({ weight: isHover ? 3 : 1.5, fillOpacity: isHover ? 1 : 0.85 }); + marker.setRadius( + isHover ? markerRadius(b.sale_share_pct) + 3 : markerRadius(b.sale_share_pct), + ); + }); + }, [ready, hoveredHouseId, geoBuildings]); + + if (mapError) { + return ( +
+
+ Не удалось загрузить карту. Проверьте интернет-соединение. +
+
+ ); + } + + return ( +
+
+ {ready && geoBuildings.length === 0 && ( +
+ Нет домов с координатами для отображения на карте. +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 5297db70..3c716ef7 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -24,6 +24,7 @@ function MeraMark() { export type ActiveTab = | "estimate" + | "sale-share" | "history" | "cache" | "scrapers" @@ -58,6 +59,13 @@ const NAV_ITEMS: Array<{ label: string; }> = [ { key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" }, + // Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**). + { + key: "sale-share", + href: "/sale-share", + scopePath: "/trade-in/sale-share", + label: "Доля в продаже", + }, { key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" }, { key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" }, // Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел. diff --git a/tradein-mvp/frontend/src/components/trade-in/saleShareUtils.ts b/tradein-mvp/frontend/src/components/trade-in/saleShareUtils.ts new file mode 100644 index 00000000..73c9c7ef --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/saleShareUtils.ts @@ -0,0 +1,68 @@ +// Утилиты страницы sale-share: цвет/размер тепловых маркеров + форматтеры. + +/** + * Цвет «тепла» доли в продаже: низкая (норма) → зелёный, высокая (сигнал + * расселения / инвест-выхода / проблемного дома) → красный. null → серый. + * Пороги согласованы с корзинами гистограммы (HISTOGRAM_BUCKETS на бэкенде). + */ +export function heatColor(pct: number | null): string { + if (pct === null) return "#9ca3af"; // gray-400 — знаменатель неизвестен + if (pct < 5) return "#16a34a"; // green-600 + if (pct < 10) return "#65a30d"; // lime-600 + if (pct < 20) return "#ca8a04"; // yellow-600 + if (pct < 30) return "#ea580c"; // orange-600 + if (pct < 50) return "#dc2626"; // red-600 + if (pct <= 100) return "#b91c1c"; // red-700 + return "#7f1d1d"; // red-900 — over_100 +} + +/** Радиус маркера на карте: 5..14 px, растёт с долей. null → минимум. */ +export function markerRadius(pct: number | null): number { + if (pct === null) return 5; + const clamped = Math.min(Math.max(pct, 0), 100); + return 5 + (clamped / 100) * 9; +} + +export function fmtRub(v: number | null | undefined): string { + return v === null || v === undefined ? "—" : v.toLocaleString("ru-RU"); +} + +export function fmtPct(v: number | null | undefined): string { + if (v === null || v === undefined) return "—"; + return v >= 100 ? `${Math.round(v)}%` : `${v.toFixed(1)}%`; +} + +export function fmtDays(v: number | null | undefined): string { + return v === null || v === undefined ? "—" : `${Math.round(v)} дн`; +} + +const HOUSE_TYPE_LABELS: Record = { + panel: "Панель", + brick: "Кирпич", + monolith: "Монолит", + monolith_brick: "Монолит-кирпич", + block: "Блочный", + wood: "Дерево", + stalin: "Сталинка", + other: "Другое", +}; + +/** Гуманизация типа дома; неизвестное значение отдаём как есть. */ +export function houseTypeLabel(t: string | null | undefined): string { + if (!t) return "—"; + return HOUSE_TYPE_LABELS[t] ?? t; +} + +const SOURCE_LABELS: Record = { + cian: "Циан", + avito: "Avito", + yandex: "Я.Недв", + domklik: "ДомКлик", + rosreestr: "Росреестр", + n1: "N1.ru", +}; + +export function sourceLabel(s: string | null | undefined): string { + if (!s) return "—"; + return SOURCE_LABELS[s] ?? s; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index ccc124a3..2f295676 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2338,3 +2338,261 @@ html, body { overflow-x: clip; } .top-nav { flex-wrap: wrap; min-width: 0; margin-left: auto; } .topbar-inner { max-width: 100%; } } + +/* ============================================================ + SALE SHARE — «доля квартир дома в продаже» (#2055 UI) + ============================================================ */ +.ss-banner { + max-width: var(--container); + margin: 16px auto 0; + padding: 12px 16px; + border-radius: var(--radius); + background: var(--accent-soft); + border: 1px solid var(--border); + color: var(--fg-2); + font-size: 13px; + line-height: 1.5; +} +.ss-banner.is-warn { + background: var(--accent-2-soft); + border-color: var(--accent-2); +} +.ss-banner b { color: var(--fg); } + +/* Histogram strip */ +.ss-hist { + display: flex; + align-items: flex-end; + gap: 6px; + height: 96px; + margin-bottom: 18px; +} +.ss-hist-col { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; + height: 100%; +} +.ss-hist-count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); +} +.ss-hist-track { + flex: 1; + width: 100%; + display: flex; + align-items: flex-end; + justify-content: center; +} +.ss-hist-bar { + width: 100%; + max-width: 38px; + border-radius: 4px 4px 0 0; + transition: opacity 0.15s ease; +} +.ss-hist-label { + font-size: 10px; + color: var(--muted-2); + white-space: nowrap; +} + +/* Slider */ +.ss-slider-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 14px; +} +.ss-slider-label { + font-size: 13px; + color: var(--fg-2); + white-space: nowrap; +} +.ss-slider { width: 100%; accent-color: var(--accent); } +.ss-slider-out { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 600; + color: var(--accent-ink); + min-width: 48px; + text-align: right; +} + +/* Filters grid */ +.ss-filters { + margin-top: 18px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} +.ss-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.ss-field label { font-size: 12px; color: var(--muted); } +.ss-field input, +.ss-field select { + padding: 7px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--fg); + font: inherit; + font-size: 13px; + width: 100%; +} +.ss-field input:focus, +.ss-field select:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +/* Map */ +.ss-map-wrap { position: relative; width: 100%; } +.ss-map { height: 460px; width: 100%; background: var(--surface-2); } +.ss-map-empty { + display: grid; + place-items: center; + height: 460px; + padding: 24px; + text-align: center; + color: var(--muted); + font-size: 14px; +} +.ss-legend { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--muted); +} +.ss-legend-bar { + width: 90px; + height: 8px; + border-radius: 4px; + background: linear-gradient( + 90deg, + #16a34a 0%, + #ca8a04 40%, + #ea580c 65%, + #dc2626 85%, + #7f1d1d 100% + ); +} + +/* List table */ +.ss-table-scroll { width: 100%; overflow-x: auto; } +.ss-table { width: 100%; } +.ss-th-btn { + background: none; + border: none; + padding: 0; + font: inherit; + font-weight: 600; + color: var(--fg-2); + cursor: pointer; + white-space: nowrap; +} +.ss-th-btn:hover { color: var(--accent); } +.ss-row { cursor: pointer; transition: background 0.12s ease; } +.ss-row:hover, +.ss-row.is-hovered { background: var(--surface-2); } +.ss-row.is-selected { background: var(--accent-soft); } +.ss-pct { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.ss-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + line-height: 1.5; +} +.ss-badge--danger { background: var(--danger-soft); color: var(--danger); } +.ss-badge--warn { background: var(--accent-2-soft); color: var(--accent-2); } +.ss-row-btn { + padding: 5px 10px; + font-size: 12px; + white-space: nowrap; +} + +/* Drawer */ +.ss-drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 1000; + display: flex; + justify-content: flex-end; +} +.ss-drawer { + background: var(--surface); + width: min(460px, 100%); + height: 100%; + display: flex; + flex-direction: column; + box-shadow: -8px 0 24px -8px rgba(16, 24, 40, 0.25); +} +.ss-drawer-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.ss-drawer-close { + border: none; + background: none; + font-size: 24px; + line-height: 1; + cursor: pointer; + color: var(--muted); + padding: 0 4px; +} +.ss-drawer-close:hover { color: var(--fg); } +.ss-drawer-body { flex: 1; overflow-y: auto; padding: 12px 18px 24px; } +.ss-listing-list { list-style: none; margin: 0; padding: 0; } +.ss-listing { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.ss-listing-main { min-width: 0; } +.ss-listing-price { + font-weight: 600; + font-size: 15px; + color: var(--fg); + font-variant-numeric: tabular-nums; +} +.ss-listing-meta { font-size: 13px; color: var(--fg-2); margin-top: 2px; } +.ss-listing-sub { font-size: 12px; color: var(--muted); margin-top: 2px; } +.ss-listing-link { + flex-shrink: 0; + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + color: var(--accent); +} +.ss-listing-link:hover { background: var(--surface-2); text-decoration: none; } + +@media (max-width: 720px) { + .ss-slider-row { grid-template-columns: 1fr auto; } + .ss-slider-label { grid-column: 1 / -1; } + .ss-hist { height: 76px; } + .ss-map, .ss-map-empty { height: 360px; } +} diff --git a/tradein-mvp/frontend/src/lib/sale-share-api.ts b/tradein-mvp/frontend/src/lib/sale-share-api.ts new file mode 100644 index 00000000..7b2832c9 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/sale-share-api.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "./api"; +import type { + BuildingListing, + BuildingSaleShare, + SaleShareQuery, + SaleShareSummary, +} from "@/types/sale-share"; + +const BASE = "/api/v1/buildings"; + +/** + * GET /api/v1/buildings/sale-share/summary + * Сводка распределения sale_share_pct: coverage, max/p95, гистограмма. + * Нужна для слайдера % и баннера покрытия — кэшируем подольше. + */ +export function useSaleShareSummary() { + return useQuery({ + queryKey: ["sale-share", "summary"], + queryFn: () => apiFetch(`${BASE}/sale-share/summary`), + staleTime: 10 * 60_000, + }); +} + +function buildParams(q: SaleShareQuery): string { + const p = new URLSearchParams(); + p.set("min_pct", String(q.min_pct)); + if (q.max_pct != null) p.set("max_pct", String(q.max_pct)); + if (q.city) p.set("city", q.city); + if (q.price_min != null) p.set("price_min", String(q.price_min)); + if (q.price_max != null) p.set("price_max", String(q.price_max)); + if (q.year_min != null) p.set("year_min", String(q.year_min)); + if (q.year_max != null) p.set("year_max", String(q.year_max)); + if (q.house_type) p.set("house_type", q.house_type); + p.set("sort", q.sort); + if (q.limit != null) p.set("limit", String(q.limit)); + return p.toString(); +} + +/** + * GET /api/v1/buildings/sale-share + * Список домов вторички с долей квартир в продаже >= min_pct (+ фильтры). + */ +export function useSaleShareBuildings(query: SaleShareQuery) { + return useQuery({ + queryKey: [ + "sale-share", + "buildings", + query.min_pct, + query.max_pct ?? null, + query.city ?? null, + query.price_min ?? null, + query.price_max ?? null, + query.year_min ?? null, + query.year_max ?? null, + query.house_type ?? null, + query.sort, + query.limit ?? 200, + ], + queryFn: () => + apiFetch(`${BASE}/sale-share?${buildParams(query)}`), + staleTime: 5 * 60_000, + }); +} + +/** + * GET /api/v1/buildings/{house_id}/listings + * Активные вторичные объявления одного дома (для drawer). Лениво — + * enabled только когда выбран дом. + */ +export function useBuildingListings(houseId: number | null) { + return useQuery({ + queryKey: ["sale-share", "listings", houseId], + queryFn: () => apiFetch(`${BASE}/${houseId}/listings`), + enabled: houseId !== null, + staleTime: 5 * 60_000, + }); +} diff --git a/tradein-mvp/frontend/src/lib/useDebouncedValue.ts b/tradein-mvp/frontend/src/lib/useDebouncedValue.ts new file mode 100644 index 00000000..3f7f76ff --- /dev/null +++ b/tradein-mvp/frontend/src/lib/useDebouncedValue.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** + * Возвращает значение, обновляемое с задержкой `delayMs` после последнего + * изменения `value`. Используется чтобы не дёргать API на каждый keystroke / + * каждый тик слайдера. Таймер — не HTTP, поэтому useEffect здесь уместен. + */ +export function useDebouncedValue(value: T, delayMs = 350): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} diff --git a/tradein-mvp/frontend/src/types/sale-share.ts b/tradein-mvp/frontend/src/types/sale-share.ts new file mode 100644 index 00000000..a80c9603 --- /dev/null +++ b/tradein-mvp/frontend/src/types/sale-share.ts @@ -0,0 +1,82 @@ +// Sale-share — типы страницы «доля квартир дома в продаже». +// Hand-written, зеркалит Pydantic-схемы tradein-mvp/backend/app/schemas/buildings.py. +// (В tradein нет OpenAPI-codegen — типы поддерживаем вручную, как trade-in.ts.) + +// Допустимые значения сортировки (бэкенд валидирует тем же паттерном). +export type SaleShareSort = + | "share_desc" + | "active_desc" + | "exposure_desc" + | "price_asc" + | "price_desc"; + +/** Один дом вторичного рынка из v_building_sale_share. */ +export interface BuildingSaleShare { + house_id: number; + address: string | null; + lat: number | null; + lon: number | null; + // Доля квартир дома, выставленных на вторичную продажу. null, если по дому + // ещё не загружен реестр квартир (ГАР) → знаменатель неизвестен. + sale_share_pct: number | null; + // active_secondary > знаменателя — implausible-матч (коллизия адреса при + // ГАР-матче). Не прячем, а маркируем бейджем «возможно, несколько корпусов». + over_100: boolean; + active_secondary: number | null; + flat_count_effective: number | null; + gar_match_method: string | null; + median_price_rub: number | null; + median_price_per_m2: number | null; + avg_days_on_market: number | null; + year_built: number | null; + house_type: string | null; + total_floors: number | null; + series_name: string | null; + is_emergency: boolean | null; +} + +/** Активное вторичное объявление в доме (click-through drawer). */ +export interface BuildingListing { + listing_id: number; + source: string; + source_url: string | null; + price_rub: number | null; + price_per_m2: number | null; + rooms: number | null; + area_m2: number | null; + floor: number | null; + total_floors: number | null; + days_on_market: number | null; + listing_date: string | null; // ISO date + address: string | null; +} + +/** Корзина гистограммы распределения sale_share_pct. */ +export interface HistogramBucket { + bucket: string; + count: number; +} + +/** Сводка для слайдера % + баннера покрытия. */ +export interface SaleShareSummary { + total_secondary_buildings: number; + buildings_with_denominator: number; + coverage_pct: number; + max_pct: number | null; + p95_pct: number | null; + histogram: HistogramBucket[]; +} + +/** Параметры запроса списка домов (драйвят query-key). */ +export interface SaleShareQuery { + min_pct: number; + max_pct?: number | null; + city?: string | null; + price_min?: number | null; + price_max?: number | null; + year_min?: number | null; + year_max?: number | null; + house_type?: string | null; + sort: SaleShareSort; + limit?: number; +}