fix(web): окно корректировки — пересборка состояния, клавиатура, доступность (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:53:22 +04:00
parent c559dd321c
commit c1d402f0e3
7 changed files with 119 additions and 34 deletions

View File

@ -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` в контракт и показать |

View File

@ -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) => (
<Button
size='small'
disabled={busy}
aria-label={`Выбрать: ${u.account_code}, ${formatRub(u.amount)}`}
onClick={(e) => {
e.stopPropagation();
onPick(u);
}}>
Выбрать
</Button>
),
},
]}
/>
</Modal>

View File

@ -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<KeyedState>(start.state);
const [state, setStateRaw] = useState<KeyedState>(start.state);
// ref обновляется там же, где состояние (а не при рендере): после await обработчик читает свежие правки, а не замыкание
const stateRef = useRef(state);
const setState = (next: KeyedState) => {
stateRef.current = next;
setStateRaw(next);
};
// savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки
const [savedAt, setSavedAt] = useState<string | null>(start.savedAt);
const [base, setBase] = useState<number | null>(start.base);
@ -81,16 +87,26 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
const [formError, setFormError] = useState<string | null>(null);
const [pickerSide, setPickerSide] = useState<SideNo | null>(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={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={requestClose} disabled={saving}>
{editable ? 'Отменить' : 'Закрыть'}
{edit ? 'Отменить' : 'Закрыть'}
</Button>
{editable && (
{edit && (
<Button type='primary' loading={saving} disabled={!aggregate || !validation.ok || allocating} onClick={save}>
Сохранить корректировки
</Button>
@ -256,15 +269,16 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{hint && <Alert type='info' showIcon title={hint} />}
{!aggregate && <Alert type='warning' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
{hint && <Alert type='info' role='alert' showIcon title={hint} />}
{!aggregate && <Alert type='warning' role='alert' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
{notice && (
<Alert
type={notice.kind === 'stale' ? 'warning' : 'info'}
role='alert'
showIcon
title={notice.text}
action={
editable ? (
edit ? (
<Button size='small' onClick={resetDraft}>
Сбросить
</Button>
@ -272,14 +286,14 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
}
/>
)}
{formError && <Alert type='error' showIcon title={formError} />}
{formError && <Alert type='error' role='alert' showIcon title={formError} />}
<div style={{ background: '#f7faf8', borderRadius: 8, padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<SideHeader company={s1.company} sideLabel={sideLabels[1]} article={s1.article} />
<SideHeader company={s2.company} sideLabel={sideLabels[2]} article={s2.article} />
<OsvBlock article={s1.article} entries={s1.entries} editable={editable} busy={busy} onPick={() => setPickerSide(1)} onUnassign={unassign} />
<OsvBlock article={s2.article} entries={s2.entries} editable={editable} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
<OsvBlock article={s1.article} entries={s1.entries} editable={edit} busy={busy} onPick={() => setPickerSide(1)} onUnassign={unassign} />
<OsvBlock article={s2.article} entries={s2.entries} editable={edit} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
</div>
<ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
@ -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
))}
</div>
<ControlRow {...(allocation ?? { left: '—', middle: '—', right: '—', red: false })} />
{!validation.ok && validation.errors.lines && <div style={{ color: '#d92d20' }}>{validation.errors.lines}</div>}
{!validation.ok && validation.errors.lines && (
<div role='alert' style={{ color: '#d92d20' }}>
{validation.errors.lines}
</div>
)}
</div>
<div>
@ -305,17 +324,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
<Input.TextArea
rows={3}
value={state.comment}
disabled={!editable}
disabled={!edit}
maxLength={COMMENT_MAX_LENGTH}
status={commentError ? 'error' : undefined}
placeholder='Укажите причину корректировки'
aria-label='Комментарий к корректировке'
aria-describedby={commentError ? 'correction-comment-error' : undefined}
onChange={(e) => {
setCommentError(null);
commit({ ...state, comment: e.target.value });
}}
/>
{commentError && <div style={{ color: '#d92d20', fontSize: 12 }}>{commentError}</div>}
{commentError && (
<div id='correction-comment-error' role='alert' style={{ color: '#d92d20', fontSize: 12 }}>
{commentError}
</div>
)}
</div>
</div>

View File

@ -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<Pick<KeyedLine, 'line_code' | 'amount'>>): void;
@ -120,7 +122,7 @@ export function DistributionBlock(p: DistProps) {
<span />
{p.lines.map((line, i) => {
const err = p.lineErrors(i);
return <LineRow key={line.id} {...p} line={line} err={err} />;
return <LineRow key={line.id} {...p} line={line} index={i} err={err} />;
})}
</div>
</div>
@ -129,10 +131,16 @@ export function DistributionBlock(p: DistProps) {
type RowProps = Pick<DistProps, 'articleCodes' | 'editable' | 'onRemoveLine' | 'onChangeLine'> & {
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 (
<>
<div>
@ -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 })}>
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label='Строка баланса' />
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label={`Строка баланса, ${where}`} aria-describedby={err.line_code ? codeErrId : undefined} />
</AutoComplete>
{err.line_code && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.line_code}</div>}
{err.line_code && (
<div id={codeErrId} style={{ color: '#d92d20', fontSize: 12 }}>
{err.line_code}
</div>
)}
</div>
<div>
<Input
@ -153,13 +165,18 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
inputMode='decimal'
status={err.amount ? 'error' : undefined}
style={{ textAlign: 'right' }}
aria-label='Сумма статьи'
aria-label={`Сумма статьи, ${where}`}
aria-describedby={err.amount ? amountErrId : undefined}
onChange={(e) => onChangeLine(line.id, { amount: e.target.value })}
/>
{err.amount && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.amount}</div>}
{err.amount && (
<div id={amountErrId} style={{ color: '#d92d20', fontSize: 12 }}>
{err.amount}
</div>
)}
</div>
<div>
{editable && <Button type='text' icon={<CloseOutlined />} aria-label='Удалить статью' onClick={() => onRemoveLine(line.id)} />}
{editable && <Button type='text' icon={<CloseOutlined />} aria-label={`Удалить статью, ${where}`} onClick={() => onRemoveLine(line.id)} />}
</div>
</>
);
@ -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 (
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '10px 12px', fontWeight: 600 }}>
<div aria-live='polite' style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '10px 12px', fontWeight: 600 }}>
<span>{left}</span>
<span style={{ color: red ? '#d92d20' : undefined }}>{middle}</span>
<span style={{ textAlign: 'right' }}>{right}</span>

View File

@ -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();
});
});

View File

@ -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');
/** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */

View File

@ -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)}` };
}