- 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
187 lines
6 KiB
TypeScript
187 lines
6 KiB
TypeScript
"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>
|
||
);
|
||
}
|