diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx index 528e048..712b180 100644 --- a/src/layouts/DocumentPage.tsx +++ b/src/layouts/DocumentPage.tsx @@ -8,6 +8,7 @@ import { getDocument } from '../services/api/documents'; import type { DocumentOut } from '../types/api'; import { documentStatusLabel, documentTypeLabel, periodLabel } from '../utils/labels'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; +import { StatementReview } from '../widgets/DocumentReview/StatementReview'; import { documentViewKind } from '../widgets/DocumentReview/viewKind'; export function DocumentPage() { @@ -55,7 +56,7 @@ export function DocumentPage() {
{broken && } {!broken && kind === 'osv' && } - {!broken && kind === 'statement' && } + {!broken && kind === 'statement' && } {kind === 'none' && }
diff --git a/src/widgets/DocumentReview/ChecksPanel.tsx b/src/widgets/DocumentReview/ChecksPanel.tsx new file mode 100644 index 0000000..d71ca4b --- /dev/null +++ b/src/widgets/DocumentReview/ChecksPanel.tsx @@ -0,0 +1,74 @@ +import { Alert, Button, Input, Modal, Space, Typography } from 'antd'; +import { useState } from 'react'; +import type { ChecksOut, FailedCheckOut } from '../../types/api'; +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 }; + +const failureList = (list: FailedCheckOut[]) => list.map((f) =>
{failureText(f)}
); + +export function ChecksPanel({ checks, editable, onAccept }: Props) { + const [open, setOpen] = useState(false); + const [comment, setComment] = useState(''); + const [saving, setSaving] = useState(false); + const canAccept = editable && checks.failed.length > 0 && checks.accepted === null; + + const submit = async () => { + setSaving(true); + try { + await onAccept(comment.trim()); + setOpen(false); + setComment(''); + } catch { + // ошибку уже показал вызывающий; окно остаётся открытым, чтобы не потерять комментарий + } finally { + setSaving(false); + } + }; + + return ( + + {checks.failed.length === 0 && checks.soft_failed.length === 0 && !checks.cross_form && ( + + )} + {checks.failed.length > 0 && ( + + )} + {checks.soft_failed.length > 0 && ( + + )} + {checks.cross_form && ( + + )} + {checks.accepted && ( + + )} + {canAccept && } + setOpen(false)}> + Комментарий обязателен (до 2000 символов). + setComment(e.target.value)} /> + + + ); +} diff --git a/src/widgets/DocumentReview/PdfPages.tsx b/src/widgets/DocumentReview/PdfPages.tsx index 8704a46..f6c14ce 100644 --- a/src/widgets/DocumentReview/PdfPages.tsx +++ b/src/widgets/DocumentReview/PdfPages.tsx @@ -1,15 +1,17 @@ import * as pdfjsLib from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs?url', import.meta.url).toString(); import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Config } from '../../conf/config'; + +// worker собирается вместе с приложением (без CDN) +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); + const MIN_ZOOM = 0.5; const MAX_ZOOM = 3; const ZOOM_STEP = 0.25; const PAGE_MARGIN = 10; // left + right margin of each page wrapper -export default function PdfJsBlock({ fileId }: { fileId: string }) { +export default function PdfPages({ data }: { data: ArrayBuffer }) { const containerRef = useRef(null); const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); const [pdfDoc, setPdfDoc] = useState(null); @@ -17,14 +19,14 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) { const [zoom, setZoom] = useState(1); // 1 = fit wrapper width const [baseScale, setBaseScale] = useState(1); const [pageWidthPt, setPageWidthPt] = useState(0); + const [error, setError] = useState(null); useEffect(() => { let pdf: PDFDocumentProxy | null = null; let cancelled = false; async function load() { - const res = await fetch(`${Config.API_URL}/files/${fileId}`); - const buffer = await res.arrayBuffer(); - pdf = await pdfjsLib.getDocument({ data: buffer }).promise; + // pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно) + pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise; if (cancelled) { pdf.destroy(); return; @@ -35,12 +37,16 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) { setPdfDoc(pdf); setNumPages(pdf.numPages); } - load(); + load().catch(() => { + if (!cancelled) { + setError('Не удалось открыть PDF'); + } + }); return () => { cancelled = true; if (pdf) pdf.destroy(); }; - }, [fileId]); + }, [data]); // fit scale = wrapper inner width / page width. // wrapper width = container client width - container padding - page margins @@ -108,6 +114,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) { const zoomOut = useCallback(() => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2))), []); const resetZoom = useCallback(() => setZoom(1), []); + if (error) return
{error}
; if (!pdfDoc) return
Загрузка PDF...
; return ( @@ -119,7 +126,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) { minWidth: 0, }}>
- Source FILE — {numPages} pages + Исходный файл — страниц: {numPages} - {Math.round(zoom * 100)}% - - +
@@ -182,7 +191,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) { background: '#f1f1f1', borderBottom: '1px solid #ddd', }}> - Page {i + 1} of {numPages} + Страница {i + 1} из {numPages} { diff --git a/src/widgets/DocumentReview/SourcePreview.tsx b/src/widgets/DocumentReview/SourcePreview.tsx new file mode 100644 index 0000000..8c41b4a --- /dev/null +++ b/src/widgets/DocumentReview/SourcePreview.tsx @@ -0,0 +1,66 @@ +import { Button, Empty, Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { fetchFileContent } from '../../services/api/files'; +import { saveBlob } from '../../utils/download'; +import PdfPages from './PdfPages'; + +type Loaded = { kind: 'pdf'; data: ArrayBuffer } | { kind: 'image'; url: string } | { kind: 'other'; blob: Blob; filename: string }; + +export function SourcePreview({ fileId }: { fileId: string }) { + const [loaded, setLoaded] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + setLoaded(null); + setError(null); + fetchFileContent(fileId) + .then(async ({ blob, filename }) => { + if (cancelled) { + return; + } + if (blob.type === 'application/pdf') { + const data = await blob.arrayBuffer(); + if (!cancelled) { + setLoaded({ kind: 'pdf', data }); + } + } else if (blob.type.startsWith('image/')) { + objectUrl = URL.createObjectURL(blob); + setLoaded({ kind: 'image', url: objectUrl }); + } else { + setLoaded({ kind: 'other', blob, filename: filename ?? 'исходный_файл' }); + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setError(e instanceof ApiError ? e.message : 'Не удалось получить исходный файл'); + } + }); + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [fileId]); + + if (error) { + return ; + } + if (!loaded) { + return ; + } + if (loaded.kind === 'pdf') { + return ; + } + if (loaded.kind === 'image') { + return Исходный документ; + } + return ( + + + + ); +} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx new file mode 100644 index 0000000..4b9f474 --- /dev/null +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -0,0 +1,179 @@ +import { Alert, Button, Descriptions, Input, Space, Splitter, 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 { SourcePreview } from './SourcePreview'; +import { StatementTable } from './StatementTable'; +import { confirmErrorInfo } from './confirmError'; +import { buildStatementGrid, draftToPatch } from './statementGrid'; + +const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback); + +type HeaderDraft = { inn?: string; report_date?: string; units?: string }; + +export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { + const [statement, setStatement] = useState(null); + const [loadError, setLoadError] = useState(null); + const [draft, setDraft] = useState>({}); + const [header, setHeader] = useState({}); + const [saving, setSaving] = useState(false); + const [confirmFailures, setConfirmFailures] = useState([]); + const [confirmMessage, setConfirmMessage] = useState(null); + + 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 editable = 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 = {}; + 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); + } + 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) { + setDraft({}); + setHeader({}); + return; + } + setSaving(true); + try { + // ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт + setStatement(await patchStatement(document.id, body)); + setDraft({}); + setHeader({}); + setConfirmFailures([]); + setConfirmMessage(null); + message.success('Изменения сохранены'); + } catch (e: unknown) { + message.error(errorText(e, 'Не удалось сохранить изменения')); + } finally { + setSaving(false); + } + }; + + const accept = async (comment: string) => { + try { + setStatement(await acceptChecks(document.id, comment)); + 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 void load()}>Повторить} />; + } + if (!statement || !grid) { + return null; + } + const h = statement.header; + const headerInput = (field: 'inn' | 'report_date' | 'units', placeholder: string) => ( + setHeader((prev) => ({ ...prev, [field]: e.target.value }))} + /> + ); + + return ( + + + + + + + + {h.organization_name ?? '—'} + {headerInput('inn', 'ИНН')} + {headerInput('report_date', 'ГГГГ-ММ-ДД')} + {headerInput('units', 'тыс. руб.')} + {h.okud ?? '—'} + {h.okpo ?? '—'} + + + {confirmMessage && ( + 0 + ? confirmFailures.map((f) =>
{failureText(f)}
) + : undefined + } + /> + )} + + + + Суммы в: {h.units.value ?? 'единицы не указаны'} + + setDraft((prev) => ({ ...prev, [key]: text }))} + /> +
+
+
+ ); +} diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx new file mode 100644 index 0000000..4f98541 --- /dev/null +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -0,0 +1,81 @@ +import { Input, Table, Tooltip } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { ColumnOut, ValueOut } from '../../types/api'; +import { formatAmount, parseAmountInput } from '../../utils/money'; +import { type GridRow, cellFlags, cellKey } from './statementGrid'; + +type Props = { + columns: ColumnOut[]; + rows: GridRow[]; + editable: boolean; + draft: Record; + onDraftChange: (key: string, text: string) => void; +}; + +const ATTENTION_BG = '#fff7e6'; +const MODIFIED_BG = '#e6f4ff'; + +function cellBackground(value: ValueOut | undefined): string | undefined { + const flags = cellFlags(value); + if (flags.modified) { + return MODIFIED_BG; + } + return flags.attention ? ATTENTION_BG : undefined; +} + +function cellTip(value: ValueOut): string | undefined { + if (value.is_modified) { + return `Исходное: ${formatAmount(value.original_amount)}`; + } + return value.confidence ? `Уверенность: ${value.confidence}%` : undefined; +} + +export function StatementTable({ columns, rows, editable, draft, onDraftChange }: Props) { + const tableColumns: ColumnsType = [ + { key: 'name', title: 'Показатель', dataIndex: 'name', width: 320 }, + { key: 'code', title: 'Код', dataIndex: 'code', width: 70 }, + ...columns.map((col) => ({ + key: col.id, + title: col.label ?? `Колонка ${col.column_index + 1}`, + width: 160, + align: 'right' as const, + onCell: (row: GridRow) => { + return { style: { background: cellBackground(row.cells[col.id]) } }; + }, + render: (_: unknown, row: GridRow) => { + const value = row.cells[col.id]; + if (!value) { + return null; + } + const key = cellKey(row.lineId, col.id); + const tip = cellTip(value); + if (!editable) { + return {formatAmount(value.amount)}; + } + const text = draft[key] ?? value.amount ?? ''; + const invalid = key in draft && !parseAmountInput(text).ok; + return ( + + onDraftChange(key, e.target.value)} + style={{ textAlign: 'right' }} + /> + + ); + }, + })), + ]; + return ( + + size='small' + rowKey='key' + columns={tableColumns} + dataSource={rows} + pagination={false} + scroll={{ y: 'calc(100vh - 360px)' }} + /> + ); +} diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts new file mode 100644 index 0000000..cec6da3 --- /dev/null +++ b/src/widgets/DocumentReview/statementGrid.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { StatementOut, ValueOut } from '../../types/api'; +import { buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid'; + +const v = (id: string, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ + id, + column_id, + column_index, + amount: '1.00', + string_value: null, + confidence: '100', + is_recognized: true, + is_modified: false, + original_amount: null, + ...extra, +}); + +const statement = { + columns: [ + { id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false }, + { id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true }, + ], + lines: [{ id: 'l1', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }], +} as unknown as StatementOut; + +describe('buildStatementGrid', () => { + it('колонки по column_index, ячейки по column_id', () => { + const grid = buildStatementGrid(statement); + expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']); + expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс' }); + expect(grid.rows[0].cells.c1?.id).toBe('v1'); + expect(grid.rows[0].cells.c2?.id).toBe('v2'); + }); +}); + +describe('cellFlags', () => { + it('внимание: уверенность < 100 или не распознано', () => { + expect(cellFlags(v('a', 'c', 0, { confidence: '87.5' })).attention).toBe(true); + expect(cellFlags(v('a', 'c', 0, { is_recognized: false })).attention).toBe(true); + expect(cellFlags(v('a', 'c', 0)).attention).toBe(false); + expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false); + }); + it('исправлено', () => { + expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true); + expect(cellFlags(undefined)).toEqual({ attention: false, modified: false }); + }); +}); + +describe('draftToPatch', () => { + it('черновик → values', () => { + expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([ + { line_id: 'l1', column_id: 'c1', amount: '10.5' }, + { line_id: 'l2', column_id: 'c1', amount: null }, + ]); + }); +}); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts new file mode 100644 index 0000000..9b0a76c --- /dev/null +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -0,0 +1,39 @@ +import type { ColumnOut, StatementOut, StatementPatch, ValueOut } from '../../types/api'; + +export type GridRow = { + key: string; + lineId: string; + code: string | null; + name: string | null; + cells: Record; +}; + +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } { + const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); + const rows = statement.lines.map((line) => { + const cells: Record = {}; + for (const value of line.values) { + cells[value.column_id] = value; + } + return { key: line.id, lineId: line.id, code: line.line_code, name: line.line_name, cells }; + }); + return { columns, rows }; +} + +/** Уверенность — строка Decimal «0..100»; сравнение с 100 — это не сумма, float здесь безопасен. */ +export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } { + if (!value) { + return { attention: false, modified: false }; + } + const lowConfidence = value.confidence !== null && Number(value.confidence) < 100; + return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified }; +} + +export const cellKey = (lineId: string, columnId: string) => `${lineId}:${columnId}`; + +export function draftToPatch(draft: Record): NonNullable { + return Object.entries(draft).map(([key, amount]) => { + const [line_id, column_id] = key.split(':'); + return { line_id, column_id, amount }; + }); +}