gendesign/frontend/src/components/trade-in/EstimateResult.tsx
lekss361 90eb5af883
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 39s
Deploy / build-frontend (push) Successful in 2m45s
feat(trade-in): TI-3 frontend /trade-in page + 5 components + hooks (#317)
2026-05-17 16:58:53 +00:00

471 lines
14 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";
import type { AggregatedEstimate, ConfidenceLevel } from "@/types/trade-in";
import type { TradeInEstimateInput } from "@/types/trade-in";
import { PriceRangeBar } from "./PriceRangeBar";
import { AnalogsTable } from "./AnalogsTable";
// ── helpers ────────────────────────────────────────────────────────────────────
function fmtRub(value: number): string {
return value.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
});
}
function fmtRubM(value: number): string {
const m = value / 1_000_000;
return `${m.toFixed(2).replace(".", ",")} млн ₽`;
}
const CONFIDENCE_COLOR: Record<
ConfidenceLevel,
{ bg: string; fg: string; border: string; label: string }
> = {
high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac", label: "Высокая" },
medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a", label: "Средняя" },
low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5", label: "Низкая" },
};
const HOUSE_TYPE_LABELS: Record<string, string> = {
panel: "Панель",
brick: "Кирпич",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_STATE_LABELS: Record<string, string> = {
needs_repair: "Требует ремонта",
standard: "Стандартный",
good: "Хороший",
excellent: "Евроремонт",
};
// ── sub-components ────────────────────────────────────────────────────────────
function SectionHeader({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
paddingBottom: 8,
borderBottom: "1px solid #e6e8ec",
}}
>
<span style={{ color: "#1d4ed8" }}>{icon}</span>
<h3
style={{ margin: 0, fontSize: 15, fontWeight: 700, color: "#1a1d23" }}
>
{title}
</h3>
</div>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
}}
>
{children}
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
}
export function EstimateResult({ estimate, input }: Props) {
const conf = CONFIDENCE_COLOR[estimate.confidence];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Hero card */}
<Card>
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<div>
<div
style={{
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.4,
fontWeight: 600,
}}
>
Оценочная стоимость
</div>
<div
style={{
fontSize: 32,
fontWeight: 800,
color: "#1a1d23",
fontVariantNumeric: "tabular-nums",
lineHeight: 1.1,
}}
>
{fmtRubM(estimate.median_price_rub)}
</div>
<div
style={{
fontSize: 14,
color: "#5b6066",
marginTop: 4,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtRub(estimate.median_price_per_m2)} / м²
</div>
</div>
{/* Confidence badge */}
<div
style={{
background: conf.bg,
border: `1px solid ${conf.border}`,
borderRadius: 8,
padding: "6px 12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
}}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
color: conf.fg,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Достоверность
</span>
<span style={{ fontSize: 16, fontWeight: 800, color: conf.fg }}>
{conf.label}
</span>
<span style={{ fontSize: 11, color: conf.fg }}>
{estimate.n_analogs} аналог{ending(estimate.n_analogs)}
</span>
</div>
</div>
{/* Price range bar */}
<PriceRangeBar
rangeLow={estimate.range_low_rub}
rangeHigh={estimate.range_high_rub}
median={estimate.median_price_rub}
/>
<div
style={{
marginTop: 10,
fontSize: 12,
color: "#9ca3af",
}}
>
Диапазон: {fmtRubM(estimate.range_low_rub)} {" "}
{fmtRubM(estimate.range_high_rub)} · данные за{" "}
{estimate.period_months} мес.
</div>
</Card>
{/* Section 1 — Cover / input snapshot */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
>
{label}
</div>
<div
style={{
fontSize: 13,
color: "#1a1d23",
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
))}
</div>
</Card>
{/* Section 2 — Listings (analogs) */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
}
title={`Объявления-аналоги (${estimate.analogs.length})`}
/>
<AnalogsTable
rows={estimate.analogs}
emptyLabel="Нет объявлений-аналогов в выборке"
/>
</Card>
{/* Section 3 — Actual deals */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
}
title={`Фактические сделки (${estimate.actual_deals.length})`}
/>
<AnalogsTable
rows={estimate.actual_deals}
emptyLabel="Нет фактических сделок в выборке"
/>
</Card>
{/* Section 4 — Trade-in cost placeholder */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="12" y2="14" />
</svg>
}
title="Выкупная стоимость (trade-in)"
/>
<div
style={{
padding: "20px 0",
textAlign: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
<div
style={{
marginBottom: 8,
display: "flex",
justifyContent: "center",
}}
>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="#d1d5db"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="12" y2="14" />
</svg>
</div>
Подробный расчёт в PDF-отчёте.
<br />
<span style={{ fontSize: 12 }}>
Выкупная цена, таблица self-sale vs trade-in, 4 преимущества будут
в TI-2.
</span>
</div>
</Card>
{/* PDF button (disabled) */}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
title="Генерация PDF появится в TI-2"
style={{ display: "inline-block", cursor: "not-allowed" }}
>
<button
type="button"
disabled
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 18px",
background: "#e5e7eb",
color: "#9ca3af",
border: "1px solid #d1d5db",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: "not-allowed",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Скачать PDF
</button>
</div>
</div>
</div>
);
}
function ending(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return "";
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return "а";
return "ов";
}