feat(tradein/v2): CacheView history + PDF + quota + address autocomplete (FE-5) #2052
9 changed files with 473 additions and 47 deletions
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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ч свежий · 18–24ч устаревает · >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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue