All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m10s
- новый src/lib/source-registry.ts — один источник правды (SOURCES as const, канон сверен с estimator._canonical_sources); n1 live:false, убран из ростера - HeroSummary: убран сфабрикованный «/7»; пустой sources_used → «источники не зафиксированы» вместо фейк-чипов; убрана выдуманная «классическая»; CV → честная метрика (реальный estimate.cv или лейбл «разброс») - ListingsCard: знаменатель убран совсем (sources_used включает оценочные источники — фиксированный знаменатель либо врёт, либо меньше числителя); fix невидимой rosreestr-точки (dot "ros"→"rosreestr", класса .ros нет в CSS) - SourcesProgress: ростер из registry, мёртвый N1 убран - EstimateForm: убран всегда-зелёный фейковый «● готов» cache-индикатор - page.tsx: восстановленный отчёт показывает created_at, не new Date() - types: +cv/source_counts/created_at (уже были в backend-схеме) - инвариант: type-level (as const satisfies) + контракт-комментарий против хардкод-массивов источников Refs #2211
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ListingsCard — Секция 2 «Рынок» из mockup tradein.html.
|
||
* Counts strip + filters + price bar + table.
|
||
*/
|
||
import { useMemo } from "react";
|
||
import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/types/trade-in";
|
||
import { safeUrl } from "@/lib/safeUrl";
|
||
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
|
||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||
|
||
interface Props {
|
||
estimate: AggregatedEstimate;
|
||
estimateId?: string;
|
||
}
|
||
|
||
function fmtRub(v: number): string {
|
||
return v.toLocaleString("ru-RU");
|
||
}
|
||
|
||
function fmtArea(v: number): string {
|
||
return v.toFixed(1);
|
||
}
|
||
|
||
/** Гуманизация свежести данных (#770 finding #31): мин → ч → дн. */
|
||
function humanizeAgo(min: number | null): string {
|
||
if (min === null || min < 0) return "—";
|
||
if (min < 60) return `${min} мин назад`;
|
||
if (min < 1440) return `${Math.round(min / 60)} ч назад`;
|
||
return `${Math.round(min / 1440)} дн назад`;
|
||
}
|
||
|
||
/** Extract Cian numeric ID from a source_url like https://ekb.cian.ru/sale/flat/123456789/ */
|
||
function extractCianId(url: string | null): string | null {
|
||
if (!url) return null;
|
||
const m = url.match(/\/sale\/flat\/(\d+)/);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
export function ListingsCard({ estimate, estimateId }: Props) {
|
||
const priceChangesQuery = useEstimateCianPriceChanges(estimateId ?? null);
|
||
|
||
const priceChangeMap = useMemo<Map<string, CianPriceChangeStats>>(() => {
|
||
const map = new Map<string, CianPriceChangeStats>();
|
||
for (const stats of priceChangesQuery.data ?? []) {
|
||
map.set(stats.cian_id, stats);
|
||
}
|
||
return map;
|
||
}, [priceChangesQuery.data]);
|
||
|
||
const lots = estimate.analogs;
|
||
if (lots.length === 0) {
|
||
return (
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция 2 · Рынок</div>
|
||
<h2>Объявления — аналогичные квартиры в продаже</h2>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
|
||
Нет объявлений-аналогов в радиусе 2 км.
|
||
</p>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
// Считаем источники
|
||
const sourceCounts: Record<string, number> = {};
|
||
for (const l of lots) {
|
||
if (l.source) sourceCounts[l.source] = (sourceCounts[l.source] ?? 0) + 1;
|
||
}
|
||
|
||
// Price bar bounds
|
||
const prices = lots.map((l) => l.price_rub).sort((a, b) => a - b);
|
||
const lo = prices[0];
|
||
const hi = prices[prices.length - 1];
|
||
const m = estimate.median_price_rub;
|
||
const span = hi - lo;
|
||
const medPct = span > 0 ? Math.max(5, Math.min(95, ((m - lo) / span) * 100)) : 50;
|
||
const p25 = prices[Math.floor(prices.length * 0.25)];
|
||
const p75 = prices[Math.floor(prices.length * 0.75)];
|
||
const p25Pct = span > 0 ? Math.max(5, Math.min(95, ((p25 - lo) / span) * 100)) : 25;
|
||
const p75Pct = span > 0 ? Math.max(5, Math.min(95, ((p75 - lo) / span) * 100)) : 75;
|
||
|
||
// Площадь / годы / комнаты — для filter chips
|
||
const areas = lots.map((l) => l.area_m2);
|
||
const aMin = Math.min(...areas);
|
||
const aMax = Math.max(...areas);
|
||
const rooms = lots[0]?.rooms;
|
||
|
||
return (
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция 2 · Рынок</div>
|
||
<h2>Объявления — аналогичные квартиры в продаже</h2>
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
Обновлено:{" "}
|
||
<b className="mono">{humanizeAgo(estimate.data_freshness_minutes)}</b>
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>
|
||
{/* #2211: без знаменателя — sources_used включает и оценочные
|
||
источники, любой фиксированный знаменатель либо врёт, либо
|
||
оказывается меньше числителя (консистентно с HeroSummary). */}
|
||
Источников: <b>{estimate.sources_used.length}</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="count-strip">
|
||
<div className="count-cell">
|
||
<div className="label">Объявлений по аналогам</div>
|
||
<div className="value">
|
||
<span data-tnum>{estimate.n_analogs}</span>
|
||
<span className="unit">шт</span>
|
||
</div>
|
||
<div className="sub">из {estimate.sources_used.length} источников</div>
|
||
</div>
|
||
<div className="count-cell">
|
||
<div className="label">Медиана</div>
|
||
<div className="value">
|
||
{m > 0 ? (
|
||
<>
|
||
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
|
||
<span className="unit">млн ₽</span>
|
||
</>
|
||
) : (
|
||
<span data-tnum>—</span>
|
||
)}
|
||
</div>
|
||
<div className="sub">
|
||
{m > 0 ? `${estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²` : "нет данных"}
|
||
</div>
|
||
</div>
|
||
<div className="count-cell">
|
||
<div className="label">Диапазон P25–P75</div>
|
||
<div className="value">
|
||
<span data-tnum>
|
||
{(estimate.range_low_rub / 1_000_000).toFixed(1)}–
|
||
{(estimate.range_high_rub / 1_000_000).toFixed(1)}
|
||
</span>
|
||
<span className="unit">млн</span>
|
||
</div>
|
||
{/* #2211 находка 4: настоящий CV (σ/среднее ₽/м², #2043) когда бэкенд его
|
||
отдал; иначе честный «разброс» = ширина диапазона (НЕ коэффициент
|
||
вариации, поэтому без аббревиатуры CV). */}
|
||
<div className="sub">
|
||
{typeof estimate.cv === "number" && estimate.cv > 0 ? (
|
||
<abbr title="Коэффициент вариации цен аналогов (σ/среднее ₽/м²)">CV</abbr>
|
||
) : (
|
||
<abbr title="Ширина ценового диапазона относительно медианы (не коэффициент вариации)">
|
||
разброс
|
||
</abbr>
|
||
)}{" "}
|
||
{typeof estimate.cv === "number" && estimate.cv > 0
|
||
? `${(estimate.cv * 100).toFixed(1)}%`
|
||
: m > 0
|
||
? `${(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%`
|
||
: "—"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filters-row">
|
||
<span className="lbl">Фильтр</span>
|
||
<div className="filters">
|
||
<span className="chip">
|
||
<span className="k">Расстояние</span>
|
||
<span className="v">≤ 2 км</span>
|
||
</span>
|
||
{rooms !== undefined && (
|
||
<span className="chip">
|
||
<span className="k">Планировка</span>
|
||
<span className="v">{rooms === 0 ? "Студия" : `${rooms}-к`}</span>
|
||
</span>
|
||
)}
|
||
<span className="chip">
|
||
<span className="k">Площадь</span>
|
||
<span className="v">
|
||
{fmtArea(aMin)} – {fmtArea(aMax)} м²
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filters-row">
|
||
<span className="lbl">Источники</span>
|
||
<div className="sources">
|
||
{Object.entries(sourceCounts).map(([src, count]) => (
|
||
<span key={src} className="source-chip">
|
||
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)} ·{" "}
|
||
<span className="mono" style={{ color: "var(--muted)" }}>
|
||
{count}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pricebar-block">
|
||
<div className="bar-head">
|
||
<span
|
||
className="bar-title"
|
||
style={{ color: "var(--fg-2)", fontSize: 12, letterSpacing: "0.04em" }}
|
||
>
|
||
ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ
|
||
{m > 0 ? ` · ${(m / 1_000_000).toFixed(2)} МЛН ₽ медиана` : ""}
|
||
</span>
|
||
</div>
|
||
<div className="pricebar">
|
||
<div className="axis">
|
||
<div className="range" style={{ left: "5%", right: "5%" }} />
|
||
<div className="median" style={{ left: `${medPct}%` }} />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "5%" }}>
|
||
<div className="endpoint-val">{(lo / 1_000_000).toFixed(1)} млн</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="endpoint" style={{ left: `${p25Pct}%` }} title="P25">
|
||
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
|
||
{(p25 / 1_000_000).toFixed(1)}
|
||
</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="endpoint" style={{ left: `${p75Pct}%` }} title="P75">
|
||
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
|
||
{(p75 / 1_000_000).toFixed(1)}
|
||
</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "95%" }}>
|
||
<div className="endpoint-val">{(hi / 1_000_000).toFixed(1)} млн</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="median-label" style={{ left: `${medPct}%`, top: -2 }}>
|
||
{(m / 1_000_000).toFixed(2)} млн ₽
|
||
</div>
|
||
<div className="exp left">P25 · {(p25 / 1_000_000).toFixed(1)} МЛН</div>
|
||
<div className="exp right">P75 · {(p75 / 1_000_000).toFixed(1)} МЛН</div>
|
||
</div>
|
||
</div>
|
||
|
||
<table className="dt" aria-label="Аналогичные объявления">
|
||
<thead>
|
||
<tr>
|
||
<th>Адрес</th>
|
||
<th>Источник</th>
|
||
<th className="num">₽ / м²</th>
|
||
<th className="num">Стоимость</th>
|
||
<th className="num">Дист.</th>
|
||
<th><span className="sr-only">Действия</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{lots.map((lot, i) => (
|
||
<AnalogRow
|
||
key={`${lot.source ?? ""}|${lot.address}|${lot.price_rub}|${i}`}
|
||
lot={lot}
|
||
priceChangeMap={priceChangeMap}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="table-foot">
|
||
<span>
|
||
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из {estimate.n_analogs}{" "}
|
||
объявлений · отсортировано по расстоянию
|
||
</span>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function AnalogRow({
|
||
lot,
|
||
priceChangeMap,
|
||
}: {
|
||
lot: AnalogLot;
|
||
priceChangeMap: Map<string, CianPriceChangeStats>;
|
||
}) {
|
||
const src = lot.source ?? "";
|
||
const dot = sourceDot(src);
|
||
const label = sourceLabel(src);
|
||
const distance =
|
||
lot.distance_m === null
|
||
? "—"
|
||
: lot.distance_m < 1000
|
||
? `${lot.distance_m} м`
|
||
: `${(lot.distance_m / 1000).toFixed(1)} км`;
|
||
const externalUrl = safeUrl(lot.source_url);
|
||
|
||
const cianId = src === "cian" ? extractCianId(lot.source_url) : null;
|
||
const priceStats = cianId ? priceChangeMap.get(cianId) : undefined;
|
||
|
||
return (
|
||
<tr>
|
||
<td>
|
||
<div className="addr">
|
||
<span className="a-main">{lot.address}</span>
|
||
<span className="a-sub">
|
||
{fmtArea(lot.area_m2)} м² · {lot.rooms === 0 ? "студия" : `${lot.rooms}-к`}
|
||
{lot.floor !== null && lot.total_floors !== null
|
||
? ` · этаж ${lot.floor}/${lot.total_floors}`
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className="src-mini">
|
||
<span className={`src-dot ${dot}`} /> {label}
|
||
</span>
|
||
</td>
|
||
<td className="num">
|
||
{fmtRub(lot.price_per_m2)}
|
||
</td>
|
||
<td className="num">
|
||
{fmtRub(lot.price_rub)}
|
||
{priceStats && (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
padding: "2px 6px",
|
||
borderRadius: 4,
|
||
background:
|
||
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
|
||
? "#dcfce7"
|
||
: "#fee2e2",
|
||
color:
|
||
priceStats.last_diff_percent != null && priceStats.last_diff_percent < 0
|
||
? "#166534"
|
||
: "#991b1b",
|
||
marginLeft: 6,
|
||
}}
|
||
>
|
||
{priceStats.n_changes > 1 ? `↓${priceStats.n_changes}` : "↓1"}
|
||
{priceStats.last_diff_percent != null
|
||
? ` ${priceStats.last_diff_percent > 0 ? "+" : ""}${priceStats.last_diff_percent.toFixed(1)}%`
|
||
: ""}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="num">{distance}</td>
|
||
<td style={{ whiteSpace: "nowrap" }}>
|
||
{externalUrl && (
|
||
<a
|
||
href={externalUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="row-link"
|
||
aria-label="Открыть оригинал"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||
<polyline points="15 3 21 3 21 9" />
|
||
<line x1="10" y1="14" x2="21" y2="3" />
|
||
</svg>
|
||
</a>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="rosreestr-btn"
|
||
onClick={() => void openRosreestrWithAddress(lot.address)}
|
||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||
>
|
||
Росреестр ↗
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|