feat(tradein/v2): CacheView history + PDF link + quota + address autocomplete (#2042)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

FE-5, last FE wiring on the mappers pattern. Closes #2042.
- New hooks (lib/trade-in-api.ts): useEstimateHistory (GET /history), useGeocodeSuggest
  (GET /geocode/suggest?q=&limit=, debounced, enabled>=3 chars); useQuota reused.
- types/trade-in.ts: EstimateHistoryItem (area_m2 string, median_price), GeocodeSuggestion.
- mappers.ts: mapCache(history) -> {rows: CacheRow[], kpis: CacheKpi[]} — per-row status
  by 24h TTL; KPIs FE-derived from /history (всего/средняя цена/повторные %), NOT the
  global /cache-stats.
- CacheView: real previous-estimates list. ParamsPanel: address autocomplete dropdown →
  captures lat/lon into submit (cleared on manual edit). HeroBar: «Скачать PDF» → /estimate/
  {id}/pdf, UUID-gated + safeUrl scheme check + disabled when no estimate. TopNav: «Мои
  отчёты» from quota.used/history. page.tsx: invalidate history+quota on new estimate.
- Hydration-safe: PDF link gated behind a mounted flag (urlId is SSR-null/client-uuid).
- FE-1..4 untouched.

Verified: next build green (/v2 39.6 kB); tsx harness ran mapCache vs real /history (10
items: ВСЕГО 10 / СРЕДНЯЯ 8,85 млн / ПОВТОРНЫЕ 50%, statuses, null graceful).
code-reviewer APPROVE after hydration + reports-fallback fixes.
This commit is contained in:
bot-backend 2026-06-28 16:14:34 +03:00
parent 0c0efb887a
commit 81ae21ada9
9 changed files with 473 additions and 47 deletions

View file

@ -10,9 +10,10 @@
// a pending or errored street-deals / analytics call degrades its section to a // a pending or errored street-deals / analytics call degrades its section to a
// fallback inside the mapper instead of blanking the page. // fallback inside the mapper instead of blanking the page.
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { tokens } from "@/components/trade-in/v2/tokens"; import { tokens } from "@/components/trade-in/v2/tokens";
import TopNav from "@/components/trade-in/v2/TopNav"; import TopNav from "@/components/trade-in/v2/TopNav";
@ -25,11 +26,13 @@ import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer"; import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types"; import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
import type { import type {
CacheData,
HistoryData, HistoryData,
SourcesData, SourcesData,
} from "@/components/trade-in/v2/mappers"; } from "@/components/trade-in/v2/mappers";
import { import {
mapAnalytics, mapAnalytics,
mapCache,
mapHistory, mapHistory,
mapObject, mapObject,
mapReport, mapReport,
@ -45,6 +48,7 @@ import type {
import { HTTPError } from "@/lib/api"; import { HTTPError } from "@/lib/api";
import { import {
useEstimate, useEstimate,
useEstimateHistory,
useEstimateHouseAnalytics, useEstimateHouseAnalytics,
useEstimateMutation, useEstimateMutation,
useEstimatePlacementHistory, useEstimatePlacementHistory,
@ -52,6 +56,7 @@ import {
useSalesVsListings, useSalesVsListings,
useStreetDeals, useStreetDeals,
} from "@/lib/trade-in-api"; } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative, // OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has // non-interactive overlay drawn over the artboard gradient. The frame has
@ -372,6 +377,7 @@ function readUrlId(): string | null {
export default function TradeInV2Page() { export default function TradeInV2Page() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const [nav, setNav] = useState(0); const [nav, setNav] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@ -383,6 +389,13 @@ export default function TradeInV2Page() {
const urlId = readUrlId(); const urlId = readUrlId();
// Post-mount flag: readUrlId() is null on SSR but a UUID on the client's first
// render, so any structure that depends on the id (e.g. the PDF <a> vs disabled
// <button>) would diverge between SSR and hydration on a fresh /v2?id= load.
// Gate id-dependent structure behind `mounted` to keep first render identical.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const mutation = useEstimateMutation(); const mutation = useEstimateMutation();
// Skip the restore fetch once we already hold a fresh result. // Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null); const restored = useEstimate(freshResult === null ? urlId : null);
@ -390,6 +403,13 @@ export default function TradeInV2Page() {
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null; const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null;
const currentEstimateId = freshResult?.estimate_id ?? urlId; const currentEstimateId = freshResult?.estimate_id ?? urlId;
// Per-user side data, independent of the current estimate. Both fail-soft
// (retry:false): history feeds the «Предыдущие оценки» overlay (04), quota
// feeds the TopNav «Мои отчёты» badge. Refreshed after a successful estimate
// via invalidateQueries in handleSubmit.
const history = useEstimateHistory();
const quota = useQuota();
// Dashboard sub-hooks — each resolves independently; failure degrades its // Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page. // section via the mappers (null input) rather than blanking the page.
const streetDeals = useStreetDeals( const streetDeals = useStreetDeals(
@ -465,6 +485,17 @@ export default function TradeInV2Page() {
() => mapSources(estimate, streetDealsData), () => mapSources(estimate, streetDealsData),
[estimate, streetDealsData], [estimate, streetDealsData],
); );
// «Предыдущие оценки» (04) — KPIs + last-estimates table derived purely from
// the per-user /history list (never /cache-stats, which is global/admin).
const cacheData = useMemo<CacheData>(
() => mapCache(history.data ?? null),
[history.data],
);
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
// authoritative monthly counter; fall back to the loaded history length, then
// to the fixture default (undefined) for unwired/pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the // Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
// estimate arrives async (useState reads initialValues only on mount). // estimate arrives async (useState reads initialValues only on mount).
@ -490,6 +521,14 @@ export default function TradeInV2Page() {
mutation.mutate(input, { mutation.mutate(input, {
onSuccess: (est) => { onSuccess: (est) => {
setFreshResult(est); setFreshResult(est);
// A new estimate consumed quota and added a history row — refresh both
// so the TopNav badge + «Предыдущие оценки» overlay reflect it.
void queryClient.invalidateQueries({
queryKey: ["trade-in", "history"],
});
void queryClient.invalidateQueries({
queryKey: ["trade-in", "quota"],
});
// basePath auto-prefixes → /trade-in/v2?id=... // basePath auto-prefixes → /trade-in/v2?id=...
router.replace(`/v2?id=${est.estimate_id}`, { scroll: false }); router.replace(`/v2?id=${est.estimate_id}`, { scroll: false });
}, },
@ -587,9 +626,10 @@ export default function TradeInV2Page() {
flexDirection: "column", flexDirection: "column",
}} }}
> >
<TopNav active={nav} onNavigate={setNav} /> <TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
<HeroBar <HeroBar
data={{ report, object: objectInfo }} data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)} onOpenInfo={() => setDrawerOpen(true)}
/> />
@ -624,6 +664,7 @@ export default function TradeInV2Page() {
history={historyData} history={historyData}
analytics={analyticsViewData} analytics={analyticsViewData}
sources={sourcesData} sources={sourcesData}
cache={cacheData}
/> />
)} )}
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} /> <LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />

View file

@ -1,13 +1,16 @@
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view). // OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613. // Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
// Data from fixtures (cacheKpi + cacheRows); no API, no local state. // Data via `data` prop (mapCache output); defaults to fixtures (cacheKpi + cacheRows).
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures"; import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr"; const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
export function CacheView() { const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */} {/* 3 KPI cards */}
@ -18,7 +21,7 @@ export function CacheView() {
gap: 16, gap: 16,
}} }}
> >
{cacheKpi.map((kpi) => ( {data.kpis.map((kpi) => (
<div <div
key={kpi.label} key={kpi.label}
style={{ style={{
@ -98,7 +101,7 @@ export function CacheView() {
<span>ИСТОЧНИКОВ</span> <span>ИСТОЧНИКОВ</span>
<span>СТАТУС</span> <span>СТАТУС</span>
</div> </div>
{cacheRows.map((r, i) => ( {data.rows.map((r, i) => (
<div <div
key={`${r.addr}-${i}`} key={`${r.addr}-${i}`}
style={{ style={{

View file

@ -1,7 +1,11 @@
"use client"; "use client";
import type { CSSProperties } from "react";
import Image from "next/image"; import Image from "next/image";
import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { object, report } from "./fixtures"; import { object, report } from "./fixtures";
import type { HeroBarData } from "./mappers"; import type { HeroBarData } from "./mappers";
@ -9,15 +13,67 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures. // Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object }; const HERO_FIXTURE: HeroBarData = { report, object };
// estimate ids are server-issued UUIDs — reject anything else before it lands
// in the PDF request path so a tampered id cannot be injected.
const PDF_UUID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
// Build + validate the PDF download URL for an estimate id. Returns null when
// there is no estimate yet (→ disabled button), the id is not a UUID, or the
// resolved URL fails the safeUrl scheme allowlist. API_BASE_URL is relative
// ("" in dev, "/trade-in" behind Caddy), so resolve against the document origin
// purely for the safeUrl http/https check; the <a> keeps the relative href
// (the browser resolves it against the current page, like OfferCard's link).
function pdfDownloadHref(estimateId: string | null | undefined): string | null {
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
if (typeof window === "undefined") return href;
return safeUrl(new URL(href, window.location.origin).toString()) ? href : null;
}
interface HeroBarProps { interface HeroBarProps {
data?: HeroBarData; data?: HeroBarData;
estimateId?: string | null;
onOpenInfo: () => void; onOpenInfo: () => void;
} }
const pdfBtnStyle: CSSProperties = {
flex: 1,
height: 50,
background: tokens.surface.w55,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
gap: 11,
padding: "0 16px",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
textDecoration: "none",
transition: "all .15s",
};
export default function HeroBar({ export default function HeroBar({
data = HERO_FIXTURE, data = HERO_FIXTURE,
estimateId,
onOpenInfo, onOpenInfo,
}: HeroBarProps) { }: HeroBarProps) {
const pdfHref = pdfDownloadHref(estimateId);
const pdfBtnInner = (
<>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
</svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
СКАЧАТЬ PDF-ОТЧЁТ
</span>
</>
);
return ( return (
<div <div
style={{ style={{
@ -123,37 +179,26 @@ export default function HeroBar({
</div> </div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}> <div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
<button {pdfHref ? (
type="button" <a
className="hero-pdf-btn" className="hero-pdf-btn"
style={{ href={pdfHref}
flex: 1, target="_blank"
height: 50, rel="noopener noreferrer"
background: tokens.surface.w55, style={pdfBtnStyle}
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
gap: 11,
padding: "0 16px",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
transition: "all .15s",
}}
>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
</svg>
<span
style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}
> >
СКАЧАТЬ PDF-ОТЧЁТ {pdfBtnInner}
</span> </a>
</button> ) : (
<button
type="button"
disabled
title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
>
{pdfBtnInner}
</button>
)}
<button <button
type="button" type="button"
className="hero-calc-btn" className="hero-calc-btn"

View file

@ -10,7 +10,7 @@
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled // The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>. // (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>.
import { useState, type CSSProperties } from "react"; import { useRef, useState, type CSSProperties } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures"; import { dropdownOptions } from "./fixtures";
import { import {
@ -19,7 +19,9 @@ import {
REPAIR_FROM_RU, REPAIR_FROM_RU,
REPAIR_RU, REPAIR_RU,
} from "./mappers"; } from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import type { import type {
GeocodeSuggestion,
HouseType, HouseType,
RepairState, RepairState,
TradeInEstimateInput, TradeInEstimateInput,
@ -202,6 +204,49 @@ const addressField: CSSProperties = {
outline: "none", outline: "none",
}; };
// Autocomplete dropdown — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow). Absolutely positioned under the address input; scrolls past ~6 rows.
const suggestPanel: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
top: "calc(100% + 4px)",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
maxHeight: 196,
overflowY: "auto",
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};
const suggestOpt: CSSProperties = {
padding: "7px 10px",
borderRadius: 4,
cursor: "pointer",
};
const suggestOptMain: CSSProperties = {
fontFamily: tokens.font.sans,
fontSize: 12.5,
color: tokens.ink,
};
const suggestOptSub: CSSProperties = {
fontFamily: tokens.font.sans,
fontSize: 10,
color: tokens.muted2,
marginTop: 1,
};
const suggestNote: CSSProperties = {
padding: "8px 10px",
fontFamily: tokens.font.sans,
fontSize: 11,
color: tokens.muted2,
};
// Inline validation / server error — only rendered when there is an error, so // Inline validation / server error — only rendered when there is an error, so
// the happy-path layout is pixel-unchanged. // the happy-path layout is pixel-unchanged.
const errorText: CSSProperties = { const errorText: CSSProperties = {
@ -273,6 +318,36 @@ export default function ParamsPanel({
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true); const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
// The typed string is debounced into `addressQuery` from inside the change
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
// gated at >=3 chars and fires on the debounced value. Picking a suggestion
// fills the full address and captures lat/lon so the backend can skip geocoding.
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? "");
const [suggestOpen, setSuggestOpen] = useState(false);
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
initialValues?.lat != null && initialValues?.lon != null
? { lat: initialValues.lat, lon: initialValues.lon }
: null,
);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggest = useGeocodeSuggest(addressQuery);
const handleAddressChange = (v: string) => {
setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point
setSuggestOpen(v.trim().length >= 3);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
};
const pickSuggestion = (s: GeocodeSuggestion) => {
setAddress(s.full_address);
setAddressQuery(s.full_address);
setCoords({ lat: s.lat, lon: s.lon });
setSuggestOpen(false);
};
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) — // TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
// дропдаун оставлен визуально, но non-functional. // дропдаун оставлен визуально, но non-functional.
const radius = "500 м"; const radius = "500 м";
@ -299,6 +374,7 @@ export default function ParamsPanel({
return; return;
} }
setValidationError(null); setValidationError(null);
setSuggestOpen(false);
onSubmit?.({ onSubmit?.({
address: trimmedAddress, address: trimmedAddress,
area_m2: areaNum, area_m2: areaNum,
@ -309,6 +385,8 @@ export default function ParamsPanel({
house_type: HOUSE_TYPE_FROM_RU[houseType], house_type: HOUSE_TYPE_FROM_RU[houseType],
repair_state: REPAIR_FROM_RU[repair], repair_state: REPAIR_FROM_RU[repair],
has_balcony: balcony, has_balcony: balcony,
lat: coords?.lat ?? null,
lon: coords?.lon ?? null,
}); });
}; };
@ -802,16 +880,24 @@ export default function ParamsPanel({
<div> <div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div> <div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* TODO(FE-5): address autocomplete (Yandex / Nominatim) out of {/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
scope here; plain controlled input for now. */} is debounced into `addressQuery`; the dropdown below mirrors the
HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */}
<input <input
type="text" type="text"
value={address} value={address}
onChange={(e) => setAddress(e.target.value)} onChange={(e) => handleAddressChange(e.target.value)}
onFocus={() => {
if (address.trim().length >= 3) setSuggestOpen(true);
}}
onBlur={() => setSuggestOpen(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit(); if (e.key === "Enter") handleSubmit();
if (e.key === "Escape") setSuggestOpen(false);
}} }}
placeholder="Город, улица, дом" placeholder="Город, улица, дом"
autoComplete="off"
style={addressField} style={addressField}
/> />
<svg <svg
@ -832,6 +918,34 @@ export default function ParamsPanel({
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" /> <line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" /> <line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
</svg> </svg>
{suggestOpen && addressQuery.trim().length >= 3 && (
<div style={suggestPanel}>
{suggest.data && suggest.data.length > 0 ? (
suggest.data.map((s, i) => (
<div
key={`${s.full_address}-${i}`}
className="pp-dd-opt"
// onMouseDown (not onClick) + preventDefault fires before
// the input's onBlur, so the pick lands before the list closes.
onMouseDown={(e) => {
e.preventDefault();
pickSuggestion(s);
}}
style={suggestOpt}
>
<div style={suggestOptMain}>{s.label}</div>
{s.full_address !== s.label && (
<div style={suggestOptSub}>{s.full_address}</div>
)}
</div>
))
) : suggest.isFetching ? (
<div style={suggestNote}>Поиск</div>
) : (
<div style={suggestNote}>Ничего не найдено</div>
)}
</div>
)}
</div> </div>
</div> </div>

View file

@ -12,18 +12,20 @@ import HistoryView from "./HistoryView";
import SourcesView from "./SourcesView"; import SourcesView from "./SourcesView";
import AnalyticsView from "./AnalyticsView"; import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView"; import { CacheView } from "./CacheView";
import type { HistoryData, SourcesData } from "./mappers"; import type { CacheData, HistoryData, SourcesData } from "./mappers";
import type { Analytics } from "./types"; import type { Analytics } from "./types";
interface SectionOverlayProps { interface SectionOverlayProps {
active: number; active: number;
onClose: () => void; onClose: () => void;
onNavigate: (i: number) => void; onNavigate: (i: number) => void;
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics). // Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
// Optional so unwired/storybook usage falls back to each view's fixture default. // / mapCache). Optional so unwired/storybook usage falls back to each view's
// fixture default.
history?: HistoryData; history?: HistoryData;
sources?: SourcesData; sources?: SourcesData;
analytics?: Analytics; analytics?: Analytics;
cache?: CacheData;
} }
export default function SectionOverlay({ export default function SectionOverlay({
@ -33,6 +35,7 @@ export default function SectionOverlay({
history, history,
sources, sources,
analytics, analytics,
cache,
}: SectionOverlayProps) { }: SectionOverlayProps) {
return ( return (
<div <div
@ -155,7 +158,7 @@ export default function SectionOverlay({
{active === 3 && ( {active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} /> <AnalyticsView data={analytics} onNavigate={onNavigate} />
)} )}
{active === 4 && <CacheView />} {active === 4 && <CacheView data={cache} />}
</div> </div>
</div> </div>
); );

View file

@ -16,6 +16,9 @@ import { navLabels, user, version } from "./fixtures";
interface TopNavProps { interface TopNavProps {
active: number; active: number;
onNavigate: (i: number) => void; onNavigate: (i: number) => void;
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
// estimate count). Defaults to the design fixture for unwired usage.
reports?: number;
} }
const menuItemStyle: CSSProperties = { const menuItemStyle: CSSProperties = {
@ -30,7 +33,11 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s", transition: "background .12s",
}; };
export default function TopNav({ active, onNavigate }: TopNavProps) { export default function TopNav({
active,
onNavigate,
reports = user.reports,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false); const [userOpen, setUserOpen] = useState(false);
return ( return (
@ -336,7 +343,7 @@ export default function TopNav({ active, onNavigate }: TopNavProps) {
color: tokens.accent, color: tokens.accent,
}} }}
> >
{user.reports} {reports}
</span> </span>
</div> </div>

View file

@ -26,6 +26,7 @@ import type {
AggregatedEstimate, AggregatedEstimate,
AnalogLot, AnalogLot,
ConfidenceLevel, ConfidenceLevel,
EstimateHistoryItem,
HouseAnalyticsKpi, HouseAnalyticsKpi,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseType, HouseType,
@ -41,6 +42,8 @@ import type {
AnalyticsKpi, AnalyticsKpi,
AxisTickX, AxisTickX,
AxisTickY, AxisTickY,
CacheKpi,
CacheRow,
DealRow, DealRow,
DkpRow, DkpRow,
History, History,
@ -1455,3 +1458,134 @@ export function mapSources(
return { adRows, dealRows, marketAds, marketDeals }; return { adRows, dealRows, marketAds, marketDeals };
} }
// ── 07 ПРЕДЫДУЩИЕ ОЦЕНКИ (CacheView) ─────────────────────────────────────────
// FE-derived ИСКЛЮЧИТЕЛЬНО из per-user списка /history (НЕ из /cache-stats — тот
// глобальный/админский: estimates_total считает ВСЕХ юзеров). Несколько полей,
// которых нет в /history, аппроксимируются на фронте (см. TODO BE-1):
// • «ИСТОЧНИКОВ» (N / 7) требует sources_used — в /history его нет → "—".
// • Статус свежести строится от 24-часового TTL кэша (ParamsPanel:
// «КЭШ ПО АДРЕСУ — 24 Ч»): <18ч свежий · 1824ч устаревает · >24ч устарел.
export interface CacheData {
rows: CacheRow[];
kpis: CacheKpi[];
}
const CACHE_TTL_HOURS = 24;
/**
* created_at -> компактное ru-время для таблицы (как в дизайне):
* сегодня -> "HH:MM:SS"; вчера -> "вчера HH:MM"; раньше -> "DD.MM.YYYY".
*/
function fmtCacheTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const t = d.getTime();
if (t >= startOfToday) {
return d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
if (t >= startOfToday - 86_400_000) {
return `вчера ${d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}`;
}
return fmtDate(iso);
}
/** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */
function cacheStatus(iso: string | null | undefined): {
status: string;
statusColor: string;
} {
if (!iso) return { status: "—", statusColor: tokens.muted };
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { status: "—", statusColor: tokens.muted };
const ageH = (Date.now() - d.getTime()) / 3_600_000;
if (ageH <= CACHE_TTL_HOURS * 0.75) {
return { status: "свежий", statusColor: tokens.success };
}
if (ageH <= CACHE_TTL_HOURS) {
return { status: "устаревает", statusColor: tokens.warn };
}
return { status: "устарел", statusColor: tokens.danger };
}
/**
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
* фронте из per-user /history (null пустая таблица + KPI с "—").
* rows addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
* kpis ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн) ·
* ПОВТОРНЫЕ АДРЕСА (доля переоценок = (всего уникальных)/всего).
*/
export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const list = history ?? [];
const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at);
return {
addr: h.address || "—",
time: fmtCacheTime(h.created_at),
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
status,
statusColor,
};
});
// KPI 1 — всего оценок (длина per-user списка, capped /history limit).
const total = list.length;
// KPI 2 — средняя цена по строкам с положительной медианой.
const prices = list
.map((h) => h.median_price)
.filter((p): p is number => p != null && Number.isFinite(p) && p > 0);
const avg =
prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : null;
// KPI 3 — доля переоценок: (всего адресов уникальных) / всего адресов.
const seen = new Map<string, number>();
for (const h of list) {
const key = (h.address ?? "").trim().toLowerCase();
if (!key) continue;
seen.set(key, (seen.get(key) ?? 0) + 1);
}
const withAddr = [...seen.values()].reduce((a, b) => a + b, 0);
const dupPct =
withAddr > 0 ? ((withAddr - seen.size) / withAddr) * 100 : null;
const kpis: CacheKpi[] = [
{
label: "ВСЕГО ОЦЕНОК",
value: String(total),
sub: "по вашим оценкам",
},
{
label: "СРЕДНЯЯ ЦЕНА",
value: avg != null ? fmtMln(avg) : "—",
unit: avg != null ? "млн ₽" : undefined,
sub: "по проведённым оценкам",
},
{
// fmtPct не используем намеренно: он добавляет знак (+18%) и второй "%" —
// для доли это неверно; рендерим число + unit "%" (как в дизайне).
label: "ПОВТОРНЫЕ АДРЕСА",
value: dupPct != null ? String(Math.round(dupPct)) : "—",
unit: dupPct != null ? "%" : undefined,
sub: "доля переоценок",
},
];
return { rows, kpis };
}

View file

@ -1,11 +1,14 @@
"use client"; "use client";
import { useMutation, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api"; import { apiFetch } from "./api";
import type { import type {
AggregatedEstimate, AggregatedEstimate,
CianPriceChangeStats, CianPriceChangeStats,
EstimateHistoryItem,
GeocodeSuggestion,
GeocodeSuggestResponse,
HouseAnalyticsResponse, HouseAnalyticsResponse,
HouseInfoForEstimate, HouseInfoForEstimate,
IMVBenchmarkResponse, IMVBenchmarkResponse,
@ -17,6 +20,7 @@ import type {
} from "@/types/trade-in"; } from "@/types/trade-in";
const BASE = "/api/v1/trade-in"; const BASE = "/api/v1/trade-in";
const GEOCODE_BASE = "/api/v1/geocode";
/** /**
* POST /api/v1/trade-in/estimate * POST /api/v1/trade-in/estimate
@ -188,3 +192,47 @@ export function useEstimateSellTimeSensitivity(estimate_id: string | null) {
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });
} }
/**
* GET /api/v1/trade-in/history?limit=N
* Per-user список последних оценок (CacheView · «Предыдущие оценки»). Скоупится
* по X-Authenticated-User в бэке (#656) header проставляет Caddy basic_auth.
*
* Fail-soft как useQuota: `retry:false` чтобы 401 на протухшей сессии не
* ре-стучался. Освежать после успешной оценки явным invalidateQueries(
* ["trade-in","history"]).
*/
export function useEstimateHistory(limit = 50) {
return useQuery<EstimateHistoryItem[]>({
queryKey: ["trade-in", "history", limit],
queryFn: () =>
apiFetch<EstimateHistoryItem[]>(`${BASE}/history?limit=${limit}`),
staleTime: 30_000,
retry: false,
});
}
/**
* GET /api/v1/geocode/suggest?q=&limit=
* Автокомплит адресов в пределах ЕКБ для поля адреса (ParamsPanel). Debounce-
* friendly: вызывающий компонент дебаунсит строку query, хук кешируется по
* queryKey; `enabled` срабатывает только начиная с 3 символов (бэкенд min 2,
* берём 3 чтобы не дёргать на 1-2 символа). `select` разворачивает обёртку
* {items} GeocodeSuggestion[]; keepPreviousData убирает мерцание списка между
* последовательными запросами.
*/
export function useGeocodeSuggest(query: string, limit = 8) {
const q = query.trim();
return useQuery<GeocodeSuggestResponse, Error, GeocodeSuggestion[]>({
queryKey: ["trade-in", "geocode-suggest", q, limit],
queryFn: () =>
apiFetch<GeocodeSuggestResponse>(
`${GEOCODE_BASE}/suggest?q=${encodeURIComponent(q)}&limit=${limit}`,
),
select: (r) => r.items,
enabled: q.length >= 3,
staleTime: 5 * 60_000,
retry: false,
placeholderData: keepPreviousData,
});
}

View file

@ -395,3 +395,34 @@ export interface SellTimeSensitivityResponse {
target_median_price_per_m2: number | null; target_median_price_per_m2: number | null;
buckets: SellTimeBucket[]; // ровно 4 элемента: cheap, median, plus5, plus10 buckets: SellTimeBucket[]; // ровно 4 элемента: cheap, median, plus5, plus10
} }
// ── Estimate history (endpoint: GET /trade-in/history?limit=N) ──
// Per-user список последних оценок (скоупится по X-Authenticated-User в бэке,
// #656). Лёгкая проекция строки trade_in_estimates — НЕ полный AggregatedEstimate.
// NB: area_m2 — NUMERIC колонка, FastAPI сериализует Decimal в СТРОКУ ("35.00").
// sources_used в этом эндпоинте отсутствует (см. mapCache TODO BE-1).
export interface EstimateHistoryItem {
id: string; // UUID оценки (для restore по ?id=)
address: string | null;
rooms: number | null;
area_m2: string | null; // Decimal -> строка, напр. "35.00"
median_price: number;
confidence: ConfidenceLevel;
n_analogs: number;
created_at: string; // ISO datetime
}
// ── Geocode suggest (endpoint: GET /geocode/suggest?q=&limit=) ──
// Автокомплит адресов в пределах ЕКБ. Зеркалит SuggestItem / SuggestResponse
// из backend/app/api/v1/geocode.py.
export interface GeocodeSuggestion {
label: string; // короткая подпись для дропдауна
full_address: string; // полный адрес — то что подставляем в поле
lat: number;
lon: number;
kind: string; // тип объекта (house / street / …)
}
export interface GeocodeSuggestResponse {
items: GeocodeSuggestion[];
}