feat(web): вкладка «Таблица корректировки» — таблица и панель (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:22:24 +04:00
parent ec6ece127f
commit d98f264f90
10 changed files with 946 additions and 7 deletions

View File

@ -185,3 +185,6 @@
| 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` |
| 28.10.2026 | R-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` |
| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк |
| 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 |
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями |

View File

@ -16,6 +16,7 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent';
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
function ReportContent() {
@ -31,6 +32,8 @@ function ReportContent() {
osv: <OsvContent card={card} onSummaryChange={setOsvSummary} />,
chess: <ChessContent card={card} />,
correction: <CorrectionContent versionId={version_id ?? ''} card={card} />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
@ -61,7 +64,7 @@ function ReportContent() {
}, [version_id]);
useEffect(() => {
const isValidTab = !tab || tab === 'osv' || tab === 'chess';
const isValidTab = !tab || tab === 'osv' || tab === 'chess' || tab === 'correction';
if (!isValidTab) {
const pathParts = pathname.split('/');

View File

@ -17,12 +17,7 @@ import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
{ key: 'chess', label: 'Шахматка' },
{
key: 'correction',
label: 'Таблица корректировки',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{ key: 'correction', label: 'Таблица корректировки' },
{
key: 'goodwill',
label: 'Гудвилл',

View File

@ -0,0 +1,199 @@
// Вкладка «Таблица корректировки»: загрузка, состояния, панель и таблица. Правки (окно, решения по строкам) — в следующих задачах.
import { App, Button, ConfigProvider, Spin } from 'antd';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns';
import { exportCorrectionTable } from '../../services/api/corrections';
import type { VersionCard } from '../../types/consolidations/version_members';
import type { CorrectionTableOut, ReadOnlyReason } from '../../types/corrections';
import { saveBlob, xlsxFilename } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification';
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
import { CorrectionTable, type RowActions } from './CorrectionTable';
import { CorrectionToolbar } from './CorrectionToolbar';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel';
import { useCorrectionTable } from './useCorrectionTable';
/** Подсказки для режима «только чтение» (дословно из спеки). */
const READ_ONLY_HINTS: Record<ReadOnlyReason, string> = {
VERSION_NOT_EDITABLE: 'Версия закрыта для изменений',
LOCK_REQUIRED: 'Возьмите консолидацию в работу',
LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь',
};
/** Подсказка «почему только чтение»; правки открыты или причина не названа — `null`. */
function readOnlyHint(t: CorrectionTableOut): string | null {
if (t.editable || !t.read_only_reason) return null;
return READ_ONLY_HINTS[t.read_only_reason];
}
const LOAD_ERROR_KEY = 'corrections-load-error';
type Props = { versionId: string; card?: VersionCard | null };
export function CorrectionContent({ versionId, card = null }: Props) {
const { table, loading, error, reload } = useCorrectionTable(versionId);
const { notification } = App.useApp();
const notify = useNotificationApi();
// по умолчанию — «тыс. руб.», 2 знака, как в дизайне
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay({ unit: 'тыс. руб.', precision: 2 });
const [filters, setFilters] = useState<TableFilters>(EMPTY_FILTERS);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS });
const [exporting, setExporting] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
useEffect(() => {
if (!error) {
notification.destroy(LOAD_ERROR_KEY);
return;
}
notification.error({
key: LOAD_ERROR_KEY,
title: 'Ошибка',
description: error,
placement: 'bottomRight',
duration: 0,
btn: <Button size='small' onClick={reload}>Повторить</Button>,
});
}, [error, notification, reload]);
useEffect(() => {
const onChange = () => {
if (!document.fullscreenElement) setFullscreen(false);
};
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = async () => {
if (!boxRef.current) return;
try {
if (!document.fullscreenElement) {
await boxRef.current.requestFullscreen();
setFullscreen(true);
} else {
await document.exitFullscreen();
setFullscreen(false);
}
} catch (e) {
notify.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' });
}
};
const download = async () => {
setExporting(true);
try {
const { blob, filename } = await exportCorrectionTable(versionId);
const info = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
saveBlob(blob, filename ?? xlsxFilename('Таблица_корректировки', info));
} catch (e) {
notify.error({ description: e instanceof ApiError ? e.message : 'Не удалось выгрузить таблицу' });
} finally {
setExporting(false);
}
};
// фильтры, строки и «Итого» пересчитываются только при смене таблицы, фильтров или раскрытия
const view = useMemo(() => (table ? filterTable(table, filters) : null), [table, filters]);
const rows = useMemo(() => (view ? buildRows(view, expanded) : []), [view, expanded]);
const totals = useMemo(() => (view && view.aggregates.length > 0 ? visibleTotals(view.aggregates) : null), [view]);
const lines = useMemo(() => (table ? lineOptions(table) : []), [table]);
const toggle = (key: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// заглушки: окно корректировки и решения по строкам подключают следующие задачи плана
const actions: RowActions = {
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }),
onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }),
};
const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0;
const emptyText = noData ? 'Внутригрупповых остатков между компаниями периметра нет' : 'Нет записей для выбранных фильтров';
let body: ReactNode;
if (table) {
body = (
<Spin spinning={loading}>
<CorrectionTable
rows={rows}
totals={totals}
format={format}
expanded={expanded}
onToggle={toggle}
rules={table.rules}
editable={table.editable}
readOnlyHint={readOnlyHint(table)}
actionsEnabled={false}
actions={actions}
filters={filters}
onFiltersChange={setFilters}
lineOptions={lines}
visibleKeys={visibleKeys as ColKey[]}
scrollY={fullscreen ? 'calc(100vh - 190px)' : 'calc(100vh - 360px)'}
emptyText={emptyText}
/>
</Spin>
);
} else if (loading) {
body = (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spin />
</div>
);
} else {
body = (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: 48 }}>
<span>{error ?? 'Таблица не загружена'}</span>
<Button onClick={reload}>Повторить</Button>
</div>
);
}
return (
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
<div
ref={boxRef}
style={{
background: '#fff',
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 12,
flex: 1,
minHeight: 0,
height: fullscreen ? '100vh' : undefined,
overflow: 'hidden',
}}>
<CorrectionToolbar
rules={table?.rules ?? []}
filters={filters}
onFiltersChange={setFilters}
onReset={() => setFilters(EMPTY_FILTERS)}
unit={unit}
onUnitChange={setUnit}
precision={precision}
onPrecisionChange={setPrecision}
visibleKeys={visibleKeys as ColKey[]}
onVisibleKeysChange={setVisibleKeys}
onExport={download}
exporting={exporting}
exportDisabled={!table}
fullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
/>
{body}
</div>
</ConfigProvider>
);
}

View File

@ -0,0 +1,398 @@
// Таблица корректировок (только отображение): строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого».
// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`.
import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import type { ColumnGroupType, ColumnType } from 'antd/es/table';
import { type ReactNode, useMemo } from 'react';
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
import { COLUMN_DEFS, type ColKey } from './columns';
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
import type { visibleTotals } from './tableModel';
/** Действия над строками; их подключают задачи окна корректировки и решений по строкам. */
export type RowActions = {
onCorrect(a: CorrectionAggregateOut): void;
onRevoke(a: CorrectionAggregateOut): void;
onAllocate(body: AllocationIn): void;
};
type Props = {
rows: TableRow[];
totals: ReturnType<typeof visibleTotals> | null;
/** Форматтер сумм (единицы и знаки из панели). */
format: (raw: string | null) => string;
expanded: ReadonlySet<string>;
onToggle: (key: string) => void;
rules: CorrectionRuleOut[];
/** Сервер разрешил правки. */
editable: boolean;
/** Подсказка, почему правки закрыты; `null` — открыты. */
readOnlyHint: string | null;
/** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */
actionsEnabled: boolean;
actions: RowActions;
filters: TableFilters;
onFiltersChange: (next: TableFilters) => void;
lineOptions: string[];
visibleKeys: ColKey[];
/** Высота области прокрутки таблицы. */
scrollY: string;
emptyText: string;
};
const DEVIATION_OPTIONS: { value: DeviationFilter; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'over', label: '≥ 5 %' },
{ value: 'under', label: '< 5 %' },
];
const COLOR_OVER = '#d92d20';
const BG_DEBIT = '#f4faf5';
const BG_CREDIT = '#f6f8fc';
const BG_UNALLOCATED = '#f2f4f7';
const BG_CHANGED = '#fff6e5';
/** «394.27» → «394,27%»; строка не переводится в число. */
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
const showLine = (line: string): string => articleCodes(line).join(' / ');
const sideSuffix = (row: TableRow) => {
// одна и та же функция отдаёт, какая сторона строки ОСВ/нераспределённой заполнена: 'D' — Дебет, 'K' — Кредит
if (row.kind === 'entry') return row.side === 1 ? 'D' : 'K';
if (row.kind === 'unallocated') return row.entry.side;
return null;
};
export function CorrectionTable(props: Props) {
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props;
const canAct = editable && actionsEnabled;
const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]);
const rowBg = (row: TableRow): string | undefined => {
if (row.kind === 'unallocated') return BG_UNALLOCATED;
if (row.kind === 'aggregate' && row.aggregate.composition_changed) return BG_CHANGED;
return undefined;
};
// меню решений по строке ОСВ / нераспределённой строке
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] => {
const e = row.entry;
const ruleItems = e.candidate_rule_ids.flatMap((id) => {
const r = ruleById.get(id);
return r ? [{ key: `assign:${id}`, label: ruleLabel(r) }] : [];
});
const items: NonNullable<MenuProps['items']> = [
{
key: 'assign',
label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…',
disabled: ruleItems.length === 0,
children: ruleItems,
},
];
if (row.kind === 'entry') items.push({ key: 'unassign', label: 'Убрать из правила' });
if ((row.kind === 'entry' && row.entry.decision === 'ASSIGN') || (row.kind === 'unallocated' && row.entry.reason === 'UNASSIGNED')) {
items.push({ key: 'reset', label: 'Вернуть к автоматике' });
}
return items;
};
const onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => {
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
if (key.startsWith('assign:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('assign:'.length) });
else if (key === 'unassign') actions.onAllocate({ ...base, action: 'UNASSIGN' });
else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' });
};
const withHint = (node: ReactNode) => (readOnlyHint ? <Tooltip title={readOnlyHint}><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>) : '—';
const cell = (key: ColKey, row: TableRow): ReactNode => {
if (row.kind === 'aggregate') {
const a = row.aggregate;
const red = a.exceeds_threshold ? { color: COLOR_OVER } : undefined;
switch (key) {
case 'include':
return withHint(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Checkbox disabled checked={row.include !== 'Нет'} indeterminate={row.include === 'Частично'} />
<span style={{ fontSize: 12 }}>{row.include}</span>
{a.composition_changed && (
<Tooltip title='Состав изменился после корректировки'>
<WarningOutlined style={{ color: '#dc6803' }} />
</Tooltip>
)}
</span>,
);
case 'd_entity':
return a.company_1.name;
case 'k_entity':
return a.company_2.name;
case 'd_line':
return showLine(a.side_1.article_line);
case 'k_line':
return showLine(a.side_2.article_line);
case 'd_account':
return a.side_1.accounts.join(', ');
case 'k_account':
return a.side_2.accounts.join(', ');
case 'd_amount':
return format(a.side_1.amount);
case 'k_amount':
return format(a.side_2.amount);
case 'dev_percent':
return <span style={red}>{percentText(a.deviation_percent)}</span>;
case 'dev_amount':
return <span style={red}>{format(a.deviation_amount)}</span>;
case 'c_d_line':
return lines(a.correction?.debit, (l) => l.line_code);
case 'c_d_amount':
return lines(a.correction?.debit, (l) => format(l.amount));
case 'c_k_line':
return lines(a.correction?.credit, (l) => l.line_code);
case 'c_k_amount':
return lines(a.correction?.credit, (l) => format(l.amount));
case 'comment':
return a.correction ? (
<span>
{a.correction.status === 'DRAFT' && (
<Tag color='orange' style={{ marginInlineEnd: 6 }}>
черновик
</Tag>
)}
{a.correction.comment || '—'}
</span>
) : (
'—'
);
case 'actions':
return (
<span style={{ display: 'inline-flex', gap: 4 }}>
<Button size='small' icon={<EditOutlined />} onClick={() => actions.onCorrect(a)}>
Корректировать
</Button>
{withHint(
<Dropdown
trigger={['click']}
menu={{
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !canAct || !a.correction }],
onClick: () => actions.onRevoke(a),
}}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' />
</Dropdown>,
)}
</span>
);
default:
return null;
}
}
// строка ОСВ в раскрытии или нераспределённая: заполнена только сторона владельца
const e = row.entry;
const owned = sideSuffix(row); // 'D' | 'K'
const own = (side: 'D' | 'K') => owned === side;
switch (key) {
case 'include':
return withHint(
<Checkbox
disabled={!canAct}
checked={!e.excluded}
onChange={(ev) => actions.onAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, action: ev.target.checked ? 'INCLUDE' : 'EXCLUDE' })}
/>,
);
case 'd_entity':
if (row.kind !== 'unallocated') return null;
return own('D') ? row.entry.owner.name : row.entry.counterparty.name;
case 'k_entity':
if (row.kind !== 'unallocated') return null;
return own('K') ? row.entry.owner.name : row.entry.counterparty.name;
case 'd_line':
case 'k_line':
case 'dev_percent':
case 'dev_amount':
case 'c_d_line':
case 'c_d_amount':
case 'c_k_line':
case 'c_k_amount':
case 'comment':
return row.kind === 'unallocated' ? '—' : null;
case 'd_account':
return own('D') ? e.account_code : null;
case 'k_account':
return own('K') ? e.account_code : null;
case 'd_amount':
return own('D') ? format(e.amount) : null;
case 'k_amount':
return own('K') ? format(e.amount) : null;
case 'actions':
return withHint(
<Dropdown
trigger={['click']}
disabled={!canAct}
menu={{ items: entryMenu(row), onClick: ({ key: k }) => onEntryMenu(row, k) }}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' disabled={!canAct} />
</Dropdown>,
);
default:
return null;
}
};
const tint = (key: ColKey): string | undefined => {
if (key.startsWith('d_')) return BG_DEBIT;
return key.startsWith('k_') ? BG_CREDIT : undefined;
};
const leaf = (key: ColKey, title: ReactNode, width: number, ellipsis = false): ColumnType<TableRow> => ({
key,
title,
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 } }),
});
const filtersTitle = (label: string, active: boolean, body: ReactNode) => (
<HeaderFilter label={label} active={active}>
{body}
</HeaderFilter>
);
const entityTitle = filtersTitle(
'ЮЛ',
filters.entity.trim() !== '',
<TextFilter value={filters.entity} placeholder='Часть названия' onChange={(v) => onFiltersChange({ ...filters, entity: v })} />,
);
const lineTitle = filtersTitle(
'Строка баланса',
filters.line !== null,
<SelectFilter
allowClear
value={filters.line}
options={props.lineOptions.map((c) => ({ value: c, label: c }))}
onChange={(v) => onFiltersChange({ ...filters, line: v })}
/>,
);
const accountTitle = filtersTitle(
'Счёт',
filters.account.trim() !== '',
<TextFilter value={filters.account} placeholder='Начало счёта' onChange={(v) => onFiltersChange({ ...filters, account: v })} />,
);
const deviationTitle = filtersTitle(
'Отклонение, %',
filters.deviation !== 'all',
<SelectFilter
value={filters.deviation}
options={DEVIATION_OPTIONS}
onChange={(v) => onFiltersChange({ ...filters, deviation: v ?? 'all' })}
/>,
);
const visible = new Set(props.visibleKeys);
const columns = (() => {
const all: Record<ColKey, ColumnType<TableRow>> = {
include: leaf('include', 'Учитывать в КФО', 110),
d_entity: leaf('d_entity', entityTitle, 210, true),
d_line: leaf('d_line', lineTitle, 110),
d_account: leaf('d_account', accountTitle, 150),
d_amount: leaf('d_amount', 'Сумма', 120),
k_entity: leaf('k_entity', entityTitle, 210, true),
k_line: leaf('k_line', lineTitle, 110),
k_account: leaf('k_account', accountTitle, 150),
k_amount: leaf('k_amount', 'Сумма', 120),
dev_percent: leaf('dev_percent', deviationTitle, 120),
dev_amount: leaf('dev_amount', 'Отклонение, сумма', 120),
c_d_line: leaf('c_d_line', 'Строка баланса', 100),
c_d_amount: leaf('c_d_amount', 'Сумма (Дт)', 110),
c_k_line: leaf('c_k_line', 'Строка баланса', 100),
c_k_amount: leaf('c_k_amount', 'Сумма (Кт)', 110),
comment: leaf('comment', 'Комментарий', 220, true),
actions: leaf('actions', '', 200),
};
const expand: ColumnType<TableRow> = {
key: 'expand',
width: 36,
render: (_, row) =>
row.kind === 'aggregate' ? (
<Button
type='text'
size='small'
aria-label={expanded.has(row.key) ? 'Свернуть' : 'Раскрыть'}
icon={expanded.has(row.key) ? <DownOutlined /> : <RightOutlined />}
onClick={() => onToggle(row.key)}
/>
) : null,
onCell: (row) => ({ style: { background: rowBg(row) } }),
};
const out: (ColumnType<TableRow> | ColumnGroupType<TableRow>)[] = [expand];
for (const def of COLUMN_DEFS) {
if ('children' in def) {
const children = def.children.filter((c) => visible.has(c.key)).map((c) => all[c.key]);
if (children.length > 0) out.push({ title: <div style={{ textAlign: 'left' }}>{def.title}</div>, children });
} else if (visible.has(def.key)) {
out.push(all[def.key]);
}
}
return out;
})();
// «Итого» — по тем же видимым колонкам, что и таблица
const totalCell = (key: ColKey | 'expand'): ReactNode => {
if (!totals) return null;
switch (key) {
case 'expand':
return <strong>Итого</strong>;
case 'd_amount':
return format(totals.amount_1);
case 'k_amount':
return format(totals.amount_2);
case 'dev_percent':
return '—';
case 'dev_amount':
return format(totals.deviation_amount);
case 'c_d_amount':
return format(totals.correction_debit);
case 'c_k_amount':
return format(totals.correction_credit);
default:
return null;
}
};
// колонка «Учитывать в КФО» в «Итого» занята подписью (colSpan 2 у первой ячейки), своей ячейки у неё нет
const leafKeys: (ColKey | 'expand')[] = [
'expand',
...COLUMN_DEFS.flatMap((d) => ('children' in d ? d.children.map((c) => c.key) : [d.key])).filter((k) => visible.has(k) && k !== 'include'),
];
return (
<Table<TableRow>
size='small'
bordered
pagination={false}
rowKey='key'
columns={columns}
dataSource={rows}
locale={{ emptyText: props.emptyText }}
scroll={{ x: 'max-content', y: props.scrollY }}
onRow={(row) => ({ style: { fontWeight: row.kind === 'aggregate' ? 600 : 400 } })}
summary={() =>
totals ? (
<Table.Summary fixed='bottom'>
<Table.Summary.Row style={{ background: '#f9fafb', fontWeight: 600 }}>
{leafKeys.map((k, i) => (
<Table.Summary.Cell key={k} index={i} colSpan={k === 'expand' && visible.has('include') ? 2 : 1}>
{totalCell(k)}
</Table.Summary.Cell>
))}
</Table.Summary.Row>
</Table.Summary>
) : null
}
/>
);
}

View File

@ -0,0 +1,115 @@
// Панель над таблицей: «Отображение», «Правило», «Тип записи», единицы и знаки, сброс фильтров, настройка, экспорт, весь экран.
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, Select, Space } from 'antd';
import { ColumnSettings } from '../../components/GenericTable/ColumnSettings';
import type { CorrectionRuleOut } from '../../types/corrections';
import { PRECISIONS, UNITS, type Unit } from '../ConsolidatedOsv/useNumberDisplay';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { type RecordType, type TableFilters, isFiltered, ruleLabel } from './tableModel';
type Props = {
rules: CorrectionRuleOut[];
filters: TableFilters;
onFiltersChange: (next: TableFilters) => void;
onReset: () => void;
unit: Unit;
onUnitChange: (u: Unit) => void;
precision: number;
onPrecisionChange: (p: number) => void;
visibleKeys: ColKey[];
onVisibleKeysChange: (keys: ColKey[]) => void;
onExport: () => void;
exporting: boolean;
exportDisabled: boolean;
fullscreen: boolean;
onToggleFullscreen: () => void;
};
const RECORD_TYPES: { value: RecordType; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'posting', label: 'Пары счетов' },
{ value: 'unallocated', label: 'Нераспределенные записи' },
];
const Labeled = ({ label, children }: { label: string; children: React.ReactNode }) => (
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>{label}</span>
{children}
</Space>
);
export function CorrectionToolbar(p: Props) {
const { filters, rules } = p;
const setDisplay = (mode: 'all' | 'preset') => {
if (mode === 'all') p.onFiltersChange({ ...filters, display: { mode: 'all' } });
// при переходе к пресету сразу выбираем первое правило, чтобы таблица не оказалась пустой
else p.onFiltersChange({ ...filters, display: { mode: 'preset', ruleId: rules[0]?.id ?? '' } });
};
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
<Labeled label='Отображение'>
<Select
variant='underlined'
style={{ minWidth: 130 }}
value={filters.display.mode}
onChange={setDisplay}
options={[
{ value: 'all', label: 'Общий' },
{ value: 'preset', label: 'По пресету', disabled: rules.length === 0 },
]}
/>
</Labeled>
{filters.display.mode === 'preset' && (
<Labeled label='Правило'>
<Select
variant='underlined'
style={{ minWidth: 220 }}
value={filters.display.ruleId}
onChange={(ruleId) => p.onFiltersChange({ ...filters, display: { mode: 'preset', ruleId } })}
options={rules.map((r) => ({ value: r.id, label: ruleLabel(r) }))}
/>
</Labeled>
)}
<Labeled label='Тип записи'>
<Select
variant='underlined'
style={{ minWidth: 190 }}
value={filters.recordType}
onChange={(recordType) => p.onFiltersChange({ ...filters, recordType })}
options={RECORD_TYPES}
/>
</Labeled>
<div style={{ width: 1, height: 40, backgroundColor: '#e5e7eb' }} />
<Labeled label='Единицы'>
<Select variant='underlined' style={{ minWidth: 120 }} value={p.unit} onChange={p.onUnitChange} options={UNITS} />
</Labeled>
<Labeled label='Знаков после запятой'>
<Select
variant='underlined'
style={{ minWidth: 150 }}
value={p.precision}
onChange={p.onPrecisionChange}
options={PRECISIONS.map((n) => ({ value: n, label: String(n) }))}
/>
</Labeled>
{isFiltered(filters) && (
<Button type='link' onClick={p.onReset}>
Сбросить фильтры
</Button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<ColumnSettings<ColShape> defs={COLUMN_DEFS} visibleKeys={p.visibleKeys} onChange={p.onVisibleKeysChange} />
<Button icon={<DownloadOutlined />} loading={p.exporting} disabled={p.exportDisabled} onClick={p.onExport}>
Экспорт
</Button>
<Button icon={p.fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={p.onToggleFullscreen}>
{p.fullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { FilterFilled, FilterOutlined } from '@ant-design/icons';
import { Input, Popover, Select } from 'antd';
import type { ReactNode } from 'react';
type Props = {
label: ReactNode;
/** Фильтр задан — значок выделяется. */
active: boolean;
children: ReactNode;
};
/** Подпись колонки со значком-воронкой: по клику открывается поле фильтра. */
export function HeaderFilter({ label, active, children }: Props) {
return (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
<span>{label}</span>
<Popover trigger='click' placement='bottomLeft' content={<div style={{ width: 200 }}>{children}</div>}>
{/* стопим всплытие, чтобы клик не запускал выбор заголовка */}
<button
type='button'
aria-label='Фильтр'
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}>
{active ? <FilterFilled style={{ color: '#2a8c3a' }} /> : <FilterOutlined style={{ color: '#98a2b3' }} />}
</button>
</Popover>
</span>
);
}
export function TextFilter({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (v: string) => void }) {
return <Input allowClear autoFocus value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />;
}
export function SelectFilter<V extends string>({
value,
options,
allowClear,
onChange,
}: {
value: V | null;
options: { value: V; label: string }[];
allowClear?: boolean;
onChange: (v: V | null) => void;
}) {
return (
<Select
style={{ width: '100%' }}
value={value}
allowClear={allowClear}
options={options}
placeholder='Все'
onChange={(v) => onChange(v ?? null)}
onClear={() => onChange(null)}
/>
);
}

View File

@ -0,0 +1,68 @@
// Колонки таблицы корректировок: ключи, группы и подписи (для «Настройки таблицы» и сборки колонок antd).
import type { ColumnDef } from '../../components/GenericTable/tableTypes';
export type ColKey =
| 'include'
| 'd_entity'
| 'd_line'
| 'd_account'
| 'd_amount'
| 'k_entity'
| 'k_line'
| 'k_account'
| 'k_amount'
| 'dev_percent'
| 'dev_amount'
| 'c_d_line'
| 'c_d_amount'
| 'c_k_line'
| 'c_k_amount'
| 'comment'
| 'actions';
/** Тип-«форма» для GenericTable: ему нужны только ключи колонок, значения не используются. */
export type ColShape = Record<ColKey, unknown>;
export const COLUMN_DEFS: ColumnDef<ColShape>[] = [
{ key: 'include', title: 'Учитывать в КФО' },
{
title: 'Дебет',
groupKey: 'debit',
children: [
{ key: 'd_entity', title: 'ЮЛ' },
{ key: 'd_line', title: 'Строка баланса' },
{ key: 'd_account', title: 'Счёт' },
{ key: 'd_amount', title: 'Сумма' },
],
},
{
title: 'Кредит',
groupKey: 'credit',
children: [
{ key: 'k_entity', title: 'ЮЛ' },
{ key: 'k_line', title: 'Строка баланса' },
{ key: 'k_account', title: 'Счёт' },
{ key: 'k_amount', title: 'Сумма' },
],
},
{
title: 'Контроль',
groupKey: 'control',
children: [
{ key: 'dev_percent', title: 'Отклонение, %' },
{ key: 'dev_amount', title: 'Отклонение, сумма' },
],
},
{
title: 'Корректировка',
groupKey: 'correction',
children: [
{ key: 'c_d_line', title: 'Строка баланса (Дт)' },
{ key: 'c_d_amount', title: 'Сумма (Дт)' },
{ key: 'c_k_line', title: 'Строка баланса (Кт)' },
{ key: 'c_k_amount', title: 'Сумма (Кт)' },
{ key: 'comment', title: 'Комментарий' },
],
},
{ key: 'actions', title: 'Действия' },
];

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { latestOnly } from './useCorrectionTable';
describe('latestOnly', () => {
it('применяется только ответ последнего запроса', () => {
const requests = latestOnly();
expect(requests.next()).toBe(1);
expect(requests.next()).toBe(2);
expect(requests.isLatest(1)).toBe(false);
expect(requests.isLatest(2)).toBe(true);
});
it('запрос, начатый после, делает прежний устаревшим', () => {
const requests = latestOnly();
const first = requests.next();
expect(requests.isLatest(first)).toBe(true);
requests.next();
expect(requests.isLatest(first)).toBe(false);
});
});

View File

@ -0,0 +1,81 @@
// Загрузка таблицы корректировок версии. Применяется только ответ последнего запроса: аналитик может быстро щёлкнуть
// два флажка подряд, и ответ на первый клик не должен перетереть свежую таблицу.
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { getCorrectionTable } from '../../services/api/corrections';
import type { CorrectionAggregateOut, CorrectionTableOut } from '../../types/corrections';
import { aggregateKey } from './tableModel';
/** Счётчик запросов: `next()` выдаёт номер нового запроса, `isLatest(id)` — «это ещё последний?». */
export function latestOnly(): { next(): number; isLatest(id: number): boolean } {
let last = 0;
return {
next: () => {
last += 1;
return last;
},
isLatest: (id) => id === last,
};
}
export function useCorrectionTable(versionId: string): {
table: CorrectionTableOut | null;
loading: boolean;
error: string | null;
reload(): void;
replace(t: CorrectionTableOut): void;
replaceAggregate(a: CorrectionAggregateOut): void;
} {
const [table, setTable] = useState<CorrectionTableOut | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// счётчик живёт всё время жизни компонента и не вызывает перерисовок
const requests = useRef(latestOnly()).current;
const reload = useCallback(() => {
const id = requests.next();
setLoading(true);
setError(null);
getCorrectionTable(versionId)
.then((t) => {
if (!requests.isLatest(id)) return;
setTable(t);
setLoading(false);
})
.catch((e: unknown) => {
if (!requests.isLatest(id)) return;
setError(e instanceof ApiError ? e.message : 'Не удалось получить таблицу корректировок');
setLoading(false);
});
}, [versionId, requests]);
useEffect(() => {
reload();
// после размонтирования ответ уже не нужен: делаем любой номер устаревшим
return () => {
requests.next();
};
}, [reload, requests]);
// ответ правки (вся таблица или агрегат) новее любого ещё не вернувшегося запроса — старые ответы отбрасываем
const replace = useCallback(
(t: CorrectionTableOut) => {
requests.next();
setTable(t);
setLoading(false);
setError(null);
},
[requests],
);
const replaceAggregate = useCallback(
(a: CorrectionAggregateOut) => {
requests.next();
setLoading(false);
setTable((prev) => (prev ? { ...prev, aggregates: prev.aggregates.map((x) => (aggregateKey(x) === aggregateKey(a) ? a : x)) } : prev));
},
[requests],
);
return { table, loading, error, reload, replace, replaceAggregate };
}