feat(tradein/v2): кликабельные строки + параметры в оверлее «Предыдущие оценки» (#2424)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 1m52s

This commit is contained in:
lekss361 2026-07-04 13:49:18 +00:00
parent 27868a4d2e
commit 3a2b4ba825
7 changed files with 168 additions and 52 deletions

View file

@ -920,6 +920,14 @@ export default function TradeInV2Page() {
analytics={analyticsViewData} analytics={analyticsViewData}
sources={sourcesData} sources={sourcesData}
cache={cacheData} cache={cacheData}
onSelectEstimate={(id) => {
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
// above (line ~649); this file uses replace() consistently for
// every /v2?id= transition, so we follow that convention here
// too rather than introducing the file's only push() call.
router.replace(`/v2?id=${id}`, { scroll: false });
setNav(0);
}}
/> />
)} )}
<LocationDrawer <LocationDrawer

View file

@ -33,15 +33,35 @@ function normTime(raw: string | null | undefined): string {
return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1"); return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1");
} }
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) { interface CacheViewProps {
data?: CacheData;
/** Row-click handler (#2420) navigates to the full report for that
* historical estimate. Optional: unwired/storybook usage leaves rows inert. */
onSelectRow?: (id: string) => void;
}
export function CacheView({ data = FIXTURE_CACHE, onSelectRow }: CacheViewProps) {
const showSrc = data.rows.some((r) => r.src && r.src !== "—"); const showSrc = data.rows.some((r) => r.src && r.src !== "—");
const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr"; // #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
const gridCols = showSrc
? "2.2fr 1.3fr 1fr 1fr 1fr"
: "2.2fr 1.3fr 1fr 1fr";
// M7 — header cells, in column order, so every data column has a header. // M7 — header cells, in column order, so every data column has a header.
const headers = showSrc const headers = showSrc
? ["АДРЕС", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"] ? ["АДРЕС", "ПАРАМЕТРЫ", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"]
: ["АДРЕС", "ВРЕМЯ", "СТАТУС"]; : ["АДРЕС", "ПАРАМЕТРЫ", "ВРЕМЯ", "СТАТУС"];
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}> <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* #2420 clickable-row affordance: hover tint + visible focus ring,
mirroring .sv-row:hover (SourcesView) / focus-visible rings
(LeadForm/ParamsPanel). Only meaningful when onSelectRow is wired. */}
<style>{`
.tiv2-cache-row:hover { background: ${tokens.surface.w50}; }
.tiv2-cache-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px ${tokens.accentDeep};
}
`}</style>
{/* 3 KPI cards */} {/* 3 KPI cards */}
<div <div
style={{ style={{
@ -136,59 +156,91 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
</span> </span>
))} ))}
</div> </div>
{data.rows.map((r, i) => ( {data.rows.map((r, i) => {
<div // #2420 — clicking/activating a row navigates to the full report
key={`${r.addr}-${i}`} // for that historical estimate (SectionOverlay -> page.tsx). Rows
role="row" // stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow
style={{ // не задан (unwired/storybook usage), меняя только tabIndex/handlers
display: "grid", // — the grid structure itself is unchanged.
gridTemplateColumns: gridCols, const clickable = Boolean(onSelectRow);
gap: 10, function activate() {
padding: "12px 18px", onSelectRow?.(r.id);
fontSize: 11, }
borderBottom: `1px solid ${tokens.lineSoft3}`, return (
alignItems: "center", <div
}} key={`${r.addr}-${i}`}
> role="row"
<span role="cell" style={{ color: tokens.body }}> className={clickable ? "tiv2-cache-row" : undefined}
{r.addr} tabIndex={clickable ? 0 : undefined}
</span> onClick={clickable ? activate : undefined}
<span onKeyDown={
role="cell" clickable
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }} ? (e) => {
> if (e.key === "Enter" || e.key === " ") {
{normTime(r.time)} if (e.key === " ") e.preventDefault();
</span> activate();
{showSrc && ( }
<span }
role="cell" : undefined
style={{ fontFamily: tokens.font.mono, color: tokens.muted }} }
> aria-label={clickable ? `Открыть отчёт: ${r.addr}` : undefined}
{r.src}
</span>
)}
<span
role="cell"
style={{ style={{
display: "flex", display: "grid",
gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center", alignItems: "center",
gap: 6, cursor: clickable ? "pointer" : undefined,
fontSize: 10,
color: r.statusColor,
}} }}
> >
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</span>
<span role="cell" style={{ color: tokens.body }}>
{r.params}
</span>
<span <span
role="cell"
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
{normTime(r.time)}
</span>
{showSrc && (
<span
role="cell"
style={{
fontFamily: tokens.font.mono,
color: tokens.muted,
}}
>
{r.src}
</span>
)}
<span
role="cell"
style={{ style={{
width: 7, display: "flex",
height: 7, alignItems: "center",
borderRadius: "50%", gap: 6,
background: r.statusColor, fontSize: 10,
color: r.statusColor,
}} }}
/> >
{r.status} <span
</span> style={{
</div> width: 7,
))} height: 7,
borderRadius: "50%",
background: r.statusColor,
}}
/>
{r.status}
</span>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>

View file

@ -28,6 +28,10 @@ interface SectionOverlayProps {
sources?: SourcesData; sources?: SourcesData;
analytics?: Analytics; analytics?: Analytics;
cache?: CacheData; cache?: CacheData;
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
// that historical estimate. Optional (threaded straight to CacheView's own
// optional onSelectRow — unwired usage leaves rows inert).
onSelectEstimate?: (id: string) => void;
} }
// Panel geometry (relative to the artboard). Kept as a constant so the click // Panel geometry (relative to the artboard). Kept as a constant so the click
@ -43,6 +47,7 @@ export default function SectionOverlay({
sources, sources,
analytics, analytics,
cache, cache,
onSelectEstimate,
}: SectionOverlayProps) { }: SectionOverlayProps) {
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused` // Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
// keeps the element that had focus before the overlay opened so we can restore // keeps the element that had focus before the overlay opened so we can restore
@ -316,7 +321,9 @@ export default function SectionOverlay({
{active === 3 && ( {active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} /> <AnalyticsView data={analytics} onNavigate={onNavigate} />
)} )}
{active === 4 && <CacheView data={cache} />} {active === 4 && (
<CacheView data={cache} onSelectRow={onSelectEstimate} />
)}
</div> </div>
</div> </div>
</> </>

View file

@ -536,46 +536,58 @@ export const analytics: Analytics = {
export const cacheRows: CacheRow[] = [ export const cacheRows: CacheRow[] = [
{ {
id: "fixture-1",
addr: "ул. Малышева, 30", addr: "ул. Малышева, 30",
time: "12:45:02", time: "12:45:02",
src: "3 / 7", src: "3 / 7",
status: "свежий", status: "свежий",
statusColor: tokens.success, statusColor: tokens.success,
params: "2 комн · 54 м² · 5/9 эт.",
}, },
{ {
id: "fixture-2",
addr: "ул. Куйбышева, 48", addr: "ул. Куйбышева, 48",
time: "12:31:50", time: "12:31:50",
src: "4 / 7", src: "4 / 7",
status: "свежий", status: "свежий",
statusColor: tokens.success, statusColor: tokens.success,
params: "3 комн · 72 м² · 12/16 эт.",
}, },
{ {
id: "fixture-3",
addr: "ул. Академика Ландау, 35", addr: "ул. Академика Ландау, 35",
time: "11:58:14", time: "11:58:14",
src: "2 / 7", src: "2 / 7",
status: "свежий", status: "свежий",
statusColor: tokens.success, statusColor: tokens.success,
params: "1 комн · 38 м²",
}, },
{ {
id: "fixture-4",
addr: "ул. 8 Марта, 120", addr: "ул. 8 Марта, 120",
time: "09:12:40", time: "09:12:40",
src: "3 / 7", src: "3 / 7",
status: "устаревает", status: "устаревает",
statusColor: tokens.warn, statusColor: tokens.warn,
params: "студия · 24 м² · 3/5 эт.",
}, },
{ {
id: "fixture-5",
addr: "пр. Ленина, 5", addr: "пр. Ленина, 5",
time: "вчера 18:04", time: "вчера 18:04",
src: "5 / 7", src: "5 / 7",
status: "устарел", status: "устарел",
statusColor: tokens.danger, statusColor: tokens.danger,
params: "2 комн · 61,5 м²",
}, },
{ {
id: "fixture-6",
addr: "ул. Белинского, 86", addr: "ул. Белинского, 86",
time: "вчера 14:22", time: "вчера 14:22",
src: "2 / 7", src: "2 / 7",
status: "устарел", status: "устарел",
statusColor: tokens.danger, statusColor: tokens.danger,
params: "4 комн · 95 м² · 7/10 эт.",
}, },
]; ];

View file

@ -2002,6 +2002,30 @@ function fmtCacheTime(iso: string | null | undefined): string {
return fmtDate(iso); return fmtDate(iso);
} }
/**
* #2420 ПАРАМЕТРЫ column: "2 комн · 54 м²" / "2 комн · 54 м² · 5/9 эт.".
* rooms + area are independent (each omitted if unknown); floor segment ONLY
* appended when BOTH floor and total_floors are known (a lone floor without a
* total is misleading omitted entirely, no "5/? эт."). "—" when neither
* rooms nor area is available (mirrors `addr: h.address ? … : "—"` above).
*/
function cacheParams(
rooms: number | null,
areaM2: string | null,
floor: number | null,
totalFloors: number | null,
): string {
const area = areaM2 != null ? Number(areaM2) : null;
const parts: string[] = [];
if (rooms != null) parts.push(rooms <= 0 ? "студия" : `${rooms} комн`);
if (area != null && Number.isFinite(area)) parts.push(fmtArea(area));
if (parts.length === 0) return "—";
if (floor != null && totalFloors != null) {
parts.push(`${floor}/${totalFloors} эт.`);
}
return parts.join(" · ");
}
/** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */ /** Свежесть оценки vs 24ч TTL кэша → подпись + token-цвет точки/текста. */
function cacheStatus(iso: string | null | undefined): { function cacheStatus(iso: string | null | undefined): {
status: string; status: string;
@ -2024,7 +2048,7 @@ function cacheStatus(iso: string | null | undefined): {
/** /**
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на * 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
* фронте из per-user /history (null пустая таблица + KPI с "—"). * фронте из per-user /history (null пустая таблица + KPI с "—").
* rows addr/время/статус по каждой строке (src всегда "—", TODO BE-1). * rows addr/время/статус/params по каждой строке (src всегда "—", TODO BE-1).
* kpis ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн). * kpis ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 внутренняя ops-метрика). * «ПОВТОРНЫЕ АДРЕСА» убран (§M8 внутренняя ops-метрика).
*/ */
@ -2034,6 +2058,7 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const rows: CacheRow[] = list.map((h) => { const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at); const { status, statusColor } = cacheStatus(h.created_at);
return { return {
id: h.id,
// §M8 — de-glue metro/address runs the same way as the analog/deal rows // §M8 — de-glue metro/address runs the same way as the analog/deal rows
// (these are the user's own past-estimate addresses, often geocoded with // (these are the user's own past-estimate addresses, often geocoded with
// metro proximity jammed on with no separators). // metro proximity jammed on with no separators).
@ -2042,6 +2067,7 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно src: "—", // TODO BE-1: /history без sources_used → N/7 недоступно
status, status,
statusColor, statusColor,
params: cacheParams(h.rooms, h.area_m2, h.floor, h.total_floors),
}; };
}); });

View file

@ -242,12 +242,16 @@ export interface Analytics {
// ---- OVERLAY: ПРЕДЫДУЩИЕ ОЦЕНКИ (CACHE) ----------------------------------- // ---- OVERLAY: ПРЕДЫДУЩИЕ ОЦЕНКИ (CACHE) -----------------------------------
export interface CacheRow { export interface CacheRow {
/** Estimate UUID — needed to navigate to the full report on row click (#2420). */
id: string;
addr: string; addr: string;
time: string; time: string;
src: string; src: string;
status: string; status: string;
/** Status dot/text colour (success / warn / danger token value). */ /** Status dot/text colour (success / warn / danger token value). */
statusColor: string; statusColor: string;
/** Pre-formatted compact summary, e.g. "2 комн · 54 м² · 5/9 эт." (#2420). */
params: string;
} }
export interface CacheKpi { export interface CacheKpi {

View file

@ -423,6 +423,13 @@ export interface EstimateHistoryItem {
confidence: ConfidenceLevel; confidence: ConfidenceLevel;
n_analogs: number; n_analogs: number;
created_at: string; // ISO datetime created_at: string; // ISO datetime
// #2420 — вернулись в GET /history наравне с остальными параметрами оценки.
floor: number | null;
total_floors: number | null;
year_built: number | null;
house_type: string | null;
repair_state: string | null;
has_balcony: boolean | null;
} }
// ── Geocode suggest (endpoint: GET /geocode/suggest?q=&limit=) ── // ── Geocode suggest (endpoint: GET /geocode/suggest?q=&limit=) ──