gendesign/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx
bot-frontend fe2f79792f fix(tradein): gate «0,00 млн ₽» в не-headline секциях при insufficient_data
QA side-finding #748 из #740: headline empty-state починен, но median=0
рендерил «0,00 млн ₽» ещё в 8 местах. Гейтим на insufficient_data/median>0:
- HeroSummary: hero-prices + hero-bars блоки скрыты при insufficientData
- ListingsCard: count-cell медиана + pricebar title → «—» при m<=0
- OfferCard: «Оценка по рынку» → «—»
- WhatIfPanel: «исходная оценка» → «—» при basePrice<=0
- SourcesProgress: «медиана:» → «—»
Happy-path (median>0) не тронут.

Refs #748
2026-05-30 18:55:47 +03:00

212 lines
7.8 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";
/**
* SourcesProgress — карточка «Шаг B · Агрегация» под mockup tradein.html.
*
* Показывает статус 7 строк-источников. Реально работают 4: Cian + Avito +
* Yandex + N1. ДомКлик и Restate пока не реализованы — показываем idle для
* соответствия макету.
*/
import type { AggregatedEstimate } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate | null;
isPending: boolean;
}
interface SourceRow {
key: string;
label: string;
dotClass: string;
status: "done" | "loading" | "error" | "idle";
count?: number;
median?: number;
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
export function SourcesProgress({ estimate, isPending }: Props) {
// Маппинг реальных данных из estimate на 7 строк-источников
const used = new Set(estimate?.sources_used ?? []);
const isDone = estimate !== null && !isPending;
// Считаем сколько лотов из какого источника
const countBySource: Record<string, number> = {};
if (estimate) {
for (const a of estimate.analogs) {
if (a.source) {
countBySource[a.source] = (countBySource[a.source] ?? 0) + 1;
}
}
}
const rows: SourceRow[] = [
{
key: "cian",
label: "Циан",
dotClass: "cian",
status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.cian,
median: used.has("cian") ? estimate?.median_price_rub : undefined,
},
{
key: "avito",
label: "Авито Оценка",
dotClass: "avito",
status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.avito,
median: used.has("avito") ? estimate?.median_price_rub : undefined,
},
{
key: "domklik",
label: "ДомКлик Прайс",
dotClass: "dom",
status: isPending ? "loading" : "idle",
},
{
key: "yandex",
label: "Я.Недвижимость",
dotClass: "yandex",
status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.yandex,
median: used.has("yandex") ? estimate?.median_price_rub : undefined,
},
{
key: "restate",
label: "Restate",
dotClass: "etagi",
status: isPending ? "loading" : "idle",
},
{
key: "n1",
label: "N1.ru",
dotClass: "n1",
status: used.has("n1") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.n1,
median: used.has("n1") ? estimate?.median_price_rub : undefined,
},
{
key: "rosreestr",
label: "Росреестр (внутр.)",
dotClass: "ros",
status: isDone && (estimate?.actual_deals.length ?? 0) > 0
? "done"
: (isPending ? "loading" : "idle"),
count: estimate?.actual_deals.length,
},
];
const doneCount = rows.filter((r) => r.status === "done").length;
const totalCount = rows.length;
const overallPct = isDone ? 100 : Math.min(95, (doneCount / totalCount) * 100 + (isPending ? 10 : 0));
return (
<article className="card progress-card">
<div className="card-head">
<div>
<div className="section-kicker">Шаг B · Агрегация</div>
<h2>Сбор данных по квартире</h2>
</div>
<div className="card-meta">
<div className="num-done">
<span data-tnum>{doneCount}</span>
<span className="total"> / {totalCount}</span> источников
</div>
{estimate && (
<div style={{ marginTop: 4 }}>
медиана:{" "}
<b className="mono" style={{ color: "var(--fg)" }}>
{estimate.insufficient_data ? "—" : `${formatMln(estimate.median_price_rub)}`}
</b>
</div>
)}
</div>
</div>
<div className="card-body">
<div className="progress-summary">
<span className="lead">
{isPending
? "Идёт параллельный запрос — Celery group, timeout 30 сек."
: isDone
? "Готово. Частичные результаты доступны при недоступных источниках."
: "Введите параметры квартиры и нажмите «Оценить»."}
</span>
<div className="progress-overall">
<div className="bar" style={{ width: `${overallPct}%` }} />
</div>
</div>
<div className="progress-list">
{rows.map((r) => (
<div key={r.key} className={`progress-row is-${r.status}`}>
<span className={`status-icon ${r.status}`}>
{r.status === "done" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{r.status === "loading" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)}
{r.status === "error" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)}
{r.status === "idle" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
</svg>
)}
</span>
<span className="src-name">{r.label}</span>
<span className="src-bar">
<i
style={{
// @ts-expect-error CSS custom prop
"--p": r.status === "done" ? "100%" : r.status === "loading" ? "60%" : "0%",
}}
/>
</span>
<span
className="src-value"
style={{ color: r.status === "error" ? "var(--danger)" : undefined }}
>
{r.status === "done" && r.count !== undefined && (
<>
<b>{r.count} лотов</b>
</>
)}
{r.status === "done" && r.count === undefined && <b>готово</b>}
{r.status === "loading" && "сбор..."}
{r.status === "error" && "timeout — нет ответа"}
{r.status === "idle" && (
<span style={{ color: "var(--muted-2)" }}>
{isDone ? "нет данных" : "ожидает запрос"}
</span>
)}
</span>
</div>
))}
</div>
</div>
<div className="card-foot">
<span className="mono" style={{ fontSize: 11, letterSpacing: "0.06em" }}>
CACHE-KEY ·{" "}
</span>
<span className="mono" style={{ fontSize: 11 }}>
{estimate?.estimate_id?.slice(0, 18) ?? "—"}
</span>{" "}
·{" "}
<span>при недоступности источника частичный результат не блокируется</span>
</div>
</article>
);
}