feat(tradein): рабочие страницы История и Кэш (#399)

Заглушки навбара (href=#) → рабочие страницы.

- GET /api/v1/trade-in/history — последние оценки из trade_in_estimates.
- GET /api/v1/trade-in/cache-stats — счётчики данных/кэшей.
- app/history/page.tsx — таблица прошлых оценок, ссылки на отчёты.
- app/cache/page.tsx — состояние данных (объявления, сделки, кадастр,
  кэш геокодера, последний парсинг).
- navbar — ссылки История / Кэш ведут на страницы.

Closes #399
This commit is contained in:
TradeIn Deploy 2026-05-22 11:28:27 +05:00
parent 4e36fcb0cc
commit abafb60120
4 changed files with 217 additions and 3 deletions

View file

@ -520,3 +520,47 @@ def get_photo(
media_type=row.content_type,
headers={"Cache-Control": "private, max-age=3600"},
)
# ── История и кэш (#399) ─────────────────────────────────────────────────────
@router.get("/history")
def estimate_history(
db: Annotated[Session, Depends(get_db)],
limit: int = 50,
) -> list[dict[str, object]]:
"""История оценок (#399) — последние N записей trade_in_estimates."""
rows = db.execute(
text(
"""
SELECT id, address, rooms, area_m2, median_price,
confidence, n_analogs, created_at
FROM trade_in_estimates
ORDER BY created_at DESC
LIMIT :limit
"""
),
{"limit": min(max(limit, 1), 200)},
).mappings().all()
return [dict(r) for r in rows]
@router.get("/cache-stats")
def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
"""Состояние данных и кэшей (#399) — для страницы «Кэш»."""
row = db.execute(
text(
"""
SELECT
(SELECT count(*) FROM geocode_cache) AS geocode_cache,
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
AS geocode_cache_fresh,
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
(SELECT count(*) FROM deals) AS deals,
(SELECT count(*) FROM cad_buildings) AS cad_buildings,
(SELECT count(*) FROM house_metadata) AS house_metadata,
(SELECT count(*) FROM trade_in_estimates) AS estimates_total
"""
)
).mappings().fetchone()
return dict(row) if row else {}

View file

@ -0,0 +1,92 @@
"use client";
/* Состояние данных и кэшей (#399). */
import { useEffect, useState } from "react";
import "@/components/trade-in/trade-in.css";
import { API_BASE_URL } from "@/lib/api";
interface CacheStats {
geocode_cache: number;
geocode_cache_fresh: number;
listings_active: number;
listings_last_scraped: string | null;
deals: number;
cad_buildings: number;
house_metadata: number;
estimates_total: number;
}
export default function CachePage() {
const [stats, setStats] = useState<CacheStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE_URL}/api/v1/trade-in/cache-stats`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => setStats(d as CacheStats | null))
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, []);
const items: Array<[string, string]> = stats
? [
["Оценок всего", String(stats.estimates_total)],
["Объявлений активных", String(stats.listings_active)],
["Сделок", String(stats.deals)],
["Кадастр зданий", String(stats.cad_buildings)],
["Кэш домов (house_metadata)", String(stats.house_metadata)],
[
"Кэш геокодера (всего / свежих)",
`${stats.geocode_cache} / ${stats.geocode_cache_fresh}`,
],
[
"Последний парсинг объявлений",
stats.listings_last_scraped
? new Date(stats.listings_last_scraped).toLocaleString("ru-RU")
: "—",
],
]
: [];
return (
<main className="page" style={{ padding: 24, maxWidth: 700, margin: "0 auto" }}>
<p style={{ marginBottom: 12 }}>
<a href={`${API_BASE_URL}/`}> К оценке</a>
</p>
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>
Состояние данных и кэшей
</h1>
{loading ? (
<p style={{ color: "var(--muted)" }}>Загрузка</p>
) : !stats ? (
<p style={{ color: "var(--muted)" }}>Не удалось загрузить статистику.</p>
) : (
<div
style={{
border: "1px solid var(--border, #e5e5e5)",
borderRadius: 8,
overflow: "hidden",
}}
>
{items.map(([k, v], i) => (
<div
key={k}
style={{
display: "flex",
justifyContent: "space-between",
padding: "11px 14px",
fontSize: 14,
borderTop: i === 0 ? "none" : "1px solid var(--border, #eee)",
}}
>
<span style={{ color: "var(--muted)" }}>{k}</span>
<span style={{ fontWeight: 600 }}>{v}</span>
</div>
))}
</div>
)}
</main>
);
}

View file

@ -0,0 +1,77 @@
"use client";
/* История оценок (#399). */
import { useEffect, useState } from "react";
import "@/components/trade-in/trade-in.css";
import { API_BASE_URL } from "@/lib/api";
interface HistoryRow {
id: string;
address: string;
rooms: number;
area_m2: number;
median_price: number;
confidence: string;
n_analogs: number;
created_at: string;
}
export default function HistoryPage() {
const [rows, setRows] = useState<HistoryRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE_URL}/api/v1/trade-in/history?limit=100`)
.then((r) => (r.ok ? r.json() : []))
.then((d) => setRows(d as HistoryRow[]))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, []);
return (
<main className="page" style={{ padding: 24, maxWidth: 1000, margin: "0 auto" }}>
<p style={{ marginBottom: 12 }}>
<a href={`${API_BASE_URL}/`}> К оценке</a>
</p>
<h1 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>История оценок</h1>
{loading ? (
<p style={{ color: "var(--muted)" }}>Загрузка</p>
) : rows.length === 0 ? (
<p style={{ color: "var(--muted)" }}>Оценок пока нет.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid var(--border, #ddd)" }}>
<th style={{ padding: 8 }}>Дата</th>
<th style={{ padding: 8 }}>Адрес</th>
<th style={{ padding: 8 }}>Комн.</th>
<th style={{ padding: 8 }}>Площадь</th>
<th style={{ padding: 8, textAlign: "right" }}>Медиана, </th>
<th style={{ padding: 8 }}>Точность</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} style={{ borderBottom: "1px solid var(--border, #eee)" }}>
<td style={{ padding: 8, whiteSpace: "nowrap" }}>
{new Date(r.created_at).toLocaleDateString("ru-RU")}
</td>
<td style={{ padding: 8 }}>
<a href={`${API_BASE_URL}/?id=${r.id}`}>{r.address}</a>
</td>
<td style={{ padding: 8 }}>{r.rooms}</td>
<td style={{ padding: 8 }}>{r.area_m2} м²</td>
<td style={{ padding: 8, textAlign: "right" }}>
{Number(r.median_price).toLocaleString("ru-RU")}
</td>
<td style={{ padding: 8 }}>{r.confidence}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}

View file

@ -11,6 +11,7 @@ import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
import { API_BASE_URL } from "@/lib/api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
@ -80,9 +81,9 @@ export default function TradeInPage() {
<span className="brand-product">Trade-In</span>
</div>
<nav className="top-nav">
<a href="#" className="is-active">Оценка</a>
<a href="#">История</a>
<a href="#">Кэш</a>
<a href={`${API_BASE_URL}/`} className="is-active">Оценка</a>
<a href={`${API_BASE_URL}/history`}>История</a>
<a href={`${API_BASE_URL}/cache`}>Кэш</a>
</nav>
</div>
</header>