arm-core/web/src/widgets/DocumentReview/StatementReview.tsx
Andrey Epifancev 4ba339914c fix(web): синхронизация встроенного фронта с arm-web@test (1237105) (#120)
Перенесён диапазон arm-web 025233f..1237105 (3 коммита, 8 файлов набора синхронизации)
по шагам docs/scripts/sync-web.sh. После переноса web/ и arm-web@test идентичны по набору.
Коммиты arm-web:
- 54f88e9 feat(DocumentReview): подстроки под родителем, пустые строки детализации свёрнуты (#34, AURORA-1527)
- 85e1b86 fix(ChecksPanel): заголовок мягких проверок «Предупреждения проверок (N)» (#34, AURORA-1527)
- a4f5481 fix(DocumentReview): label переключателя пустых строк связан через htmlFor (#36, AURORA-1527)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:41:01 +04:00

224 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
import { SourcePreview } from './SourcePreview';
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [statement, setStatement] = useState<StatementOut | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const [header, setHeader] = useState<HeaderDraft>({});
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const [showEmptySublines, setShowEmptySublines] = useState(false);
const load = useCallback(async () => {
try {
setStatement(await getStatement(document.id));
setLoadError(null);
} catch (e: unknown) {
setLoadError(errorText(e, 'Не удалось получить форму'));
}
}, [document.id]);
useEffect(() => {
void load();
}, [load]);
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set<string>()), [grid, draft]);
const shownRows = useMemo(() => {
if (!grid) return [];
return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key));
}, [grid, showEmptySublines, emptySublines]);
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0;
const save = async () => {
const values: Record<string, string | null> = {};
for (const [key, text] of Object.entries(draft)) {
const parsed = parseAmountInput(text);
if (!parsed.ok) {
message.error('Исправьте суммы с неверным форматом');
return;
}
values[key] = parsed.value;
}
const body: StatementPatch = {};
if (Object.keys(values).length > 0) {
body.values = draftToPatch(values, grid?.rows ?? []);
}
const headerPatch: HeaderDraft = {};
for (const field of ['inn', 'report_date', 'units'] as const) {
const text = header[field]?.trim();
if (text) {
headerPatch[field] = text;
}
}
if (Object.keys(headerPatch).length > 0) {
body.header = headerPatch;
}
if (!body.values && !body.header) {
cancel();
return;
}
setSaving(true);
try {
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
setStatement(await patchStatement(document.id, body));
setDraft({});
setHeader({});
setEditing(false);
setConfirmFailures([]);
setConfirmMessage(null);
message.success('Изменения сохранены');
onChanged();
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось сохранить изменения'));
} finally {
setSaving(false);
}
};
const cancel = () => {
setDraft({});
setHeader({});
setEditing(false);
};
const accept = async (comment: string) => {
try {
setStatement(await acceptChecks(document.id, comment));
onChanged();
setConfirmFailures([]);
setConfirmMessage(null);
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось принять нарушения'));
throw e;
}
};
const confirm = async () => {
try {
await confirmDocument(document.id);
message.success('Документ подтверждён');
setConfirmFailures([]);
setConfirmMessage(null);
onChanged();
await load();
} catch (e: unknown) {
const info = confirmErrorInfo(e);
setConfirmFailures(info.failures);
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
}
};
if (loadError) {
return <Alert type='error' showIcon title={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />;
}
if (!statement || !grid) {
return null;
}
const cardActions = editing ? (
<Space>
<Button
type='primary'
icon={<CheckOutlined />}
style={confirmButtonStyle}
loading={saving}
disabled={!actions.canSave}
onClick={save}>
Сохранить
</Button>
<Button onClick={cancel}>Отменить</Button>
</Space>
) : (
<Space>
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
Редактировать
</Button>
<Button
type='primary'
icon={<CheckOutlined />}
style={actions.canConfirm ? confirmButtonStyle : undefined}
disabled={!actions.canConfirm}
onClick={confirm}>
Подтвердить данные
</Button>
</Space>
);
return (
<Splitter style={{ height: '100%' }}>
<Splitter.Panel defaultSize='45%' min='25%'>
<div style={{ height: '100%', paddingRight: 6 }}>
<SourcePreview fileId={statement.document.file_id} />
</div>
</Splitter.Panel>
<Splitter.Panel>
<div style={{ height: '100%', paddingLeft: 6 }}>
<ReviewCard title='Результат распознавания' extra={cardActions}>
<Space orientation='vertical' size={12} style={{ width: '100%' }}>
<StatementHeaderBlock
header={statement.header}
documentType={statement.document.document_type}
editing={editing}
draft={header}
onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))}
/>
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} />
{confirmMessage && (
<Alert
type='error'
showIcon
title={confirmMessage}
description={
confirmFailures.length > 0
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
: undefined
}
/>
)}
{emptySublines.size > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
<label htmlFor='show-empty-sublines' style={{ cursor: 'pointer' }}>
Показать пустые строки детализации ({emptySublines.size})
</label>
</span>
)}
<StatementTable
documentType={statement.document.document_type}
columns={grid.columns}
rows={shownRows}
editable={editing && actions.canSave}
draft={draft}
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
/>
</Space>
</ReviewCard>
</div>
</Splitter.Panel>
</Splitter>
);
}