From c2cbbf5dfd145d71e0f0190644e5a8afb38e0f60 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:39:26 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task?= =?UTF-8?q?=205=20=E2=80=94=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B0=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 2 +- web/src/layouts/ConsolidationReportPage.tsx | 2 +- .../CorrectionTable/CorrectionContent.tsx | 16 ++++-- .../CorrectionTable/CorrectionTable.tsx | 17 ++++--- .../widgets/CorrectionTable/RevokeModal.tsx | 3 +- .../widgets/CorrectionTable/readOnly.test.ts | 47 +++++++++++++++++- web/src/widgets/CorrectionTable/readOnly.ts | 49 ++++++++++++++++--- 7 files changed, 113 insertions(+), 23 deletions(-) diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 0528a33..0f5d0ac 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -189,7 +189,7 @@ | 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-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы; комментарий окна отмены теряется при 409 и перезагрузке — допустимо, корректировка уже изменена; перечитывание и при 404 (`needsReload`) | | 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/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 214bf63..dd2cf1e 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -33,7 +33,7 @@ function ReportContent() { chess: , - correction: , + correction: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index b9b80f3..9b495dc 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -13,7 +13,7 @@ import { CorrectionTable, type RowActions } from './CorrectionTable'; import { CorrectionToolbar } from './CorrectionToolbar'; import { RevokeModal } from './RevokeModal'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; -import { needsReload, readOnlyHint } from './readOnly'; +import { errorOutcome, needsReload, readOnlyHint } from './readOnly'; import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel'; import { useCorrectionTable } from './useCorrectionTable'; @@ -34,6 +34,8 @@ export function CorrectionContent({ versionId, card = null }: Props) { const [fullscreen, setFullscreen] = useState(false); // идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд const [busy, setBusy] = useState(false); + // ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно + const inflight = useRef(false); const [revoking, setRevoking] = useState(null); const boxRef = useRef(null); @@ -108,25 +110,30 @@ export function CorrectionContent({ versionId, card = null }: Props) { // ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу const fail = (e: unknown, fallback: string) => { - notify.error({ description: e instanceof ApiError ? e.message : fallback }); - if (needsReload(e)) reload(); + const out = errorOutcome(e, fallback); + notify.error({ description: out.message }); + if (out.reload) reload(); }; // решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком const onAllocate = async (body: AllocationIn) => { + if (inflight.current) return; + inflight.current = true; setBusy(true); try { replace(await allocate(versionId, body)); } catch (e) { fail(e, 'Не удалось сохранить решение по строке'); } finally { + inflight.current = false; setBusy(false); } }; // отмена корректировки: ответ — один агрегат, подменяем только его const onRevoke = async (comment: string) => { - if (!revoking) return; + if (!revoking || inflight.current) return; + inflight.current = true; setBusy(true); try { replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment })); @@ -136,6 +143,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { if (needsReload(e)) setRevoking(null); fail(e, 'Не удалось отменить корректировку'); } finally { + inflight.current = false; setBusy(false); } }; diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index ac2f006..f6d787f 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -8,7 +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 { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly'; import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; import type { visibleTotals } from './tableModel'; @@ -90,9 +90,8 @@ export function CorrectionTable(props: Props) { }); 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' || key === 'RESET') actions.onAllocate({ ...base, action: key }); + const body = menuBody(row, key); + if (body) actions.onAllocate(body); }; // почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё @@ -118,7 +117,7 @@ export function CorrectionTable(props: Props) { case 'include': return withHint( - + {row.include} {a.composition_changed && ( @@ -126,6 +125,7 @@ export function CorrectionTable(props: Props) { )} , + AGGREGATE_FLAG_HINT, ); case 'd_entity': return a.company_1.name; @@ -182,7 +182,7 @@ export function CorrectionTable(props: Props) { items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }], onClick: () => actions.onRevoke(a), }}> -