gendesign/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
bot-frontend 19c69be493
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
fix(tradein): recharts a11y — aria-hidden чарты, h1, aria-label th (#835) (#844)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 20:49:19 +00:00

201 lines
6.7 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">{estimate.period_months} мес · разница к рынку</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 aria-label="Действия" />
</tr>
</thead>
<tbody>
{deals.map((d, i) => (
<DealRow key={`${d.source ?? ""}|${d.address}|${d.price_rub}|${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>
);
}