fix(web): правки по ревью Task 4 — подсказки, очистка уведомления, доступность (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:26:28 +04:00
parent d98f264f90
commit 14693d8dce
5 changed files with 45 additions and 12 deletions

View File

@ -281,6 +281,15 @@ describe('resolveLeaveAfterFetch (T7.3)', () => {
}); });
}); });
describe('reportTabNavigation: вкладка «Таблица корректировки» (#206, T4.13)', () => {
it('шахматка → таблица корректировки: меняется последний сегмент, запись истории заменяется', () => {
expect(reportTabNavigation('/groups/g/versions/v/chess', 'chess', 'correction')).toEqual({
to: '/groups/g/versions/v/correction',
options: { replace: true },
});
});
});
describe('reportTabNavigation: адрес без search (T7.4)', () => { describe('reportTabNavigation: адрес без search (T7.4)', () => {
it('?holder=&missing= не переносятся на другую вкладку', () => { it('?holder=&missing= не переносятся на другую вкладку', () => {
expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess'); expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess');

View File

@ -28,6 +28,9 @@ function readOnlyHint(t: CorrectionTableOut): string | null {
return READ_ONLY_HINTS[t.read_only_reason]; return READ_ONLY_HINTS[t.read_only_reason];
} }
/** Решения по строкам и окно корректировки подключаются в Task 5–6: тогда флаг становится true (одна правка). */
const ROW_ACTIONS_READY = false;
const LOAD_ERROR_KEY = 'corrections-load-error'; const LOAD_ERROR_KEY = 'corrections-load-error';
type Props = { versionId: string; card?: VersionCard | null }; type Props = { versionId: string; card?: VersionCard | null };
@ -61,6 +64,9 @@ export function CorrectionContent({ versionId, card = null }: Props) {
}); });
}, [error, notification, reload]); }, [error, notification, reload]);
// ушли с вкладки — уведомление об ошибке закрываем, оно относится к этому экрану
useEffect(() => () => notification.destroy(LOAD_ERROR_KEY), [notification]);
useEffect(() => { useEffect(() => {
const onChange = () => { const onChange = () => {
if (!document.fullscreenElement) setFullscreen(false); if (!document.fullscreenElement) setFullscreen(false);
@ -134,7 +140,7 @@ export function CorrectionContent({ versionId, card = null }: Props) {
rules={table.rules} rules={table.rules}
editable={table.editable} editable={table.editable}
readOnlyHint={readOnlyHint(table)} readOnlyHint={readOnlyHint(table)}
actionsEnabled={false} actionsEnabled={ROW_ACTIONS_READY && table.editable}
actions={actions} actions={actions}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}

View File

@ -57,6 +57,8 @@ const BG_CHANGED = '#fff6e5';
/** «394.27» → «394,27%»; строка не переводится в число. */ /** «394.27» → «394,27%»; строка не переводится в число. */
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана';
const showLine = (line: string): string => articleCodes(line).join(' / '); const showLine = (line: string): string => articleCodes(line).join(' / ');
const sideSuffix = (row: TableRow) => { const sideSuffix = (row: TableRow) => {
@ -106,15 +108,26 @@ export function CorrectionTable(props: Props) {
else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' }); else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' });
}; };
const withHint = (node: ReactNode) => (readOnlyHint ? <Tooltip title={readOnlyHint}><span>{node}</span></Tooltip> : node); // почему контрол неактивен: сервер закрыл правки — его причина; иначе действие ещё не подключено в этой версии экрана
const inactiveHint = readOnlyHint ?? (actionsEnabled ? null : UNAVAILABLE_HINT);
const withHint = (node: ReactNode, hint: string | null = inactiveHint) =>
hint ? (
<Tooltip title={hint}>
<span>{node}</span>
</Tooltip>
) : (
node
);
const lines = (list: CorrectionLineOut[] | undefined, pick: (l: CorrectionLineOut) => string): ReactNode => const lines = (list: CorrectionLineOut[] | undefined, pick: (l: CorrectionLineOut) => string): ReactNode =>
list && list.length > 0 ? list.map((l) => <div key={`${l.line_code}-${l.amount}`}>{pick(l)}</div>) : '—'; list && list.length > 0 ? list.map((l, i) => <div key={`${i}-${l.line_code}-${l.amount}`}>{pick(l)}</div>) : '—';
const cell = (key: ColKey, row: TableRow): ReactNode => { const cell = (key: ColKey, row: TableRow): ReactNode => {
if (row.kind === 'aggregate') { if (row.kind === 'aggregate') {
const a = row.aggregate; const a = row.aggregate;
const red = a.exceeds_threshold ? { color: COLOR_OVER } : undefined; // красным — только значение выше порога; прочерк («нет значения») не красим
const redIf = (text: string): ReactNode =>
a.exceeds_threshold && text !== '—' ? <span style={{ color: COLOR_OVER }}>{text}</span> : text;
switch (key) { switch (key) {
case 'include': case 'include':
return withHint( return withHint(
@ -127,6 +140,7 @@ export function CorrectionTable(props: Props) {
</Tooltip> </Tooltip>
)} )}
</span>, </span>,
readOnlyHint,
); );
case 'd_entity': case 'd_entity':
return a.company_1.name; return a.company_1.name;
@ -145,9 +159,9 @@ export function CorrectionTable(props: Props) {
case 'k_amount': case 'k_amount':
return format(a.side_2.amount); return format(a.side_2.amount);
case 'dev_percent': case 'dev_percent':
return <span style={red}>{percentText(a.deviation_percent)}</span>; return redIf(percentText(a.deviation_percent));
case 'dev_amount': case 'dev_amount':
return <span style={red}>{format(a.deviation_amount)}</span>; return redIf(format(a.deviation_amount));
case 'c_d_line': case 'c_d_line':
return lines(a.correction?.debit, (l) => l.line_code); return lines(a.correction?.debit, (l) => l.line_code);
case 'c_d_amount': case 'c_d_amount':
@ -254,7 +268,7 @@ export function CorrectionTable(props: Props) {
width, width,
ellipsis, ellipsis,
render: (_, row) => cell(key, row), render: (_, row) => cell(key, row),
onCell: (row) => ({ style: { background: rowBg(row) ?? tint(key), opacity: row.kind === 'entry' && row.entry.excluded ? 0.5 : 1 } }), onCell: (row) => ({ style: { background: rowBg(row) ?? tint(key), opacity: (row.kind === 'entry' || row.kind === 'unallocated') && row.entry.excluded ? 0.5 : 1 } }),
}); });
const filtersTitle = (label: string, active: boolean, body: ReactNode) => ( const filtersTitle = (label: string, active: boolean, body: ReactNode) => (
@ -323,6 +337,7 @@ export function CorrectionTable(props: Props) {
type='text' type='text'
size='small' size='small'
aria-label={expanded.has(row.key) ? 'Свернуть' : 'Раскрыть'} aria-label={expanded.has(row.key) ? 'Свернуть' : 'Раскрыть'}
aria-expanded={expanded.has(row.key)}
icon={expanded.has(row.key) ? <DownOutlined /> : <RightOutlined />} icon={expanded.has(row.key) ? <DownOutlined /> : <RightOutlined />}
onClick={() => onToggle(row.key)} onClick={() => onToggle(row.key)}
/> />

View File

@ -3,7 +3,7 @@ import { Input, Popover, Select } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
type Props = { type Props = {
label: ReactNode; label: string;
/** Фильтр задан — значок выделяется. */ /** Фильтр задан — значок выделяется. */
active: boolean; active: boolean;
children: ReactNode; children: ReactNode;
@ -18,7 +18,7 @@ export function HeaderFilter({ label, active, children }: Props) {
{/* стопим всплытие, чтобы клик не запускал выбор заголовка */} {/* стопим всплытие, чтобы клик не запускал выбор заголовка */}
<button <button
type='button' type='button'
aria-label='Фильтр' aria-label={`Фильтр: ${label}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}> style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}>
{active ? <FilterFilled style={{ color: '#2a8c3a' }} /> : <FilterOutlined style={{ color: '#98a2b3' }} />} {active ? <FilterFilled style={{ color: '#2a8c3a' }} /> : <FilterOutlined style={{ color: '#98a2b3' }} />}

View File

@ -1,6 +1,6 @@
// Загрузка таблицы корректировок версии. Применяется только ответ последнего запроса: аналитик может быстро щёлкнуть // Загрузка таблицы корректировок версии. Применяется только ответ последнего запроса: аналитик может быстро щёлкнуть
// два флажка подряд, и ответ на первый клик не должен перетереть свежую таблицу. // два флажка подряд, и ответ на первый клик не должен перетереть свежую таблицу.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { getCorrectionTable } from '../../services/api/corrections'; import { getCorrectionTable } from '../../services/api/corrections';
import type { CorrectionAggregateOut, CorrectionTableOut } from '../../types/corrections'; import type { CorrectionAggregateOut, CorrectionTableOut } from '../../types/corrections';
@ -29,8 +29,8 @@ export function useCorrectionTable(versionId: string): {
const [table, setTable] = useState<CorrectionTableOut | null>(null); const [table, setTable] = useState<CorrectionTableOut | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// счётчик живёт всё время жизни компонента и не вызывает перерисовок // счётчик живёт всё время жизни компонента и не вызывает перерисовок; useState(fn) создаёт его один раз
const requests = useRef(latestOnly()).current; const [requests] = useState(latestOnly);
const reload = useCallback(() => { const reload = useCallback(() => {
const id = requests.next(); const id = requests.next();
@ -50,6 +50,9 @@ export function useCorrectionTable(versionId: string): {
}, [versionId, requests]); }, [versionId, requests]);
useEffect(() => { useEffect(() => {
// другая версия — старая таблица и ошибка не должны мелькать до прихода новой
setTable(null);
setError(null);
reload(); reload();
// после размонтирования ответ уже не нужен: делаем любой номер устаревшим // после размонтирования ответ уже не нужен: делаем любой номер устаревшим
return () => { return () => {