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-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-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-19: агрегат исчез из ответа — окно открыто, стороны «—», «Сохранить» неактивна, пояснение в плашке; ключ PUT берётся из ответа, поэтому без агрегата сохранить нельзя | Review Focus плана | — |
| 28.10.2026 | R-20: в окне нет названий статей рядом с кодом («дт 1230 Дебиторская задолженность» в дизайне) | в ответе API названий статей нет, а фронт их не выдумывает | добавить поле `article_name` в контракт и показать |

View File

@ -1,5 +1,5 @@
// Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick). // Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick).
import { Modal, Table } from 'antd'; import { Button, Modal, Table } from 'antd';
import type { UnallocatedEntryOut } from '../../types/corrections'; import type { UnallocatedEntryOut } from '../../types/corrections';
import { formatRub } from './editorView'; import { formatRub } from './editorView';
@ -40,6 +40,23 @@ export function AllocationPicker({ open, sideLabel, article, candidates, busy, o
{ title: 'Счёт', dataIndex: 'account_code', width: 120 }, { title: 'Счёт', dataIndex: 'account_code', width: 120 },
{ title: 'Строка баланса', width: 140, render: () => article }, { title: 'Строка баланса', width: 140, render: () => article },
{ title: 'Сумма', dataIndex: 'amount', align: 'right', width: 160, render: (v: string) => formatRub(v) }, { 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> </Modal>

View File

@ -1,14 +1,14 @@
// Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль, // Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль,
// комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу. // комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу.
import { Alert, App, Button, Input, Modal } from 'antd'; 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 { useCurrentUser } from '../../auth/MeContext';
import { allocate, saveCorrection } from '../../services/api/corrections'; import { allocate, saveCorrection } from '../../services/api/corrections';
import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections'; import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections';
import { AllocationPicker } from './AllocationPicker'; import { AllocationPicker } from './AllocationPicker';
import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide'; import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide';
import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage'; 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 { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView';
import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly'; import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly';
import { articleCodes, findAggregate, toAggregateKey } from './tableModel'; 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), 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 — редакция корректировки, от которой начаты правки // savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки
const [savedAt, setSavedAt] = useState<string | null>(start.savedAt); const [savedAt, setSavedAt] = useState<string | null>(start.savedAt);
const [base, setBase] = useState<number | null>(start.base); 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 [formError, setFormError] = useState<string | null>(null);
const [pickerSide, setPickerSide] = useState<SideNo | null>(null); const [pickerSide, setPickerSide] = useState<SideNo | null>(null);
const stateRef = useRef(state);
stateRef.current = state;
const busy = saving || allocating; 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) => { const commit = (next: KeyedState) => {
setState(next); setState(next);
if (!editable || !aggregate) return; if (!edit || !aggregate) return;
const p = plain(next); const p = plain(next);
if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base }); if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base });
else { else {
@ -146,9 +162,6 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
try { try {
const t = await allocate(versionId, body); const t = await allocate(versionId, body);
onTable(t); onTable(t);
// без своих правок суммы по умолчанию следуют за новыми суммами сторон
const fresh = findAggregate(t, aggregateKey);
if (fresh && !isDirty(initial, plain(stateRef.current))) setState(withIds(initialEditorState(fresh)));
return true; return true;
} catch (e) { } catch (e) {
const out = errorOutcome(e, 'Не удалось выполнить действие со строкой'); const out = errorOutcome(e, 'Не удалось выполнить действие со строкой');
@ -171,7 +184,7 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
const validation = validateLines(plain(state)); const validation = validateLines(plain(state));
const save = async () => { const save = async () => {
if (!aggregate || inflight.current || !validation.ok) return; if (!edit || !aggregate || inflight.current || !validation.ok) return;
inflight.current = true; inflight.current = true;
setSaving(true); setSaving(true);
setFormError(null); setFormError(null);
@ -246,9 +259,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={requestClose} disabled={saving}> <Button onClick={requestClose} disabled={saving}>
{editable ? 'Отменить' : 'Закрыть'} {edit ? 'Отменить' : 'Закрыть'}
</Button> </Button>
{editable && ( {edit && (
<Button type='primary' loading={saving} disabled={!aggregate || !validation.ok || allocating} onClick={save}> <Button type='primary' loading={saving} disabled={!aggregate || !validation.ok || allocating} onClick={save}>
Сохранить корректировки Сохранить корректировки
</Button> </Button>
@ -256,15 +269,16 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
</div> </div>
}> }>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{hint && <Alert type='info' showIcon title={hint} />} {hint && <Alert type='info' role='alert' showIcon title={hint} />}
{!aggregate && <Alert type='warning' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />} {!aggregate && <Alert type='warning' role='alert' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
{notice && ( {notice && (
<Alert <Alert
type={notice.kind === 'stale' ? 'warning' : 'info'} type={notice.kind === 'stale' ? 'warning' : 'info'}
role='alert'
showIcon showIcon
title={notice.text} title={notice.text}
action={ action={
editable ? ( edit ? (
<Button size='small' onClick={resetDraft}> <Button size='small' onClick={resetDraft}>
Сбросить Сбросить
</Button> </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={{ background: '#f7faf8', borderRadius: 8, padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<SideHeader company={s1.company} sideLabel={sideLabels[1]} article={s1.article} /> <SideHeader company={s1.company} sideLabel={sideLabels[1]} article={s1.article} />
<SideHeader company={s2.company} sideLabel={sideLabels[2]} article={s2.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={s1.article} entries={s1.entries} editable={edit} 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={s2.article} entries={s2.entries} editable={edit} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
</div> </div>
<ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} /> <ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <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} articleCodes={n === 1 ? s1.codes : s2.codes}
lines={n === 1 ? state.debit : state.credit} lines={n === 1 ? state.debit : state.credit}
lineErrors={lineErrors(n)} lineErrors={lineErrors(n)}
editable={editable} editable={edit}
sideName={n === 1 ? 'дебет' : 'кредит'}
onAddLine={() => editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])} onAddLine={() => editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])}
onRemoveLine={(id) => editLines(n, (ls) => ls.filter((l) => l.id !== id))} 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)))} 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> </div>
<ControlRow {...(allocation ?? { left: '—', middle: '—', right: '—', red: false })} /> <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>
<div> <div>
@ -305,17 +324,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
<Input.TextArea <Input.TextArea
rows={3} rows={3}
value={state.comment} value={state.comment}
disabled={!editable} disabled={!edit}
maxLength={COMMENT_MAX_LENGTH} maxLength={COMMENT_MAX_LENGTH}
status={commentError ? 'error' : undefined} status={commentError ? 'error' : undefined}
placeholder='Укажите причину корректировки' placeholder='Укажите причину корректировки'
aria-label='Комментарий к корректировке' aria-label='Комментарий к корректировке'
aria-describedby={commentError ? 'correction-comment-error' : undefined}
onChange={(e) => { onChange={(e) => {
setCommentError(null); setCommentError(null);
commit({ ...state, comment: e.target.value }); 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>
</div> </div>

View File

@ -97,6 +97,8 @@ type DistProps = {
lines: KeyedLine[]; lines: KeyedLine[];
lineErrors: LineErrors; lineErrors: LineErrors;
editable: boolean; editable: boolean;
/** Сторона в подписях для скринридера: «дебет» / «кредит». */
sideName: string;
onAddLine(): void; onAddLine(): void;
onRemoveLine(id: number): void; onRemoveLine(id: number): void;
onChangeLine(id: number, patch: Partial<Pick<KeyedLine, 'line_code' | 'amount'>>): void; onChangeLine(id: number, patch: Partial<Pick<KeyedLine, 'line_code' | 'amount'>>): void;
@ -120,7 +122,7 @@ export function DistributionBlock(p: DistProps) {
<span /> <span />
{p.lines.map((line, i) => { {p.lines.map((line, i) => {
const err = p.lineErrors(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>
</div> </div>
@ -129,10 +131,16 @@ export function DistributionBlock(p: DistProps) {
type RowProps = Pick<DistProps, 'articleCodes' | 'editable' | 'onRemoveLine' | 'onChangeLine'> & { type RowProps = Pick<DistProps, 'articleCodes' | 'editable' | 'onRemoveLine' | 'onChangeLine'> & {
line: KeyedLine; line: KeyedLine;
/** Номер строки в стороне с нуля. */
index: number;
sideName: string;
err: { line_code?: string; amount?: 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 ( return (
<> <>
<div> <div>
@ -142,9 +150,13 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
disabled={!editable} disabled={!editable}
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(v) => onChangeLine(line.id, { line_code: v })}> 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> </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>
<div> <div>
<Input <Input
@ -153,13 +165,18 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
inputMode='decimal' inputMode='decimal'
status={err.amount ? 'error' : undefined} status={err.amount ? 'error' : undefined}
style={{ textAlign: 'right' }} style={{ textAlign: 'right' }}
aria-label='Сумма статьи' aria-label={`Сумма статьи, ${where}`}
aria-describedby={err.amount ? amountErrId : undefined}
onChange={(e) => onChangeLine(line.id, { amount: e.target.value })} 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>
<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> </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 }) { export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) {
return ( 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>{left}</span>
<span style={{ color: red ? '#d92d20' : undefined }}>{middle}</span> <span style={{ color: red ? '#d92d20' : undefined }}>{middle}</span>
<span style={{ textAlign: 'right' }}>{right}</span> <span style={{ textAlign: 'right' }}>{right}</span>

View File

@ -6,6 +6,7 @@ import {
isDirty, isDirty,
liveControl, liveControl,
pickerCandidates, pickerCandidates,
rebaseState,
toCorrectionIn, toCorrectionIn,
validateLines, validateLines,
} from './editorModel'; } from './editorModel';
@ -230,3 +231,18 @@ describe('pickerCandidates', () => {
expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); 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; 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'); const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D');
/** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */ /** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */

View File

@ -2,7 +2,7 @@
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import type { CorrectionAggregateOut } from '../../types/corrections'; import type { CorrectionAggregateOut } from '../../types/corrections';
import { formatScaledAmount } from '../../utils/money'; import { formatScaledAmount } from '../../utils/money';
import type { Draft } from './draftStorage'; import { type Draft, isStale } from './draftStorage';
import type { Diff } from './editorModel'; import type { Diff } from './editorModel';
import { errorOutcome } from './readOnly'; 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 { export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null {
if (savedAt === null) return 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)}` }; return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` };
} }