From c1d402f0e3848bd26175f32cac92a0952192976e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:53:22 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B0=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0,=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C=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 | 1 + .../CorrectionTable/AllocationPicker.tsx | 19 ++++- .../CorrectionTable/CorrectionEditorModal.tsx | 70 +++++++++++++------ .../widgets/CorrectionTable/EditorSide.tsx | 33 ++++++--- .../CorrectionTable/editorModel.test.ts | 16 +++++ .../widgets/CorrectionTable/editorModel.ts | 10 +++ web/src/widgets/CorrectionTable/editorView.ts | 4 +- 7 files changed, 119 insertions(+), 34 deletions(-) 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={
- {editable && ( + {edit && ( @@ -256,15 +269,16 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
}>
- {hint && } - {!aggregate && } + {hint && } + {!aggregate && } {notice && ( Сбросить @@ -272,14 +286,14 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable } /> )} - {formError && } + {formError && }
- setPickerSide(1)} onUnassign={unassign} /> - setPickerSide(2)} onUnassign={unassign} /> + setPickerSide(1)} onUnassign={unassign} /> + setPickerSide(2)} onUnassign={unassign} />
@@ -289,7 +303,8 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable articleCodes={n === 1 ? s1.codes : s2.codes} lines={n === 1 ? state.debit : state.credit} lineErrors={lineErrors(n)} - editable={editable} + editable={edit} + sideName={n === 1 ? 'дебет' : 'кредит'} onAddLine={() => editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])} onRemoveLine={(id) => editLines(n, (ls) => ls.filter((l) => l.id !== id))} onChangeLine={(id, patch) => editLines(n, (ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)))} @@ -297,7 +312,11 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable ))}
- {!validation.ok && validation.errors.lines &&
{validation.errors.lines}
} + {!validation.ok && validation.errors.lines && ( +
+ {validation.errors.lines} +
+ )}
@@ -305,17 +324,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable { setCommentError(null); commit({ ...state, comment: e.target.value }); }} /> - {commentError &&
{commentError}
} + {commentError && ( + + )}
diff --git a/web/src/widgets/CorrectionTable/EditorSide.tsx b/web/src/widgets/CorrectionTable/EditorSide.tsx index ecee983..021adf1 100644 --- a/web/src/widgets/CorrectionTable/EditorSide.tsx +++ b/web/src/widgets/CorrectionTable/EditorSide.tsx @@ -97,6 +97,8 @@ type DistProps = { lines: KeyedLine[]; lineErrors: LineErrors; editable: boolean; + /** Сторона в подписях для скринридера: «дебет» / «кредит». */ + sideName: string; onAddLine(): void; onRemoveLine(id: number): void; onChangeLine(id: number, patch: Partial>): void; @@ -120,7 +122,7 @@ export function DistributionBlock(p: DistProps) { {p.lines.map((line, i) => { const err = p.lineErrors(i); - return ; + return ; })} @@ -129,10 +131,16 @@ export function DistributionBlock(p: DistProps) { type RowProps = Pick & { line: KeyedLine; + /** Номер строки в стороне с нуля. */ + index: number; + sideName: string; err: { line_code?: string; amount?: string }; }; -function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { +function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { + const where = `${sideName}, ${index + 1}`; + const codeErrId = `line-${line.id}-code-error`; + const amountErrId = `line-${line.id}-amount-error`; return ( <>
@@ -142,9 +150,13 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine disabled={!editable} style={{ width: '100%' }} onChange={(v) => onChangeLine(line.id, { line_code: v })}> - + - {err.line_code &&
{err.line_code}
} + {err.line_code && ( +
+ {err.line_code} +
+ )}
onChangeLine(line.id, { amount: e.target.value })} /> - {err.amount &&
{err.amount}
} + {err.amount && ( +
+ {err.amount} +
+ )}
- {editable &&
); @@ -168,7 +185,7 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine /** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */ export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) { return ( -
+
{left} {middle} {right} diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 6bae288..0cbf776 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -6,6 +6,7 @@ import { isDirty, liveControl, pickerCandidates, + rebaseState, toCorrectionIn, validateLines, } from './editorModel'; @@ -230,3 +231,18 @@ describe('pickerCandidates', () => { expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); }); }); + +describe('rebaseState', () => { + const st = (amount: string, comment = ''): EditorState => ({ debit: [{ line_code: '1230', amount }], credit: [], comment }); + it('аналитик не правил — окно берёт новое исходное состояние', () => { + expect(rebaseState(st('100.00'), st('250.00'), st('100.00'))).toEqual(st('250.00')); + }); + it('аналитик правил — ввод не затирается', () => { + expect(rebaseState(st('100.00'), st('250.00'), st('999.00'))).toBeNull(); + expect(rebaseState(st('100.00'), st('250.00'), st('100.00', 'комментарий'))).toBeNull(); + }); + it('исходное не изменилось или совпало с текущим — пересобирать нечего', () => { + expect(rebaseState(st('100.00'), st('100.00'), st('100.00'))).toBeNull(); + expect(rebaseState(st('100.00'), st('250.00'), st('250,00'))).toBeNull(); + }); +}); diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts index 43540e5..6152200 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -138,6 +138,16 @@ export function isDirty(initial: EditorState, current: EditorState): boolean { return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; } +/** + * Состояние окна после смены исходного состояния агрегата (перечитали таблицу, строка ОСВ перенесена): если аналитик + * ничего не правил относительно прежнего `prev`, окно показывает новое `next` (вернём его); правил — ввод не трогаем (`null`). + * `null` и когда пересобирать нечего (новое состояние совпало с текущим). + */ +export function rebaseState(prev: EditorState, next: EditorState, current: EditorState): EditorState | null { + if (isDirty(prev, current)) return null; + return isDirty(next, current) ? next : null; +} + const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D'); /** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */ diff --git a/web/src/widgets/CorrectionTable/editorView.ts b/web/src/widgets/CorrectionTable/editorView.ts index 65f478a..f0dff50 100644 --- a/web/src/widgets/CorrectionTable/editorView.ts +++ b/web/src/widgets/CorrectionTable/editorView.ts @@ -2,7 +2,7 @@ import { ApiError } from '../../api/client'; import type { CorrectionAggregateOut } from '../../types/corrections'; import { formatScaledAmount } from '../../utils/money'; -import type { Draft } from './draftStorage'; +import { type Draft, isStale } from './draftStorage'; import type { Diff } from './editorModel'; import { errorOutcome } from './readOnly'; @@ -71,7 +71,7 @@ export type DraftNotice = { kind: 'restored' | 'stale'; text: string }; */ export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null { if (savedAt === null) return null; - if (a && baseVersionNo !== (a.correction?.version_no ?? null)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; + if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' }; return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` }; }