gendesign/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
bot-frontend 7f46d6774e fix(tradein): guard деления на median=0 в ListingsCard/DealsCard
При пустой оценке (median_price_rub<=0) CV и «ниже рынка» делились на 0 →
клиент видел «Infinity%»/«NaN%». Обёрнуто в guard m>0 ? ... : «—».
Доп: знак сравнения сделок к рынку — «ниже»/«выше» по знаку diff.

Refs #741
2026-05-30 18:36:32 +03:00

201 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}