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}
sources={sourcesData}
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

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");
}
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 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.
const headers = showSrc
? ["АДРЕС", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"]
: ["АДРЕС", "ВРЕМЯ", "СТАТУС"];
? ["АДРЕС", "ПАРАМЕТРЫ", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"]
: ["АДРЕС", "ПАРАМЕТРЫ", "ВРЕМЯ", "СТАТУС"];
return (
<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 */}
<div
style={{
@ -136,59 +156,91 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
</span>
))}
</div>
{data.rows.map((r, i) => (
<div
key={`${r.addr}-${i}`}
role="row"
style={{
display: "grid",
gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
}}
>
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</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"
{data.rows.map((r, i) => {
// #2420 — clicking/activating a row navigates to the full report
// for that historical estimate (SectionOverlay -> page.tsx). Rows
// stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow
// не задан (unwired/storybook usage), меняя только tabIndex/handlers
// — the grid structure itself is unchanged.
const clickable = Boolean(onSelectRow);
function activate() {
onSelectRow?.(r.id);
}
return (
<div
key={`${r.addr}-${i}`}
role="row"
className={clickable ? "tiv2-cache-row" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? activate : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === " ") e.preventDefault();
activate();
}
}
: undefined
}
aria-label={clickable ? `Открыть отчёт: ${r.addr}` : undefined}
style={{
display: "flex",
display: "grid",
gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
gap: 6,
fontSize: 10,
color: r.statusColor,
cursor: clickable ? "pointer" : undefined,
}}
>
<span role="cell" style={{ color: tokens.body }}>
{r.addr}
</span>
<span role="cell" style={{ color: tokens.body }}>
{r.params}
</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={{
width: 7,
height: 7,
borderRadius: "50%",
background: r.statusColor,
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 10,
color: r.statusColor,
}}
/>
{r.status}
</span>
</div>
))}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: r.statusColor,
}}
/>
{r.status}
</span>
</div>
);
})}
</div>
</div>
</div>

View file

@ -28,6 +28,10 @@ interface SectionOverlayProps {
sources?: SourcesData;
analytics?: Analytics;
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
@ -43,6 +47,7 @@ export default function SectionOverlay({
sources,
analytics,
cache,
onSelectEstimate,
}: SectionOverlayProps) {
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
// keeps the element that had focus before the overlay opened so we can restore
@ -316,7 +321,9 @@ export default function SectionOverlay({
{active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} />
)}
{active === 4 && <CacheView data={cache} />}
{active === 4 && (
<CacheView data={cache} onSelectRow={onSelectEstimate} />
)}
</div>
</div>
</>

View file

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

View file

@ -2002,6 +2002,30 @@ function fmtCacheTime(iso: string | null | undefined): string {
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-цвет точки/текста. */
function cacheStatus(iso: string | null | undefined): {
status: string;
@ -2024,7 +2048,7 @@ function cacheStatus(iso: string | null | undefined): {
/**
* 07 ПРЕДЫДУЩИЕ ОЦЕНКИ: таблица последних оценок + 3 KPI, всё посчитано на
* фронте из per-user /history (null пустая таблица + KPI с "—").
* rows addr/время/статус по каждой строке (src всегда "—", TODO BE-1).
* rows addr/время/статус/params по каждой строке (src всегда "—", TODO BE-1).
* kpis ВСЕГО ОЦЕНОК (length) · СРЕДНЯЯ ЦЕНА (avg median_price, млн).
* «ПОВТОРНЫЕ АДРЕСА» убран (§M8 внутренняя ops-метрика).
*/
@ -2034,6 +2058,7 @@ export function mapCache(history: EstimateHistoryItem[] | null): CacheData {
const rows: CacheRow[] = list.map((h) => {
const { status, statusColor } = cacheStatus(h.created_at);
return {
id: h.id,
// §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
// 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 недоступно
status,
statusColor,
params: cacheParams(h.rooms, h.area_m2, h.floor, h.total_floors),
};
});

View file

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

View file

@ -423,6 +423,13 @@ export interface EstimateHistoryItem {
confidence: ConfidenceLevel;
n_analogs: number;
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=) ──