diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 59f3fbf..0528a33 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -188,3 +188,8 @@ | 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: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями | +| 28.10.2026 | R-10: `ROW_ACTIONS_READY`/`actionsEnabled` удалены; контролы строк неактивны при `!editable` (с подсказкой `readOnlyHint`) и на время запроса (`busy`, без подсказки) | включение R-7 в Task 5; блокировка на время запроса не даёт отправить два решения подряд | аналитик не видит, почему контрол замер, пока идёт запрос (виден спиннер таблицы) | +| 28.10.2026 | R-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы | +| 28.10.2026 | R-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) | +| 28.10.2026 | R-13: меню строки вынесено в чистую `allocationMenu` (ключ `MOVE` — «выбрать правило», уходит как `ASSIGN`); правило-кандидат, которого нет в `rules`, пункт «в правило» не включает | тестируемость без React; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен | + diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index 29fc5ef..b9b80f3 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -3,40 +3,26 @@ 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 { allocate, exportCorrectionTable, revokeCorrection } from '../../services/api/corrections'; import type { VersionCard } from '../../types/consolidations/version_members'; -import type { CorrectionTableOut, ReadOnlyReason } from '../../types/corrections'; +import type { AllocationIn, CorrectionAggregateOut } 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 { RevokeModal } from './RevokeModal'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; -import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; +import { needsReload, readOnlyHint } from './readOnly'; +import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, 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]; -} - -/** Решения по строкам и окно корректировки подключаются в Task 5–6: тогда флаг становится true (одна правка). */ -const ROW_ACTIONS_READY = false; - 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 { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId); const { notification } = App.useApp(); const notify = useNotificationApi(); // по умолчанию — «тыс. руб.», 2 знака, как в дизайне @@ -46,6 +32,9 @@ export function CorrectionContent({ versionId, card = null }: Props) { const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); const [exporting, setExporting] = useState(false); const [fullscreen, setFullscreen] = useState(false); + // идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд + const [busy, setBusy] = useState(false); + const [revoking, setRevoking] = useState(null); const boxRef = useRef(null); // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» @@ -117,11 +106,45 @@ export function CorrectionContent({ versionId, card = null }: Props) { return next; }); - // заглушки: окно корректировки и решения по строкам подключают следующие задачи плана + // ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу + const fail = (e: unknown, fallback: string) => { + notify.error({ description: e instanceof ApiError ? e.message : fallback }); + if (needsReload(e)) reload(); + }; + + // решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком + const onAllocate = async (body: AllocationIn) => { + setBusy(true); + try { + replace(await allocate(versionId, body)); + } catch (e) { + fail(e, 'Не удалось сохранить решение по строке'); + } finally { + setBusy(false); + } + }; + + // отмена корректировки: ответ — один агрегат, подменяем только его + const onRevoke = async (comment: string) => { + if (!revoking) return; + setBusy(true); + try { + replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment })); + setRevoking(null); + } catch (e) { + // 409 — данные устарели, окно закрываем (таблица перечитается); иная ошибка — окно остаётся, можно повторить + if (needsReload(e)) setRevoking(null); + fail(e, 'Не удалось отменить корректировку'); + } finally { + setBusy(false); + } + }; + + // «Корректировать» остаётся заглушкой до Task 6 const actions: RowActions = { onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }), - onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }), - onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }), + onRevoke: setRevoking, + onAllocate, }; const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0; @@ -130,7 +153,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { let body: ReactNode; if (table) { body = ( - + {body} + setRevoking(null)} + onSubmit={onRevoke} + /> ); diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index bc20665..ac2f006 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -8,6 +8,7 @@ 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 { allocationMenu } from './readOnly'; import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; import type { visibleTotals } from './tableModel'; @@ -30,8 +31,8 @@ type Props = { editable: boolean; /** Подсказка, почему правки закрыты; `null` — открыты. */ readOnlyHint: string | null; - /** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */ - actionsEnabled: boolean; + /** Запрос решения уже идёт: контролы строк на это время неактивны (без подсказки). */ + busy: boolean; actions: RowActions; filters: TableFilters; onFiltersChange: (next: TableFilters) => void; @@ -57,8 +58,6 @@ const BG_CHANGED = '#fff6e5'; /** «394.27» → «394,27%»; строка не переводится в число. */ const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); -const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана'; - const showLine = (line: string): string => articleCodes(line).join(' / '); const sideSuffix = (row: TableRow) => { @@ -69,8 +68,8 @@ const sideSuffix = (row: TableRow) => { }; export function CorrectionTable(props: Props) { - const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props; - const canAct = editable && actionsEnabled; + const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, busy, actions, filters, onFiltersChange } = props; + const canAct = editable && !busy; const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]); const rowBg = (row: TableRow): string | undefined => { @@ -79,38 +78,25 @@ export function CorrectionTable(props: Props) { 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) }] : []; + // меню решений по строке ОСВ / нераспределённой строке: пункты даёт allocationMenu, здесь только переводим в формат antd + const entryMenu = (row: Extract): MenuProps['items'] => + allocationMenu(row, rules).map((item) => { + if (item.key !== 'MOVE') return { key: item.key, label: item.label, disabled: item.disabled }; + const children = row.entry.candidate_rule_ids.flatMap((id) => { + const r = ruleById.get(id); + return r ? [{ key: `ASSIGN:${id}`, label: ruleLabel(r) }] : []; + }); + return { key: 'MOVE', label: item.label, disabled: item.disabled, children }; }); - 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' }); + if (key.startsWith('ASSIGN:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('ASSIGN:'.length) }); + else if (key === 'UNASSIGN' || key === 'RESET') actions.onAllocate({ ...base, action: key }); }; - // почему контрол неактивен: сервер закрыл правки — его причина; иначе действие ещё не подключено в этой версии экрана - const inactiveHint = readOnlyHint ?? (actionsEnabled ? null : UNAVAILABLE_HINT); - const withHint = (node: ReactNode, hint: string | null = inactiveHint) => + // почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё + const withHint = (node: ReactNode, hint: string | null = readOnlyHint) => hint ? ( {node} @@ -140,7 +126,6 @@ export function CorrectionTable(props: Props) { )} , - readOnlyHint, ); case 'd_entity': return a.company_1.name; @@ -192,11 +177,12 @@ export function CorrectionTable(props: Props) { {withHint( actions.onRevoke(a), }}> -