All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m13s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI / openapi-codegen-check (pull_request) Successful in 2m20s
Волна 0 инвентаризации техдолга. МЕРА: у восьми компонентов витрины v2 проп data имел значение по умолчанию из fixtures.ts — sample-данных дизайн-макета. При сбое передачи данных компонент отрисовал бы выдуманные числа вместо пустого состояния; на платном экране оценки это неотличимо от правды. Проп стал обязательным. Цепная правка в SectionOverlay: его опциональные поля пробрасывались в теперь-обязательные пропы. Проверено: единственный потребитель — app/v2/page.tsx, всегда передаёт data явно. Отдельной preview-страницы у v2/fixtures.ts нет. Статический UI-конфиг из того же файла (лейблы, версия, опции) не тронут — он не про данные отчёта. Птица: удалены шесть осиротевших компонентов (ScoreCard, AnalysisBreadcrumb, AnalysisSidebar, MassingEconomics, UserAvatar, PticaPlaceholderPanel) — по каждому подтверждён ноль импортов по всему репозиторию, включая динамические. Подчищены два ссылающихся комментария. Проверка: tsc и eslint чисто на обоих фронтах, next build успешен, vitest Птицы 264 теста зелёные.
248 lines
8.9 KiB
TypeScript
248 lines
8.9 KiB
TypeScript
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
|
||
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
|
||
// Data via the required `data` prop (mapCache output).
|
||
|
||
import { tokens } from "./tokens";
|
||
import type { CacheData } from "./mappers";
|
||
|
||
/**
|
||
* M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
|
||
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
|
||
* • same-day → "HH:MM:SS" (e.g. "23:20:51")
|
||
* • yesterday → "вчера HH:MM"
|
||
* • older → "DD.MM.YYYY"
|
||
* The raw column mixed second-precision clock times with bare dates, so a lone
|
||
* "23:20:51" was visually indistinguishable from a date. We normalize every bare
|
||
* clock time to minute precision (HH:MM) so same-day rows read uniformly with the
|
||
* "вчера HH:MM" rows, while date rows pass through unchanged. The ISO timestamp is
|
||
* already discarded upstream (mapper not editable here), so a same-day cell stays
|
||
* a time by the column's convention: a clock value ⇒ today, a date ⇒ older.
|
||
* Missing / unparseable → «—». Pure + idempotent on its own output.
|
||
*/
|
||
function normTime(raw: string | null | undefined): string {
|
||
const s = (raw ?? "").trim();
|
||
if (!s || s === "—") return "—";
|
||
// Bare clock time "HH:MM:SS" | "HH:MM" → minute precision (drop seconds).
|
||
const clock = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
|
||
if (clock) return `${clock[1].padStart(2, "0")}:${clock[2]}`;
|
||
// "вчера HH:MM:SS" → "вчера HH:MM"; dates ("DD.MM.YYYY") have no HH:MM:SS run
|
||
// and pass through unchanged.
|
||
return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1");
|
||
}
|
||
|
||
interface CacheViewProps {
|
||
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
|
||
// mapCache output) — an omitted prop must be a TS error, not a silent
|
||
// fallback to fabricated «Предыдущие оценки» rows.
|
||
data: CacheData;
|
||
/** Row-click handler (#2420) — navigates to the full report for that
|
||
* historical estimate. Optional: leaves rows inert when absent. */
|
||
onSelectRow?: (id: string) => void;
|
||
}
|
||
|
||
export function CacheView({ data, onSelectRow }: CacheViewProps) {
|
||
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
|
||
// #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={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(3,1fr)",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{data.kpis.map((kpi) => (
|
||
<div
|
||
key={kpi.label}
|
||
style={{
|
||
background: tokens.surface.w55,
|
||
border: `1px solid ${tokens.line2}`,
|
||
borderRadius: 8,
|
||
padding: "16px 18px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 9.5,
|
||
letterSpacing: 1.5,
|
||
// C1 — key caption (labels the headline KPI value) → body2.
|
||
color: tokens.body2,
|
||
}}
|
||
>
|
||
{kpi.label}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontFamily: tokens.font.mono,
|
||
fontSize: 30,
|
||
fontWeight: 300,
|
||
color: tokens.ink2,
|
||
margin: "8px 0 5px",
|
||
}}
|
||
>
|
||
{kpi.value}
|
||
{kpi.unit && (
|
||
<>
|
||
{kpi.unit !== "%" && " "}
|
||
<span style={{ fontSize: 14, color: tokens.muted }}>
|
||
{kpi.unit}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 10, color: tokens.hint }}>{kpi.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Последние оценки table */}
|
||
<div
|
||
style={{
|
||
background: tokens.surface.w55,
|
||
border: `1px solid ${tokens.line2}`,
|
||
borderRadius: 8,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
padding: "14px 18px",
|
||
borderBottom: `1px solid ${tokens.lineSoft}`,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: tokens.ink2,
|
||
}}
|
||
>
|
||
Последние оценки
|
||
</div>
|
||
{/* M7 — real table semantics via ARIA roles over the existing grid. */}
|
||
<div role="table" aria-label="Последние оценки">
|
||
<div
|
||
role="row"
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: gridCols,
|
||
gap: 10,
|
||
padding: "9px 18px",
|
||
fontSize: 9,
|
||
letterSpacing: 1,
|
||
// C1 — column headers are primary labels → body2.
|
||
color: tokens.body2,
|
||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||
}}
|
||
>
|
||
{headers.map((h) => (
|
||
<span key={h} role="columnheader">
|
||
{h}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{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: "grid",
|
||
gridTemplateColumns: gridCols,
|
||
gap: 10,
|
||
padding: "12px 18px",
|
||
fontSize: 11,
|
||
borderBottom: `1px solid ${tokens.lineSoft3}`,
|
||
alignItems: "center",
|
||
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={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 10,
|
||
color: r.statusColor,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: r.statusColor,
|
||
}}
|
||
/>
|
||
{r.status}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|