feat(web): вкладка «Таблица корректировки» — таблица и панель (#206)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec6ece127f
commit
d98f264f90
@ -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: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями |
|
||||
|
||||
@ -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('/');
|
||||
|
||||
@ -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: 'Гудвилл',
|
||||
|
||||
199
web/src/widgets/CorrectionTable/CorrectionContent.tsx
Normal file
199
web/src/widgets/CorrectionTable/CorrectionContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
398
web/src/widgets/CorrectionTable/CorrectionTable.tsx
Normal file
398
web/src/widgets/CorrectionTable/CorrectionTable.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
web/src/widgets/CorrectionTable/CorrectionToolbar.tsx
Normal file
115
web/src/widgets/CorrectionTable/CorrectionToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
web/src/widgets/CorrectionTable/HeaderFilter.tsx
Normal file
57
web/src/widgets/CorrectionTable/HeaderFilter.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
web/src/widgets/CorrectionTable/columns.ts
Normal file
68
web/src/widgets/CorrectionTable/columns.ts
Normal 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: 'Действия' },
|
||||
];
|
||||
20
web/src/widgets/CorrectionTable/useCorrectionTable.test.ts
Normal file
20
web/src/widgets/CorrectionTable/useCorrectionTable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
web/src/widgets/CorrectionTable/useCorrectionTable.ts
Normal file
81
web/src/widgets/CorrectionTable/useCorrectionTable.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user