fix(web): правки по ревью Task 4 — подсказки, очистка уведомления, доступность (#206)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d98f264f90
commit
14693d8dce
@ -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');
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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' }} />}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user