fix: форма ББ/ОФР — onChanged после сохранения и акцепта, черновик без no-op правок, акцепт блокируется при несохранённом (I-1, M-1, M-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 01:27:36 +04:00
parent 615201c925
commit 65c3dc2de7
5 changed files with 75 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import { Alert, Button, Input, Modal, Space, Typography } from 'antd'; import { Alert, Button, Input, Modal, Space, Tooltip, Typography } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import type { ChecksOut, FailedCheckOut } from '../../types/api'; import type { ChecksOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money'; import { formatAmount } from '../../utils/money';
@ -6,11 +6,11 @@ import { formatAmount } from '../../utils/money';
export const failureText = (f: FailedCheckOut) => export const failureText = (f: FailedCheckOut) =>
`Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`; `Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`;
type Props = { checks: ChecksOut; editable: boolean; onAccept: (comment: string) => Promise<void> }; type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> };
const failureList = (list: FailedCheckOut[]) => list.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>); const failureList = (list: FailedCheckOut[]) => list.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
export function ChecksPanel({ checks, editable, onAccept }: Props) { export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -56,7 +56,13 @@ export function ChecksPanel({ checks, editable, onAccept }: Props) {
description={`${new Date(checks.accepted.at).toLocaleString('ru-RU')}: ${checks.accepted.comment}`} description={`${new Date(checks.accepted.at).toLocaleString('ru-RU')}: ${checks.accepted.comment}`}
/> />
)} )}
{canAccept && <Button onClick={() => setOpen(true)}>Принять с комментарием</Button>} {canAccept && (
<Tooltip title={dirty ? 'Сначала сохраните изменения' : undefined}>
<Button disabled={dirty} onClick={() => setOpen(true)}>
Принять с комментарием
</Button>
</Tooltip>
)}
<Modal <Modal
open={open} open={open}
title='Принять нарушения' title='Принять нарушения'

View File

@ -8,7 +8,7 @@ import { ChecksPanel, failureText } from './ChecksPanel';
import { SourcePreview } from './SourcePreview'; import { SourcePreview } from './SourcePreview';
import { StatementTable } from './StatementTable'; import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError'; import { confirmErrorInfo } from './confirmError';
import { buildStatementGrid, draftToPatch } from './statementGrid'; import { applyDraftInput, buildStatementGrid, draftToPatch } from './statementGrid';
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback); const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
@ -78,6 +78,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
setConfirmFailures([]); setConfirmFailures([]);
setConfirmMessage(null); setConfirmMessage(null);
message.success('Изменения сохранены'); message.success('Изменения сохранены');
onChanged();
} catch (e: unknown) { } catch (e: unknown) {
message.error(errorText(e, 'Не удалось сохранить изменения')); message.error(errorText(e, 'Не удалось сохранить изменения'));
} finally { } finally {
@ -88,6 +89,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const accept = async (comment: string) => { const accept = async (comment: string) => {
try { try {
setStatement(await acceptChecks(document.id, comment)); setStatement(await acceptChecks(document.id, comment));
onChanged();
setConfirmFailures([]); setConfirmFailures([]);
setConfirmMessage(null); setConfirmMessage(null);
} catch (e: unknown) { } catch (e: unknown) {
@ -143,7 +145,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<Descriptions.Item label='ОКУД'>{h.okud ?? '—'}</Descriptions.Item> <Descriptions.Item label='ОКУД'>{h.okud ?? '—'}</Descriptions.Item>
<Descriptions.Item label='ОКПО'>{h.okpo ?? '—'}</Descriptions.Item> <Descriptions.Item label='ОКПО'>{h.okpo ?? '—'}</Descriptions.Item>
</Descriptions> </Descriptions>
<ChecksPanel checks={statement.checks} editable={editable} onAccept={accept} /> <ChecksPanel checks={statement.checks} editable={editable} dirty={dirty} onAccept={accept} />
{confirmMessage && ( {confirmMessage && (
<Alert <Alert
type='error' type='error'
@ -170,7 +172,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
rows={grid.rows} rows={grid.rows}
editable={editable} editable={editable}
draft={draft} draft={draft}
onDraftChange={(key, text) => setDraft((prev) => ({ ...prev, [key]: text }))} onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
/> />
</Space> </Space>
</Splitter.Panel> </Splitter.Panel>

View File

@ -9,7 +9,7 @@ type Props = {
rows: GridRow[]; rows: GridRow[];
editable: boolean; editable: boolean;
draft: Record<string, string>; draft: Record<string, string>;
onDraftChange: (key: string, text: string) => void; onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
}; };
const ATTENTION_BG = '#fff7e6'; const ATTENTION_BG = '#fff7e6';
@ -60,7 +60,7 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange }
size='small' size='small'
status={invalid ? 'error' : undefined} status={invalid ? 'error' : undefined}
value={text} value={text}
onChange={(e) => onDraftChange(key, e.target.value)} onChange={(e) => onDraftChange(key, e.target.value, value.amount)}
style={{ textAlign: 'right' }} style={{ textAlign: 'right' }}
/> />
</Tooltip> </Tooltip>

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api'; import type { StatementOut, ValueOut } from '../../types/api';
import { buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid'; import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
const v = (id: string, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({ const v = (id: string, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
id, id,
@ -54,3 +54,26 @@ describe('draftToPatch', () => {
]); ]);
}); });
}); });
describe('applyDraftInput', () => {
const parse = (s: string): { ok: true; value: string | null } | { ok: false } => {
const n = s.replace(/\s/g, '').replace(',', '.');
if (n === '') {
return { ok: true, value: null };
}
return /^-?\d+(\.\d+)?$/.test(n) ? { ok: true, value: n } : { ok: false };
};
it('изменение попадает в черновик', () => {
expect(applyDraftInput({}, 'k', '5', '1.00', parse)).toEqual({ k: '5' });
});
it('возврат к исходному убирает ключ (с учётом нулей дроби)', () => {
expect(applyDraftInput({ k: '5' }, 'k', '1', '1.00', parse)).toEqual({});
expect(applyDraftInput({ k: '5' }, 'k', '1,0', '1.00', parse)).toEqual({});
});
it('пустое при пустом исходном — не изменение', () => {
expect(applyDraftInput({ k: '5' }, 'k', '', null, parse)).toEqual({});
});
it('неверный формат остаётся в черновике', () => {
expect(applyDraftInput({}, 'k', '12,3,4', '1.00', parse)).toEqual({ k: '12,3,4' });
});
});

View File

@ -37,3 +37,37 @@ export function draftToPatch(draft: Record<string, string | null>): NonNullable<
return { line_id, column_id, amount }; return { line_id, column_id, amount };
}); });
} }
/** Каноничный вид суммы для сравнения строк: без хвостовых нулей дроби и лишних ведущих нулей. */
function normalizeAmount(value: string | null): string | null {
if (value === null) {
return null;
}
const match = /^(-?)(\d+)(?:\.(\d*))?$/.exec(value);
if (!match) {
return value;
}
const int = match[2].replace(/^0+(?=\d)/, '');
const frac = (match[3] ?? '').replace(/0+$/, '');
const body = frac ? `${int}.${frac}` : int;
return body === '0' ? '0' : `${match[1]}${body}`;
}
/**
* Обновить черновик после ввода в ячейку: если значение совпало с исходным — ключ убирается
* (в PATCH уходят только реальные изменения). Неверный формат остаётся в черновике (подсвечивается и блокирует сохранение).
*/
export function applyDraftInput(
draft: Record<string, string>,
key: string,
text: string,
originalAmount: string | null,
parse: (input: string) => { ok: true; value: string | null } | { ok: false },
): Record<string, string> {
const parsed = parse(text);
if (parsed.ok && normalizeAmount(parsed.value) === normalizeAmount(originalAmount)) {
const { [key]: _removed, ...rest } = draft;
return rest;
}
return { ...draft, [key]: text };
}