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
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:
parent
27868a4d2e
commit
3a2b4ba825
7 changed files with 168 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 эт.",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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=) ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue