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:
parent
4e36fcb0cc
commit
abafb60120
4 changed files with 217 additions and 3 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
92
tradein-mvp/frontend/src/app/cache/page.tsx
vendored
Normal file
92
tradein-mvp/frontend/src/app/cache/page.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
77
tradein-mvp/frontend/src/app/history/page.tsx
Normal file
77
tradein-mvp/frontend/src/app/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue