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
212 lines
7.8 KiB
TypeScript
212 lines
7.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|