diff --git a/src/widgets/DocumentReview/CodeCell.tsx b/src/widgets/DocumentReview/CodeCell.tsx
new file mode 100644
index 0000000..251faa7
--- /dev/null
+++ b/src/widgets/DocumentReview/CodeCell.tsx
@@ -0,0 +1,20 @@
+import { Tooltip, Typography } from 'antd';
+import { codeCellView } from './cellView';
+import type { GridRow } from './statementGrid';
+
+/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */
+export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) {
+ const view = codeCellView(row);
+ const content = view.muted ? (
+ {view.text}
+ ) : (
+ {view.text}
+ );
+ if (!view.tip) return content;
+ return (
+ {view.tip}}>
+ {/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
+ {content}
+
+ );
+}
diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx
index 7123992..5bcc742 100644
--- a/src/widgets/DocumentReview/StatementReview.tsx
+++ b/src/widgets/DocumentReview/StatementReview.tsx
@@ -11,6 +11,7 @@ import { SourcePreview } from './SourcePreview';
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
+import { detailCount, filterLayout, formLayout } from './formLayout';
import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
@@ -26,6 +27,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [confirmFailures, setConfirmFailures] = useState([]);
const [confirmMessage, setConfirmMessage] = useState(null);
const [showEmptySublines, setShowEmptySublines] = useState(false);
+ const [showDetails, setShowDetails] = useState(false);
const load = useCallback(async () => {
try {
@@ -43,10 +45,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set()), [grid, draft]);
- const shownRows = useMemo(() => {
- if (!grid) return [];
- return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key));
- }, [grid, showEmptySublines, emptySublines]);
+ 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 details = grid ? detailCount(grid.rows) : 0;
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
@@ -206,10 +210,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
)}
+ {details > 0 && (
+
+
+
+
+ )}
setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx
index 94400a4..7e8e4d6 100644
--- a/src/widgets/DocumentReview/StatementTable.tsx
+++ b/src/widgets/DocumentReview/StatementTable.tsx
@@ -1,51 +1,33 @@
-import { Input, Table, Tooltip } from 'antd';
+import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
-import type { ColumnOut, DocumentType, ValueOut } from '../../types/api';
-import { formatAmount, parseAmountInput } from '../../utils/money';
-import { type LayoutRow, formLayout } from './formLayout';
+import type { ColumnOut } from '../../types/api';
+import { CodeCell } from './CodeCell';
+import { ValueCell } from './ValueCell';
+import { COLORS, rowBackground, valueBackground } from './cellView';
+import type { LayoutRow } from './formLayout';
import { columnTitle } from './reviewView';
-import { type GridRow, cellFlags, cellKey } from './statementGrid';
+import { cellKey } from './statementGrid';
type Props = {
- documentType: DocumentType;
columns: ColumnOut[];
- rows: GridRow[];
+ layout: LayoutRow[];
editable: boolean;
draft: Record;
onDraftChange: (key: string, text: string, originalAmount: string | null) => 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, { fraction: 'auto', empty: '-' })}`;
- }
- return value.confidence ? `Уверенность: ${value.confidence}%` : undefined;
-}
-
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
-const SECTION_BG = '#f2f4f7';
-const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание)
function lineWeight(r: LayoutRow): number | undefined {
if (r.kind !== 'line') return undefined;
if (r.grand) return 700;
return r.total ? 600 : undefined;
}
-const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined);
+const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined;
+// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
+const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
-export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
- const layout = formLayout(documentType, rows);
+export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
@@ -57,14 +39,19 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
...sectionSpan(r, true),
style:
r.kind === 'section'
- ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
- : { background: lineBackground(r) },
+ ? { background: COLORS.section, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
+ : { background: labelBackground(r) },
}),
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title;
const text = (
{r.displayName || '—'}
+ {r.secondaryName && (
+
+ {r.secondaryName}
+
+ )}
);
return r.fullName ? {text} : text;
@@ -75,8 +62,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
title: 'Код',
width: 60,
align: 'center' as const,
- onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }),
- render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? {r.row.code} : null),
+ onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
+ render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? : null),
},
...columns.map((col) => ({
key: col.id,
@@ -86,34 +73,32 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
onCell: (r: LayoutRow) =>
r.kind === 'section'
? sectionSpan(r, false)
- : { style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) } },
+ : {
+ style: {
+ background: isUnresolved(r)
+ ? rowBackground(r.row)
+ : (valueBackground(r.row.cells[col.id]) ?? (r.grand ? COLORS.grand : undefined)),
+ fontWeight: lineWeight(r),
+ },
+ },
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return null;
- const row = r.row;
- const value = row.cells[col.id];
- if (!value) {
- return null;
- }
- const key = cellKey(row.key, col.id);
- const tip = cellTip(value);
- if (!editable) {
- return {formatAmount(value.amount, { fraction: 'auto', empty: '-' })};
- }
- const text = draft[key] ?? value.amount ?? '';
- const invalid = key in draft && !parseAmountInput(text).ok;
- return (
-
- onDraftChange(key, e.target.value, value.amount)}
- style={{ textAlign: 'right' }}
- />
-
- );
+ const value = r.row.cells[col.id];
+ if (!value) return null;
+ const draftKey = cellKey(r.row.key, col.id);
+ return ;
},
})),
];
- return size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />;
+ return (
+
+ size='small'
+ bordered
+ rowKey='key'
+ columns={tableColumns}
+ dataSource={layout}
+ pagination={false}
+ onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})}
+ />
+ );
}
diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx
new file mode 100644
index 0000000..d1ba587
--- /dev/null
+++ b/src/widgets/DocumentReview/ValueCell.tsx
@@ -0,0 +1,42 @@
+import { CheckOutlined } from '@ant-design/icons';
+import { Input, Tooltip } from 'antd';
+import type { ValueOut } from '../../types/api';
+import { formatAmount, parseAmountInput } from '../../utils/money';
+import { cellTipLines } from './cellView';
+
+type Props = {
+ value: ValueOut;
+ editable: boolean;
+ /** ключ ячейки в черновике и сам черновик (текст, пока аналитик правит) */
+ draftKey: string;
+ draft: Record;
+ onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
+};
+
+/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
+export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) {
+ const lines = cellTipLines(value);
+ const tip = lines.length > 0 ? lines.map((line, i) => {line}
) : undefined;
+ if (!editable) {
+ return (
+
+ {value.is_confirmed && }
+ {formatAmount(value.amount, { fraction: 'auto', empty: '-' })}
+
+ );
+ }
+ const text = draft[draftKey] ?? value.amount ?? '';
+ const invalid = draftKey in draft && !parseAmountInput(text).ok;
+ return (
+
+ : undefined}
+ value={text}
+ onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
+ style={{ textAlign: 'right' }}
+ />
+
+ );
+}
diff --git a/src/widgets/DocumentReview/cellView.test.ts b/src/widgets/DocumentReview/cellView.test.ts
new file mode 100644
index 0000000..1e85ecb
--- /dev/null
+++ b/src/widgets/DocumentReview/cellView.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import type { ValueOut } from '../../types/api';
+import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView';
+import type { GridRow } from './statementGrid';
+
+const val = (extra: Partial = {}): ValueOut => ({
+ id: 'v1',
+ column_id: 'c1',
+ column_index: 0,
+ amount: '1.00',
+ string_value: null,
+ text: null,
+ confidence: null,
+ is_recognized: true,
+ is_modified: false,
+ original_amount: null,
+ needs_review: false,
+ is_confirmed: false,
+ ...extra,
+});
+
+const row = (extra: Partial = {}): GridRow => ({
+ key: 'l1',
+ lineId: 'l1',
+ code: '1240',
+ name: 'Финансовые вложения',
+ referenceName: null,
+ origin: 'source',
+ parentCode: null,
+ lineStatus: 'ok',
+ rowIndex: 1,
+ parentRowIndex: null,
+ codeText: '1240',
+ codeConfidence: '100',
+ needsReview: false,
+ isConfirmed: false,
+ codeModified: false,
+ cells: {},
+ ...extra,
+});
+
+describe('cellTipLines', () => {
+ it('распознанный текст и уверенность', () => {
+ expect(cellTipLines(val({ text: '42831 1', confidence: '100', is_recognized: false }))).toEqual([
+ 'Распознано: «42831 1»',
+ 'Уверенность: 100%',
+ ]);
+ });
+ it('исправленное: первой строкой исходное значение', () => {
+ const lines = cellTipLines(val({ is_modified: true, original_amount: '293709' }));
+ expect(lines[0]).toMatch(/^Исходное: /);
+ expect(lines[0]).toContain('293');
+ });
+ it('подтверждённое', () => {
+ expect(cellTipLines(val({ is_confirmed: true }))).toContain('Подтверждено аналитиком');
+ });
+ it('нет текста и уверенности — пусто', () => {
+ expect(cellTipLines(val({ text: null, confidence: null }))).toEqual([]);
+ });
+ it('расшифровка без needs_review тоже получает подсказку', () => {
+ expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([
+ 'Распознано: «70045»',
+ 'Уверенность: 67%',
+ ]);
+ });
+});
+
+describe('codeCellView', () => {
+ it('ok: код и уверенность', () => {
+ expect(codeCellView(row({ codeConfidence: '50' }))).toEqual({ text: '1240', muted: false, tip: 'Уверенность кода: 50%' });
+ });
+ it('ok: уверенность 100 — без подсказки', () => {
+ expect(codeCellView(row()).tip).toBeUndefined();
+ });
+ it('ok: назначен аналитиком — уверенности нет', () => {
+ expect(codeCellView(row({ codeModified: true, codeConfidence: '50' })).tip).toBe('Код назначен аналитиком');
+ });
+ it('ok: подтверждён', () => {
+ expect(codeCellView(row({ isConfirmed: true })).tip).toBe('Код подтверждён');
+ });
+ it('unreadable', () => {
+ expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: 'ИЧ,250' }))).toEqual({
+ text: 'ИЧ,250',
+ muted: true,
+ tip: 'Код не распознан: «ИЧ,250»',
+ });
+ });
+ it('unreadable без текста — тире', () => {
+ expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: null })).text).toBe('—');
+ });
+ it('missing', () => {
+ expect(codeCellView(row({ lineStatus: 'missing', code: null, codeText: null }))).toEqual({
+ text: '—',
+ muted: true,
+ tip: 'Кода в строке нет',
+ });
+ });
+ it('detail', () => {
+ expect(codeCellView(row({ lineStatus: 'detail', code: null }))).toEqual({
+ text: '',
+ muted: true,
+ tip: 'Расшифровка — в суммы формы не входит',
+ });
+ });
+});
+
+describe('фон', () => {
+ it('needs_review — внимание', () => {
+ expect(valueBackground(val({ needs_review: true }))).toBe(COLORS.attention);
+ });
+ it('исправлено перекрывает внимание', () => {
+ expect(valueBackground(val({ needs_review: true, is_modified: true }))).toBe(COLORS.modified);
+ });
+ it('низкая уверенность без needs_review — без фона', () => {
+ expect(valueBackground(val({ confidence: '50' }))).toBeUndefined();
+ expect(valueBackground(undefined)).toBeUndefined();
+ });
+ it('строка unreadable/missing — красноватая', () => {
+ expect(rowBackground(row({ lineStatus: 'unreadable' }))).toBe(COLORS.unresolved);
+ expect(rowBackground(row({ lineStatus: 'missing' }))).toBe(COLORS.unresolved);
+ });
+ it('ok с needsReview — внимание; обычная — без фона', () => {
+ expect(rowBackground(row({ needsReview: true }))).toBe(COLORS.attention);
+ expect(rowBackground(row())).toBeUndefined();
+ });
+});
diff --git a/src/widgets/DocumentReview/cellView.ts b/src/widgets/DocumentReview/cellView.ts
new file mode 100644
index 0000000..e63450b
--- /dev/null
+++ b/src/widgets/DocumentReview/cellView.ts
@@ -0,0 +1,64 @@
+// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются.
+import type { IssueOut, ValueOut } from '../../types/api';
+import { formatAmount } from '../../utils/money';
+import { type GridRow, cellFlags } from './statementGrid';
+
+export const COLORS = {
+ attention: '#fff7e6', // требует внимания (needs_review)
+ modified: '#e6f4ff', // исправлено аналитиком
+ unresolved: '#fff1f0', // строка без разобранного кода (unreadable / missing)
+ section: '#f2f4f7', // заголовок раздела
+ grand: '#f9f0ff', // «БАЛАНС»
+} as const;
+
+/** Фон ячейки: исправлено перекрывает «внимание»; сама по себе низкая уверенность не подсвечивается. */
+export function valueBackground(value: ValueOut | undefined): string | undefined {
+ const flags = cellFlags(value);
+ if (flags.modified) return COLORS.modified;
+ return flags.attention ? COLORS.attention : undefined;
+}
+
+/** Фон строки: без кода — вся строка; ok с needsReview — ячейки наименования и кода (суммы красит valueBackground). */
+export function rowBackground(row: GridRow): string | undefined {
+ if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return COLORS.unresolved;
+ return row.lineStatus === 'ok' && row.needsReview ? COLORS.attention : undefined;
+}
+
+const percent = (confidence: string): string => `${Math.round(Number(confidence))}%`;
+
+/** Строки подсказки ячейки суммы; подсказка есть и без подсветки (Р-23). */
+export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] {
+ const lines: string[] = [];
+ if (value.is_modified) {
+ lines.push(`Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`);
+ }
+ if (value.text !== null) lines.push(`Распознано: «${value.text}»`);
+ if (value.confidence !== null) lines.push(`Уверенность: ${percent(value.confidence)}`);
+ if (value.is_confirmed) lines.push('Подтверждено аналитиком');
+ for (const issue of issues) lines.push(issue.message);
+ return lines;
+}
+
+/** Ячейка кода (P6-16): текст, серый ли он, подсказка. */
+export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } {
+ switch (row.lineStatus) {
+ case 'unreadable': {
+ const raw = row.codeText?.trim() ?? '';
+ return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw}»` };
+ }
+ case 'missing':
+ return { text: '—', muted: true, tip: 'Кода в строке нет' };
+ case 'detail':
+ return { text: '', muted: true, tip: 'Расшифровка — в суммы формы не входит' };
+ default: {
+ const tips: string[] = [];
+ if (row.codeModified) {
+ tips.push('Код назначен аналитиком');
+ } else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) {
+ tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`);
+ }
+ if (row.isConfirmed) tips.push('Код подтверждён');
+ return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined };
+ }
+ }
+}