AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
5 changed files with 45 additions and 12 deletions
Showing only changes of commit 14693d8dce - Show all commits

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

View File

@ -57,6 +57,8 @@ const BG_CHANGED = '#fff6e5';
/** «394.27» → «394,27%»; строка не переводится в число. */
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана';
const showLine = (line: string): string => articleCodes(line).join(' / ');
const sideSuffix = (row: TableRow) => {
@ -106,15 +108,26 @@ export function CorrectionTable(props: Props) {
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 =>
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 => {
if (row.kind === '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) {
case 'include':
return withHint(
@ -127,6 +140,7 @@ export function CorrectionTable(props: Props) {
</Tooltip>
)}
</span>,
readOnlyHint,
);
case 'd_entity':
return a.company_1.name;
@ -145,9 +159,9 @@ export function CorrectionTable(props: Props) {
case 'k_amount':
return format(a.side_2.amount);
case 'dev_percent':
return <span style={red}>{percentText(a.deviation_percent)}</span>;
return redIf(percentText(a.deviation_percent));
case 'dev_amount':
return <span style={red}>{format(a.deviation_amount)}</span>;
return redIf(format(a.deviation_amount));
case 'c_d_line':
return lines(a.correction?.debit, (l) => l.line_code);
case 'c_d_amount':
@ -254,7 +268,7 @@ export function CorrectionTable(props: Props) {
width,
ellipsis,
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) => (
@ -323,6 +337,7 @@ export function CorrectionTable(props: Props) {
type='text'
size='small'
aria-label={expanded.has(row.key) ? 'Свернуть' : 'Раскрыть'}
aria-expanded={expanded.has(row.key)}
icon={expanded.has(row.key) ? <DownOutlined /> : <RightOutlined />}
onClick={() => onToggle(row.key)}
/>

View File

@ -3,7 +3,7 @@ import { Input, Popover, Select } from 'antd';
import type { ReactNode } from 'react';
type Props = {
label: ReactNode;
label: string;
/** Фильтр задан — значок выделяется. */
active: boolean;
children: ReactNode;
@ -18,7 +18,7 @@ export function HeaderFilter({ label, active, children }: Props) {
{/* стопим всплытие, чтобы клик не запускал выбор заголовка */}
<button
type='button'
aria-label='Фильтр'
aria-label={`Фильтр: ${label}`}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}>
{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 { getCorrectionTable } from '../../services/api/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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// счётчик живёт всё время жизни компонента и не вызывает перерисовок
const requests = useRef(latestOnly()).current;
// счётчик живёт всё время жизни компонента и не вызывает перерисовок; useState(fn) создаёт его один раз
const [requests] = useState(latestOnly);
const reload = useCallback(() => {
const id = requests.next();
@ -50,6 +50,9 @@ export function useCorrectionTable(versionId: string): {
}, [versionId, requests]);
useEffect(() => {
// другая версия — старая таблица и ошибка не должны мелькать до прихода новой
setTable(null);
setError(null);
reload();
// после размонтирования ответ уже не нужен: делаем любой номер устаревшим
return () => {