diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 36eb34a..59f3fbf 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -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: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями | diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index ea92e89..214bf63 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -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: , chess: , + + correction: , }; 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('/'); diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 5387a01..741dcfe 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -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: 'Гудвилл', diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx new file mode 100644 index 0000000..e19c371 --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -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 = { + 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(EMPTY_FILTERS); + const [expanded, setExpanded] = useState>(new Set()); + const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); + const [exporting, setExporting] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const boxRef = useRef(null); + + // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» + useEffect(() => { + if (!error) { + notification.destroy(LOAD_ERROR_KEY); + return; + } + notification.error({ + key: LOAD_ERROR_KEY, + title: 'Ошибка', + description: error, + placement: 'bottomRight', + duration: 0, + btn: , + }); + }, [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 = ( + + + + ); + } else if (loading) { + body = ( +
+ +
+ ); + } else { + body = ( +
+ {error ?? 'Таблица не загружена'} + +
+ ); + } + + return ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+ 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} +
+
+ ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx new file mode 100644 index 0000000..ea09a8b --- /dev/null +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -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 | null; + /** Форматтер сумм (единицы и знаки из панели). */ + format: (raw: string | null) => string; + expanded: ReadonlySet; + 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): 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 = [ + { + 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, 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 ? {node} : node); + + const lines = (list: CorrectionLineOut[] | undefined, pick: (l: CorrectionLineOut) => string): ReactNode => + list && list.length > 0 ? list.map((l) =>
{pick(l)}
) : '—'; + + 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( + + + {row.include} + {a.composition_changed && ( + + + + )} + , + ); + 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 {percentText(a.deviation_percent)}; + case 'dev_amount': + return {format(a.deviation_amount)}; + 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 ? ( + + {a.correction.status === 'DRAFT' && ( + + черновик + + )} + {a.correction.comment || '—'} + + ) : ( + '—' + ); + case 'actions': + return ( + + + {withHint( + actions.onRevoke(a), + }}> +