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(null); const [loadError, setLoadError] = useState(null); const [draft, setDraftState] = useState(emptyDraft); // ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик const [draftErrors, setDraftErrors] = useState([]); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [confirmFailures, setConfirmFailures] = useState([]); const [confirmMessage, setConfirmMessage] = useState(null); const [confirmLines, setConfirmLines] = useState([]); const [duplicateId, setDuplicateId] = useState(null); const navigate = useNavigate(); const [showEmptySublines, setShowEmptySublines] = useState(false); const [showDetails, setShowDetails] = useState(false); const [highlightKey, setHighlightKey] = useState(null); // ошибка элемента lines[] из ответа на сохранение (P6-9); строки на экране нет — текст над таблицей const [lineServerError, setLineServerError] = useState<{ lineId: string; text: string } | null>(null); const [pendingFocus, setPendingFocus] = useState(null); const highlightTimer = useRef(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()), [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() : emptySublines }), [fullLayout, showDetails, showEmptySublines, emptySublines], ); const codeOptions = useMemo(() => (statement && grid ? buildCodeOptions(statement.edition_lines, grid.rows) : []), [statement, grid]); // красные поля кода: ошибки формата из черновика и текст сервера const lineErrors = useMemo(() => { const result: Record = {}; 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 void load()}>Повторить} />; } if (!statement || !grid || !places) { return null; } let confirmDescription: ReactNode; if (confirmFailures.length > 0) { confirmDescription = confirmFailures.map((f) =>
{failureText(f)}
); } else if (confirmLines.length > 0) { confirmDescription = ( ); } else if (duplicateId) { confirmDescription = ( navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму ); } const confirmEnabled = actions.canConfirm && unresolved.length === 0; const cardActions = editing ? ( ) : ( ); return (
{unresolved.length > 0 && ( // настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7) )} setDraft((prev) => ({ ...prev, header: { ...prev.header, [field]: text } }))} onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))} /> {confirmMessage && } {emptySublines.size > 0 && ( )} {details > 0 && ( )} {serverErrorOffScreen && } {counters && (

Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}

)} 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) })) } />
); }