feat(tradein/v2): CacheView history + PDF + quota + address autocomplete (FE-5) #2052

Merged
lekss361 merged 1 commit from feat/tradein-v2-fe5-cache into main 2026-06-28 13:15:18 +00:00
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
// 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 { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { tokens } from "@/components/trade-in/v2/tokens";
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 type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
import type {
CacheData,
HistoryData,
SourcesData,
} from "@/components/trade-in/v2/mappers";
import {
mapAnalytics,
mapCache,
mapHistory,
mapObject,
mapReport,
@ -45,6 +48,7 @@ import type {
import { HTTPError } from "@/lib/api";
import {
useEstimate,
useEstimateHistory,
useEstimateHouseAnalytics,
useEstimateMutation,
useEstimatePlacementHistory,
@ -52,6 +56,7 @@ import {
useSalesVsListings,
useStreetDeals,
} from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
@ -372,6 +377,7 @@ function readUrlId(): string | null {
export default function TradeInV2Page() {
const router = useRouter();
const queryClient = useQueryClient();
const [nav, setNav] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
@ -383,6 +389,13 @@ export default function TradeInV2Page() {
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();
// Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null);
@ -390,6 +403,13 @@ export default function TradeInV2Page() {
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null;
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
// section via the mappers (null input) rather than blanking the page.
const streetDeals = useStreetDeals(
@ -465,6 +485,17 @@ export default function TradeInV2Page() {
() => mapSources(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
// estimate arrives async (useState reads initialValues only on mount).
@ -490,6 +521,14 @@ export default function TradeInV2Page() {
mutation.mutate(input, {
onSuccess: (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=...
router.replace(`/v2?id=${est.estimate_id}`, { scroll: false });
},
@ -587,9 +626,10 @@ export default function TradeInV2Page() {
flexDirection: "column",
}}
>
<TopNav active={nav} onNavigate={setNav} />
<TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
/>
@ -624,6 +664,7 @@ export default function TradeInV2Page() {
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
/>
)}
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />

View file

@ -1,13 +1,16 @@
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
// 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 { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */}
@ -18,7 +21,7 @@ export function CacheView() {
gap: 16,
}}
>
{cacheKpi.map((kpi) => (
{data.kpis.map((kpi) => (
<div
key={kpi.label}
style={{
@ -98,7 +101,7 @@ export function CacheView() {
<span>ИСТОЧНИКОВ</span>
<span>СТАТУС</span>
</div>
{cacheRows.map((r, i) => (
{data.rows.map((r, i) => (
<div
key={`${r.addr}-${i}`}
style={{

View file

@ -1,7 +1,11 @@
"use client";
import type { CSSProperties } from "react";
import Image from "next/image";
import { API_BASE_URL } from "@/lib/api";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import { object, report } from "./fixtures";
import type { HeroBarData } from "./mappers";
@ -9,15 +13,67 @@ import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
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 {
data?: HeroBarData;
estimateId?: string | null;
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({
data = HERO_FIXTURE,
estimateId,
onOpenInfo,
}: 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 (
<div
style={{
@ -123,37 +179,26 @@ export default function HeroBar({
</div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
{pdfHref ? (
<a
className="hero-pdf-btn"
href={pdfHref}
target="_blank"
rel="noopener noreferrer"
style={pdfBtnStyle}
>
{pdfBtnInner}
</a>
) : (
<button
type="button"
className="hero-pdf-btn"
style={{
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,
transition: "all .15s",
}}
disabled
title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
>
<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>
{pdfBtnInner}
</button>
)}
<button
type="button"
className="hero-calc-btn"

View file

@ -10,7 +10,7 @@
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
// (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 { dropdownOptions } from "./fixtures";
import {
@ -19,7 +19,9 @@ import {
REPAIR_FROM_RU,
REPAIR_RU,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import type {
GeocodeSuggestion,
HouseType,
RepairState,
TradeInEstimateInput,
@ -202,6 +204,49 @@ const addressField: CSSProperties = {
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
// the happy-path layout is pixel-unchanged.
const errorText: CSSProperties = {
@ -273,6 +318,36 @@ export default function ParamsPanel({
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
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): радиус анализа пока не выбирается (бэкенд не поддерживает) —
// дропдаун оставлен визуально, но non-functional.
const radius = "500 м";
@ -299,6 +374,7 @@ export default function ParamsPanel({
return;
}
setValidationError(null);
setSuggestOpen(false);
onSubmit?.({
address: trimmedAddress,
area_m2: areaNum,
@ -309,6 +385,8 @@ export default function ParamsPanel({
house_type: HOUSE_TYPE_FROM_RU[houseType],
repair_state: REPAIR_FROM_RU[repair],
has_balcony: balcony,
lat: coords?.lat ?? null,
lon: coords?.lon ?? null,
});
};
@ -802,16 +880,24 @@ export default function ParamsPanel({
<div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<div style={{ position: "relative" }}>
{/* TODO(FE-5): address autocomplete (Yandex / Nominatim) out of
scope here; plain controlled input for now. */}
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
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
type="text"
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) => {
if (e.key === "Enter") handleSubmit();
if (e.key === "Escape") setSuggestOpen(false);
}}
placeholder="Город, улица, дом"
autoComplete="off"
style={addressField}
/>
<svg
@ -832,6 +918,34 @@ export default function ParamsPanel({
<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" />
</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>

View file

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

View file

@ -16,6 +16,9 @@ import { navLabels, user, version } from "./fixtures";
interface TopNavProps {
active: number;
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 = {
@ -30,7 +33,11 @@ const menuItemStyle: CSSProperties = {
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);
return (
@ -336,7 +343,7 @@ export default function TopNav({ active, onNavigate }: TopNavProps) {
color: tokens.accent,
}}
>
{user.reports}
{reports}
</span>
</div>

View file

@ -26,6 +26,7 @@ import type {
AggregatedEstimate,
AnalogLot,
ConfidenceLevel,
EstimateHistoryItem,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
HouseType,
@ -41,6 +42,8 @@ import type {
AnalyticsKpi,
AxisTickX,
AxisTickY,
CacheKpi,
CacheRow,
DealRow,
DkpRow,
History,
@ -1455,3 +1458,134 @@ export function mapSources(
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";
import { useMutation, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
AggregatedEstimate,
CianPriceChangeStats,
EstimateHistoryItem,
GeocodeSuggestion,
GeocodeSuggestResponse,
HouseAnalyticsResponse,
HouseInfoForEstimate,
IMVBenchmarkResponse,
@ -17,6 +20,7 @@ import type {
} from "@/types/trade-in";
const BASE = "/api/v1/trade-in";
const GEOCODE_BASE = "/api/v1/geocode";
/**
* POST /api/v1/trade-in/estimate
@ -188,3 +192,47 @@ export function useEstimateSellTimeSensitivity(estimate_id: string | null) {
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;
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[];
}