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:
parent
615201c925
commit
65c3dc2de7
@ -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 type { ChecksOut, FailedCheckOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
@ -6,11 +6,11 @@ import { formatAmount } from '../../utils/money';
|
||||
export const failureText = (f: FailedCheckOut) =>
|
||||
`Колонка ${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>);
|
||||
|
||||
export function ChecksPanel({ checks, editable, onAccept }: Props) {
|
||||
export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
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}`}
|
||||
/>
|
||||
)}
|
||||
{canAccept && <Button onClick={() => setOpen(true)}>Принять с комментарием</Button>}
|
||||
{canAccept && (
|
||||
<Tooltip title={dirty ? 'Сначала сохраните изменения' : undefined}>
|
||||
<Button disabled={dirty} onClick={() => setOpen(true)}>
|
||||
Принять с комментарием
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Modal
|
||||
open={open}
|
||||
title='Принять нарушения'
|
||||
|
||||
@ -8,7 +8,7 @@ import { ChecksPanel, failureText } from './ChecksPanel';
|
||||
import { SourcePreview } from './SourcePreview';
|
||||
import { StatementTable } from './StatementTable';
|
||||
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);
|
||||
|
||||
@ -78,6 +78,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
setConfirmFailures([]);
|
||||
setConfirmMessage(null);
|
||||
message.success('Изменения сохранены');
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
message.error(errorText(e, 'Не удалось сохранить изменения'));
|
||||
} finally {
|
||||
@ -88,6 +89,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
const accept = async (comment: string) => {
|
||||
try {
|
||||
setStatement(await acceptChecks(document.id, comment));
|
||||
onChanged();
|
||||
setConfirmFailures([]);
|
||||
setConfirmMessage(null);
|
||||
} 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.okpo ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<ChecksPanel checks={statement.checks} editable={editable} onAccept={accept} />
|
||||
<ChecksPanel checks={statement.checks} editable={editable} dirty={dirty} onAccept={accept} />
|
||||
{confirmMessage && (
|
||||
<Alert
|
||||
type='error'
|
||||
@ -170,7 +172,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
rows={grid.rows}
|
||||
editable={editable}
|
||||
draft={draft}
|
||||
onDraftChange={(key, text) => setDraft((prev) => ({ ...prev, [key]: text }))}
|
||||
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
|
||||
/>
|
||||
</Space>
|
||||
</Splitter.Panel>
|
||||
|
||||
@ -9,7 +9,7 @@ type Props = {
|
||||
rows: GridRow[];
|
||||
editable: boolean;
|
||||
draft: Record<string, string>;
|
||||
onDraftChange: (key: string, text: string) => void;
|
||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||
};
|
||||
|
||||
const ATTENTION_BG = '#fff7e6';
|
||||
@ -60,7 +60,7 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange }
|
||||
size='small'
|
||||
status={invalid ? 'error' : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(key, e.target.value)}
|
||||
onChange={(e) => onDraftChange(key, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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 => ({
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,3 +37,37 @@ export function draftToPatch(draft: Record<string, string | null>): NonNullable<
|
||||
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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user