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,
|
media_type=row.content_type,
|
||||||
headers={"Cache-Control": "private, max-age=3600"},
|
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 "@/components/trade-in/trade-in.css";
|
||||||
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
||||||
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||||
|
|
@ -80,9 +81,9 @@ export default function TradeInPage() {
|
||||||
<span className="brand-product">Trade-In</span>
|
<span className="brand-product">Trade-In</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="top-nav">
|
<nav className="top-nav">
|
||||||
<a href="#" className="is-active">Оценка</a>
|
<a href={`${API_BASE_URL}/`} className="is-active">Оценка</a>
|
||||||
<a href="#">История</a>
|
<a href={`${API_BASE_URL}/history`}>История</a>
|
||||||
<a href="#">Кэш</a>
|
<a href={`${API_BASE_URL}/cache`}>Кэш</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue