При пустой оценке (median_price_rub<=0) CV и «ниже рынка» делились на 0 → клиент видел «Infinity%»/«NaN%». Обёрнуто в guard m>0 ? ... : «—». Доп: знак сравнения сделок к рынку — «ниже»/«выше» по знаку diff. Refs #741
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* DealsCard — Секция 3 «Сделки» из mockup. Реальные сделки из Росреестра / ДомКлик.
|
||
*/
|
||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||
|
||
interface Props {
|
||
estimate: AggregatedEstimate;
|
||
}
|
||
|
||
const SOURCE_DOTS: Record<string, string> = {
|
||
rosreestr: "ros",
|
||
domklik: "dom",
|
||
etagi: "etagi",
|
||
};
|
||
|
||
const SOURCE_LABELS: Record<string, string> = {
|
||
rosreestr: "Росреестр",
|
||
domklik: "ДомКлик",
|
||
etagi: "Этажи",
|
||
};
|
||
|
||
function fmtRub(v: number): string {
|
||
return v.toLocaleString("ru-RU");
|
||
}
|
||
|
||
function fmtDate(iso: string | null): string {
|
||
if (!iso) return "—";
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||
} catch {
|
||
return "—";
|
||
}
|
||
}
|
||
|
||
export function DealsCard({ estimate }: Props) {
|
||
const deals = estimate.actual_deals;
|
||
if (deals.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Сортируем по дате DESC, берем медиану и range
|
||
const prices = deals.map((d) => d.price_rub).sort((a, b) => a - b);
|
||
const m = prices[Math.floor(prices.length / 2)];
|
||
const lo = prices[0];
|
||
const hi = prices[prices.length - 1];
|
||
|
||
const sourceCounts: Record<string, number> = {};
|
||
for (const d of deals) {
|
||
if (d.source) sourceCounts[d.source] = (sourceCounts[d.source] ?? 0) + 1;
|
||
}
|
||
|
||
return (
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция 3 · Сделки</div>
|
||
<h2>Фактические сделки по аналогичным квартирам</h2>
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
Период: <b className="mono">за {estimate.period_months} мес.</b>
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>
|
||
ground truth · подтверждено Росреестром
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="count-strip">
|
||
<div className="count-cell">
|
||
<div className="label">Сделок по аналогам</div>
|
||
<div className="value">
|
||
<span data-tnum>{deals.length}</span>
|
||
<span className="unit">шт</span>
|
||
</div>
|
||
<div className="sub">подтверждено Росреестром</div>
|
||
</div>
|
||
<div className="count-cell">
|
||
<div className="label">Медиана сделок</div>
|
||
<div className="value">
|
||
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
|
||
<span className="unit">млн ₽</span>
|
||
</div>
|
||
<div className="sub">
|
||
{estimate.median_price_rub > 0
|
||
? `на ${Math.abs(((estimate.median_price_rub - m) / estimate.median_price_rub) * 100).toFixed(0)}% ${
|
||
estimate.median_price_rub - m >= 0 ? "ниже" : "выше"
|
||
} рынка`
|
||
: "—"}
|
||
</div>
|
||
</div>
|
||
<div className="count-cell">
|
||
<div className="label">Диапазон</div>
|
||
<div className="value">
|
||
<span data-tnum>
|
||
{(lo / 1_000_000).toFixed(1)} – {(hi / 1_000_000).toFixed(1)}
|
||
</span>
|
||
<span className="unit">млн</span>
|
||
</div>
|
||
<div className="sub">7 месяцев · разница к рынку</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filters-row">
|
||
<span className="lbl">Источники</span>
|
||
<div className="sources">
|
||
{Object.entries(sourceCounts).map(([src, c]) => (
|
||
<span key={src} className="source-chip">
|
||
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
|
||
{SOURCE_LABELS[src] ?? src} ·{" "}
|
||
<span className="mono" style={{ color: "var(--muted)" }}>
|
||
{c} сделок
|
||
</span>
|
||
</span>
|
||
))}
|
||
</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 />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{deals.map((d, i) => (
|
||
<DealRow key={i} deal={d} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="table-foot">
|
||
<span>
|
||
Показано <b style={{ color: "var(--fg)" }}>{deals.length}</b> фактических сделок
|
||
</span>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function DealRow({ deal }: { deal: AnalogLot }) {
|
||
const src = deal.source ?? "";
|
||
const dot = SOURCE_DOTS[src] ?? "dom";
|
||
const label = SOURCE_LABELS[src] ?? src;
|
||
|
||
// Tier badge для rosreestr deals (PR M / #564 Phase 3).
|
||
// T0_per_house — точный кадастровый match (high confidence).
|
||
// T1_per_street — улица-уровень (open dataset default).
|
||
const tierBadge =
|
||
deal.tier === "T0_per_house" ? { text: "по дому", className: "tier-badge tier-t0" }
|
||
: deal.tier === "T1_per_street" ? { text: "по улице", className: "tier-badge tier-t1" }
|
||
: null;
|
||
|
||
return (
|
||
<tr>
|
||
<td>
|
||
<div className="addr">
|
||
<span className="a-main">{deal.address}</span>
|
||
<span className="a-sub">
|
||
{deal.area_m2.toFixed(1)} м² · {deal.rooms === 0 ? "студия" : `${deal.rooms}-к`}
|
||
{deal.floor !== null && deal.total_floors !== null
|
||
? ` · этаж ${deal.floor}/${deal.total_floors}`
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className="src-mini">
|
||
<span className={`src-dot ${dot}`} /> {label}
|
||
</span>
|
||
{tierBadge ? (
|
||
<span className={tierBadge.className} title="Точность сопоставления сделки">
|
||
{tierBadge.text}
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
<td className="num">{fmtRub(deal.price_per_m2)}</td>
|
||
<td className="num">{fmtRub(deal.price_rub)}</td>
|
||
<td className="num">{fmtDate(deal.listing_date)}</td>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className="rosreestr-btn"
|
||
onClick={() => void openRosreestrWithAddress(deal.address)}
|
||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||
>
|
||
Росреестр ↗
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|