diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md
index 7ea97b3..00b07e5 100644
--- a/openspec/changes/corrections-table-ui/design.md
+++ b/openspec/changes/corrections-table-ui/design.md
@@ -199,3 +199,4 @@
| 28.10.2026 | R-17: после `UNASSIGN`/`ASSIGN` в окне суммы статей без своих правок (не dirty) пересобираются из нового агрегата (`initialEditorState`), при правках — остаются как есть | у агрегата без корректировки статья предлагается с суммой стороны, она изменилась; затирать ввод аналитика нельзя | при правках статьи не подстраиваются под новые суммы сторон — их видно по строке контроля |
| 28.10.2026 | R-18: суммы в окне показываются в рублях (`1 000 000,00`) независимо от единиц панели, а в полях ввода — как есть (`1000000.00`, пробелы запрещены R-4); у строки контроля без процента (`percent = null`, одна сторона 0) процент не пишется, у равенства — «= 0,00» | единицы панели — про таблицу; дизайн показывает «(100%)» там, где процент к меньшей стороне не определён, фронт его не выдумывает | расхождение с дизайном в тексте контроля |
| 28.10.2026 | R-19: агрегат исчез из ответа — окно открыто, стороны «—», «Сохранить» неактивна, пояснение в плашке; ключ PUT берётся из ответа, поэтому без агрегата сохранить нельзя | Review Focus плана | — |
+| 28.10.2026 | R-20: в окне нет названий статей рядом с кодом («дт 1230 Дебиторская задолженность» в дизайне) | в ответе API названий статей нет, а фронт их не выдумывает | добавить поле `article_name` в контракт и показать |
diff --git a/web/src/widgets/CorrectionTable/AllocationPicker.tsx b/web/src/widgets/CorrectionTable/AllocationPicker.tsx
index f8fb6f8..c3a093f 100644
--- a/web/src/widgets/CorrectionTable/AllocationPicker.tsx
+++ b/web/src/widgets/CorrectionTable/AllocationPicker.tsx
@@ -1,5 +1,5 @@
// Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick).
-import { Modal, Table } from 'antd';
+import { Button, Modal, Table } from 'antd';
import type { UnallocatedEntryOut } from '../../types/corrections';
import { formatRub } from './editorView';
@@ -40,6 +40,23 @@ export function AllocationPicker({ open, sideLabel, article, candidates, busy, o
{ title: 'Счёт', dataIndex: 'account_code', width: 120 },
{ title: 'Строка баланса', width: 140, render: () => article },
{ title: 'Сумма', dataIndex: 'amount', align: 'right', width: 160, render: (v: string) => formatRub(v) },
+ {
+ // кнопка — путь с клавиатуры (Tab, Enter); клик по строке остаётся для мыши
+ title: '',
+ width: 110,
+ render: (_: unknown, u: UnallocatedEntryOut) => (
+
+ ),
+ },
]}
/>
diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx
index 86b20a2..9a7a43d 100644
--- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx
+++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx
@@ -1,14 +1,14 @@
// Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль,
// комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу.
import { Alert, App, Button, Input, Modal } from 'antd';
-import { useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useCurrentUser } from '../../auth/MeContext';
import { allocate, saveCorrection } from '../../services/api/corrections';
import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections';
import { AllocationPicker } from './AllocationPicker';
import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide';
import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage';
-import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, toCorrectionIn, validateLines } from './editorModel';
+import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel';
import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView';
import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly';
import { articleCodes, findAggregate, toAggregateKey } from './tableModel';
@@ -68,7 +68,13 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
base: useDraft && draft ? draft.baseVersionNo : (aggregate?.correction?.version_no ?? null),
};
});
- const [state, setState] = useState(start.state);
+ const [state, setStateRaw] = useState(start.state);
+ // ref обновляется там же, где состояние (а не при рендере): после await обработчик читает свежие правки, а не замыкание
+ const stateRef = useRef(state);
+ const setState = (next: KeyedState) => {
+ stateRef.current = next;
+ setStateRaw(next);
+ };
// savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки
const [savedAt, setSavedAt] = useState(start.savedAt);
const [base, setBase] = useState(start.base);
@@ -81,16 +87,26 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
const [formError, setFormError] = useState(null);
const [pickerSide, setPickerSide] = useState(null);
- const stateRef = useRef(state);
- stateRef.current = state;
const busy = saving || allocating;
- const dirty = editable && isDirty(initial, plain(state));
+ // Агрегат пересобрался (перечитали таблицу, перенос строки ОСВ): без своих правок окно берёт новые суммы, с правками — ввод остаётся.
+ const prevInitial = useRef(initial);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену исходного состояния агрегата
+ useEffect(() => {
+ if (prevInitial.current === initial) return;
+ const next = rebaseState(prevInitial.current, initial, plain(stateRef.current));
+ prevInitial.current = initial;
+ if (next) setState(withIds(next));
+ }, [initial]);
+
+ // без агрегата окно только на просмотр: правок и «несохранённого» нет
+ const edit = editable && aggregate !== null;
+ const dirty = edit && isDirty(initial, plain(state));
/** Любая правка статей/комментария: в состояние и в черновик (совпало с исходным — черновик удаляем). */
const commit = (next: KeyedState) => {
setState(next);
- if (!editable || !aggregate) return;
+ if (!edit || !aggregate) return;
const p = plain(next);
if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base });
else {
@@ -146,9 +162,6 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
try {
const t = await allocate(versionId, body);
onTable(t);
- // без своих правок суммы по умолчанию следуют за новыми суммами сторон
- const fresh = findAggregate(t, aggregateKey);
- if (fresh && !isDirty(initial, plain(stateRef.current))) setState(withIds(initialEditorState(fresh)));
return true;
} catch (e) {
const out = errorOutcome(e, 'Не удалось выполнить действие со строкой');
@@ -171,7 +184,7 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
const validation = validateLines(plain(state));
const save = async () => {
- if (!aggregate || inflight.current || !validation.ok) return;
+ if (!edit || !aggregate || inflight.current || !validation.ok) return;
inflight.current = true;
setSaving(true);
setFormError(null);
@@ -246,9 +259,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
footer={