From 65c3dc2de78156ec7cfdddb0c4458886376bf218 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 01:27:36 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=91?= =?UTF-8?q?=D0=91/=D0=9E=D0=A4=D0=A0=20=E2=80=94=20onChanged=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=B0=D0=BA=D1=86=D0=B5?= =?UTF-8?q?=D0=BF=D1=82=D0=B0,=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B1=D0=B5=D0=B7=20no-op=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA,=20=D0=B0=D0=BA=D1=86=D0=B5=D0=BF=D1=82=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=BC=20(I-1,=20M-1,?= =?UTF-8?q?=20M-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- src/widgets/DocumentReview/ChecksPanel.tsx | 14 +++++--- .../DocumentReview/StatementReview.tsx | 8 +++-- src/widgets/DocumentReview/StatementTable.tsx | 4 +-- .../DocumentReview/statementGrid.test.ts | 25 +++++++++++++- src/widgets/DocumentReview/statementGrid.ts | 34 +++++++++++++++++++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/widgets/DocumentReview/ChecksPanel.tsx b/src/widgets/DocumentReview/ChecksPanel.tsx index d71ca4b..993881f 100644 --- a/src/widgets/DocumentReview/ChecksPanel.tsx +++ b/src/widgets/DocumentReview/ChecksPanel.tsx @@ -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 }; +type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise }; const failureList = (list: FailedCheckOut[]) => list.map((f) =>
{failureText(f)}
); -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 && } + {canAccept && ( + + + + )} (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 {h.okud ?? '—'} {h.okpo ?? '—'} - + {confirmMessage && ( setDraft((prev) => ({ ...prev, [key]: text }))} + onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} /> diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 4f98541..0da1baf 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -9,7 +9,7 @@ type Props = { rows: GridRow[]; editable: boolean; draft: Record; - 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' }} /> diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index cec6da3..a0fb130 100644 --- a/src/widgets/DocumentReview/statementGrid.test.ts +++ b/src/widgets/DocumentReview/statementGrid.test.ts @@ -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 => ({ 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' }); + }); +}); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 9b0a76c..ac12d30 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -37,3 +37,37 @@ export function draftToPatch(draft: Record): 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, + key: string, + text: string, + originalAmount: string | null, + parse: (input: string) => { ok: true; value: string | null } | { ok: false }, +): Record { + const parsed = parse(text); + if (parsed.ok && normalizeAmount(parsed.value) === normalizeAmount(originalAmount)) { + const { [key]: _removed, ...rest } = draft; + return rest; + } + return { ...draft, [key]: text }; +}