gendesign/tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx
bot-backend 44fdab2d5e
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/ui): две равноправные hero-цены (выставить/получить) + компактный ряд бенчмарков, убрать дубли
2026-06-20 10:14:27 +03:00

197 lines
7.3 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>
</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>при недоступности источника частичный результат не блокируется</span>
</div>
</article>
);
}