gendesign/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

187 lines
6 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";
/**
* DealsCard — Секция 3 «Сделки» из mockup. Реальные сделки из Росреестра / ДомКлик.
*/
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
}
const SOURCE_DOTS: Record<string, string> = {
rosreestr: "ros",
domklik: "dom",
etagi: "etagi",
};
const SOURCE_LABELS: Record<string, string> = {
rosreestr: "Росреестр",
domklik: "ДомКлик",
etagi: "Этажи",
};
function fmtRub(v: number): string {
return v.toLocaleString("ru-RU");
}
function fmtDate(iso: string | null): string {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
} catch {
return "—";
}
}
export function DealsCard({ estimate }: Props) {
const deals = estimate.actual_deals;
if (deals.length === 0) {
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 3 · Сделки</div>
<h2>Фактические сделки по аналогичным квартирам</h2>
</div>
</div>
<div className="card-body">
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
Нет фактических сделок в радиусе 2 км за 12 месяцев.
</p>
</div>
</article>
);
}
// Сортируем по дате DESC, берем медиану и range
const prices = deals.map((d) => d.price_rub).sort((a, b) => a - b);
const m = prices[Math.floor(prices.length / 2)];
const lo = prices[0];
const hi = prices[prices.length - 1];
const sourceCounts: Record<string, number> = {};
for (const d of deals) {
if (d.source) sourceCounts[d.source] = (sourceCounts[d.source] ?? 0) + 1;
}
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 3 · Сделки</div>
<h2>Фактические сделки по аналогичным квартирам</h2>
</div>
<div className="card-meta">
<div>
Период: <b className="mono">за {estimate.period_months} мес.</b>
</div>
<div style={{ marginTop: 4 }}>
ground truth · подтверждено Росреестром
</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Сделок по аналогам</div>
<div className="value">
<span data-tnum>{deals.length}</span>
<span className="unit">шт</span>
</div>
<div className="sub">подтверждено Росреестром</div>
</div>
<div className="count-cell">
<div className="label">Медиана сделок</div>
<div className="value">
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
<span className="unit">млн </span>
</div>
<div className="sub">
на {(((estimate.median_price_rub - m) / estimate.median_price_rub) * 100).toFixed(0)}%
ниже рынка
</div>
</div>
<div className="count-cell">
<div className="label">Диапазон</div>
<div className="value">
<span data-tnum>
{(lo / 1_000_000).toFixed(1)} {(hi / 1_000_000).toFixed(1)}
</span>
<span className="unit">млн</span>
</div>
<div className="sub">7 месяцев · разница к рынку</div>
</div>
</div>
<div className="filters-row">
<span className="lbl">Источники</span>
<div className="sources">
{Object.entries(sourceCounts).map(([src, c]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
{SOURCE_LABELS[src] ?? src} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{c} сделок
</span>
</span>
))}
</div>
</div>
<table className="dt" aria-label="Фактические сделки">
<thead>
<tr>
<th>Адрес</th>
<th>Источник</th>
<th className="num"> / м²</th>
<th className="num">Цена сделки</th>
<th className="num">Дата</th>
</tr>
</thead>
<tbody>
{deals.map((d, i) => (
<DealRow key={i} deal={d} />
))}
</tbody>
</table>
<div className="table-foot">
<span>
Показано <b style={{ color: "var(--fg)" }}>{deals.length}</b> фактических сделок
</span>
</div>
</article>
);
}
function DealRow({ deal }: { deal: AnalogLot }) {
const src = deal.source ?? "";
const dot = SOURCE_DOTS[src] ?? "dom";
const label = SOURCE_LABELS[src] ?? src;
return (
<tr>
<td>
<div className="addr">
<span className="a-main">{deal.address}</span>
<span className="a-sub">
{deal.area_m2.toFixed(1)} м² · {deal.rooms === 0 ? "студия" : `${deal.rooms}`}
{deal.floor !== null && deal.total_floors !== null
? ` · этаж ${deal.floor}/${deal.total_floors}`
: ""}
</span>
</div>
</td>
<td>
<span className="src-mini">
<span className={`src-dot ${dot}`} /> {label}
</span>
</td>
<td className="num">{fmtRub(deal.price_per_m2)}</td>
<td className="num">{fmtRub(deal.price_rub)}</td>
<td className="num">{fmtDate(deal.listing_date)}</td>
</tr>
);
}