- маршрут /documents/:document_id возвращён: старые ссылки («Открыть подтверждённую форму») снова открывают документ; ЮЛ и консолидация для переключателя документов берутся из документа и его файла, а не из адреса; в списке переключателя — вторая строка (тип · формат · дата), ББ и ОФР одного PDF различимы - чек-лист: ОФР — ячейка INCOME (было 'OFR' → всегда «Отсутствует»), сборка строк — buildChecklistRows с тестами, rowKey по ЮЛ, типы ответа GET …/checklist - удаление файла по id файла (fileId строки), а не по id документа (было 404); ошибка — без закрытия окна - замена: настоящая загрузка нового файла в ту же консолидацию (автозамена ядра, 05-domain-rules §9.5 п. 3), причина — комментарием к заменяемому документу; кнопка доступна только в версии в работе - комментарий: путь без хвостового «/», типы, закрытие только после успеха, лимит 4000 - счётчики над таблицей формы — модуль reviewCounters (по needs_review, как подсветка) с тестами; buildStatementGrid возвращён к test (падали 3 теста) - PageTitle и store возвращены к test (ширина переключателя — внутри DocumentPage; имя ЮЛ — пропсом) - цвет подписей полей в теме: #667085 (опечатка #665087) - типы во всех новых компонентах: tsc 0 ошибок, ESLint/biome без ошибок, vitest 331/331, сборка Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||
import { Alert, Button, Space, Splitter, Switch, Typography, message } from 'antd';
|
||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ApiError } from '../../api/client';
|
||
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
||
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
|
||
import { parseAmountInput } from '../../utils/money';
|
||
import { ChecksPanel } from './ChecksPanel';
|
||
import { IssuesPanel } from './IssuesPanel';
|
||
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
||
import { SourcePreview } from './SourcePreview';
|
||
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
||
import { StatementTable } from './StatementTable';
|
||
import { failureKey, failureText } from './checkText';
|
||
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
|
||
import { confirmErrorInfo } from './confirmError';
|
||
import { switchesFor } from './focusTarget';
|
||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||
import { issueListItems, issuePlaces } from './issuePlaces';
|
||
import { applyLineMenu, showsCodeInput } from './lineMenu';
|
||
import { countReviewValues } from './reviewCounters';
|
||
import { reviewActions } from './reviewView';
|
||
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
|
||
import { applyDraftInput, buildStatementGrid, 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, setDraftState] = useState<StatementDraft>(emptyDraft);
|
||
// ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик
|
||
const [draftErrors, setDraftErrors] = useState<DraftError[]>([]);
|
||
const [editing, setEditing] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
||
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
|
||
const [confirmLines, setConfirmLines] = useState<UnresolvedLineOut[]>([]);
|
||
const [duplicateId, setDuplicateId] = useState<string | null>(null);
|
||
const navigate = useNavigate();
|
||
const [showEmptySublines, setShowEmptySublines] = useState(false);
|
||
const [showDetails, setShowDetails] = useState(false);
|
||
const [highlightKey, setHighlightKey] = useState<string | null>(null);
|
||
// ошибка элемента lines[] из ответа на сохранение (P6-9); строки на экране нет — текст над таблицей
|
||
const [lineServerError, setLineServerError] = useState<{ lineId: string; text: string } | null>(null);
|
||
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
|
||
const highlightTimer = useRef<number | undefined>(undefined);
|
||
|
||
const setDraft = (update: (prev: StatementDraft) => StatementDraft) => {
|
||
setDraftState(update);
|
||
setDraftErrors([]);
|
||
setLineServerError(null);
|
||
};
|
||
const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]);
|
||
|
||
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]);
|
||
// счётчики над таблицей — только пока форма на проверке
|
||
const counters = useMemo(
|
||
() => (statement && statement.document.status === 'NEEDS_CHECK' ? countReviewValues(statement) : null),
|
||
[statement],
|
||
);
|
||
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
|
||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
|
||
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
|
||
const layout = useMemo(
|
||
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
|
||
[fullLayout, showDetails, showEmptySublines, emptySublines],
|
||
);
|
||
const codeOptions = useMemo(() => (statement && grid ? buildCodeOptions(statement.edition_lines, grid.rows) : []), [statement, grid]);
|
||
// красные поля кода: ошибки формата из черновика и текст сервера
|
||
const lineErrors = useMemo(() => {
|
||
const result: Record<string, string> = {};
|
||
for (const error of draftErrors) if (error.kind === 'code' || error.kind === 'conflict') result[error.key] = error.message;
|
||
if (lineServerError) result[lineServerError.lineId] = lineServerError.text;
|
||
return result;
|
||
}, [draftErrors, lineServerError]);
|
||
const serverErrorOffScreen =
|
||
lineServerError !== null &&
|
||
!layout.some(
|
||
(item) =>
|
||
item.kind === 'line' &&
|
||
item.row.lineId === lineServerError.lineId &&
|
||
editing &&
|
||
showsCodeInput(item.row, draft.lines[lineServerError.lineId]),
|
||
);
|
||
const places = useMemo(
|
||
() => (grid && statement ? issuePlaces(statement.issues ?? [], grid.rows, grid.columns) : null),
|
||
[grid, statement],
|
||
);
|
||
const issueItems = useMemo(
|
||
() => (grid && statement && places ? issueListItems(statement.issues ?? [], places, grid.rows, grid.columns) : []),
|
||
[grid, statement, places],
|
||
);
|
||
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
|
||
const focusRow = (key: string) => {
|
||
if (!grid) return;
|
||
const need = switchesFor(key, grid.rows, { showDetails, showEmptySublines, emptySublines });
|
||
if (need.details) setShowDetails(true);
|
||
if (need.emptySublines) setShowEmptySublines(true);
|
||
setPendingFocus(key);
|
||
};
|
||
useEffect(() => {
|
||
if (pendingFocus === null) return;
|
||
window.document.getElementById(`row-${pendingFocus}`)?.scrollIntoView({ block: 'center' });
|
||
setHighlightKey(pendingFocus);
|
||
setPendingFocus(null);
|
||
window.clearTimeout(highlightTimer.current);
|
||
highlightTimer.current = window.setTimeout(() => setHighlightKey(null), 2000);
|
||
}, [pendingFocus]);
|
||
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
|
||
const details = grid ? detailCount(grid.rows) : 0;
|
||
const unresolved = useMemo(() => (grid ? unresolvedLines(grid.rows) : []), [grid]);
|
||
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
|
||
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
||
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
|
||
const dirty = !isDraftEmpty(draft);
|
||
|
||
const save = async () => {
|
||
if (!statement || !grid) return;
|
||
const built = buildStatementPatch(draft, { rows: grid.rows, header: statement.header });
|
||
if (!built.ok) {
|
||
setDraftErrors(built.errors);
|
||
message.error(built.errors[0].message);
|
||
return;
|
||
}
|
||
if (built.body === null) {
|
||
cancel();
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
|
||
setStatement(await patchStatement(document.id, built.body));
|
||
setDraft(() => emptyDraft());
|
||
setEditing(false);
|
||
setConfirmFailures([]);
|
||
setConfirmMessage(null);
|
||
resetConfirmError();
|
||
message.success('Изменения сохранены');
|
||
onChanged();
|
||
} catch (e: unknown) {
|
||
const target = lineErrorTarget(e);
|
||
if (target) setLineServerError({ lineId: target.lineId, text: errorText(e, 'Не удалось сохранить строку') });
|
||
message.error(errorText(e, 'Не удалось сохранить изменения'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const cancel = () => {
|
||
setDraft(() => emptyDraft());
|
||
setEditing(false);
|
||
};
|
||
|
||
const accept = async (comment: string) => {
|
||
try {
|
||
setStatement(await acceptChecks(document.id, comment));
|
||
onChanged();
|
||
setConfirmFailures([]);
|
||
setConfirmMessage(null);
|
||
resetConfirmError();
|
||
} catch (e: unknown) {
|
||
message.error(errorText(e, 'Не удалось принять нарушения'));
|
||
throw e;
|
||
}
|
||
};
|
||
|
||
const resetConfirmError = () => {
|
||
setConfirmLines([]);
|
||
setDuplicateId(null);
|
||
};
|
||
|
||
const confirm = async () => {
|
||
try {
|
||
await confirmDocument(document.id);
|
||
message.success('Документ подтверждён');
|
||
setConfirmFailures([]);
|
||
setConfirmMessage(null);
|
||
resetConfirmError();
|
||
onChanged();
|
||
await load();
|
||
} catch (e: unknown) {
|
||
const info = confirmErrorInfo(e);
|
||
setConfirmFailures(info.failures);
|
||
setConfirmLines(info.lines);
|
||
setDuplicateId(info.code === 'DUPLICATE_FORM' ? info.documentId : null);
|
||
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 || !places) {
|
||
return null;
|
||
}
|
||
|
||
let confirmDescription: ReactNode;
|
||
if (confirmFailures.length > 0) {
|
||
confirmDescription = confirmFailures.map((f) => <div key={failureKey(f)}>{failureText(f)}</div>);
|
||
} else if (confirmLines.length > 0) {
|
||
confirmDescription = (
|
||
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(confirmLines[0].line_id)}>
|
||
{unresolvedHint(confirmLines.length)}
|
||
</Button>
|
||
);
|
||
} else if (duplicateId) {
|
||
confirmDescription = (
|
||
<Typography.Link onClick={() => navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму</Typography.Link>
|
||
);
|
||
}
|
||
const confirmEnabled = actions.canConfirm && unresolved.length === 0;
|
||
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={confirmEnabled ? confirmButtonStyle : undefined}
|
||
disabled={!confirmEnabled}
|
||
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%' }}>
|
||
{unresolved.length > 0 && (
|
||
// настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7)
|
||
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(unresolved[0].key)}>
|
||
{unresolvedHint(unresolved.length)}
|
||
</Button>
|
||
)}
|
||
<StatementHeaderBlock
|
||
header={statement.header}
|
||
documentType={statement.document.document_type}
|
||
editing={editing}
|
||
draft={draft.header}
|
||
byField={places.byField}
|
||
errorKeys={errorKeys}
|
||
onDraftChange={(field, text) => setDraft((prev) => ({ ...prev, header: { ...prev.header, [field]: text } }))}
|
||
onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))}
|
||
/>
|
||
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
|
||
<IssuesPanel mode='statement' items={issueItems} onShow={focusRow} />
|
||
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
|
||
{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>
|
||
)}
|
||
{details > 0 && (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||
<Switch id='show-details' size='small' checked={showDetails} onChange={setShowDetails} />
|
||
<label htmlFor='show-details' style={{ cursor: 'pointer' }}>
|
||
Показать расшифровки ({details})
|
||
</label>
|
||
</span>
|
||
)}
|
||
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
|
||
{counters && (
|
||
<p style={{ color: 'red' }}>
|
||
Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}
|
||
</p>
|
||
)}
|
||
<StatementTable
|
||
columns={grid.columns}
|
||
layout={layout}
|
||
places={places}
|
||
highlightKey={highlightKey}
|
||
editable={editing && actions.canSave}
|
||
draft={draft.amounts}
|
||
lineActions={draft.lines}
|
||
codeOptions={codeOptions}
|
||
lineErrors={lineErrors}
|
||
onLineCodeChange={(row, code) =>
|
||
row.lineId && setDraft((prev) => ({ ...prev, lines: { ...prev.lines, [row.lineId as string]: { kind: 'code', code } } }))
|
||
}
|
||
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
|
||
errorKeys={errorKeys}
|
||
fullDraft={draft}
|
||
onConfirmToggle={(key, state) =>
|
||
setDraft((prev) => {
|
||
const confirms = { ...prev.confirms };
|
||
// повторный клик по нажатой кнопке убирает подтверждение из черновика
|
||
if (state === 'pending-confirm' || state === 'pending-unconfirm') delete confirms[key];
|
||
else confirms[key] = state === 'confirm';
|
||
return { ...prev, confirms };
|
||
})
|
||
}
|
||
onColumnDateChange={(columnId, text) => setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [columnId]: text } }))}
|
||
onColumnDateConfirm={(col) =>
|
||
col.period_date && setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [col.id]: col.period_date as string } }))
|
||
}
|
||
onDraftChange={(key, text, original) =>
|
||
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
|
||
}
|
||
/>
|
||
</Space>
|
||
</ReviewCard>
|
||
</div>
|
||
</Splitter.Panel>
|
||
</Splitter>
|
||
);
|
||
}
|