Перенесён диапазон 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>
224 lines
8.3 KiB
TypeScript
224 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|