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}
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 эт.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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=) ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue