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 }; + } + } +}