471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
"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 "ов";
|
||
}
|