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 { 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='Принять нарушения'
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user