diff --git a/src/widgets/DocumentReview/IssuesPanel.tsx b/src/widgets/DocumentReview/IssuesPanel.tsx index e95152d..9576f10 100644 --- a/src/widgets/DocumentReview/IssuesPanel.tsx +++ b/src/widgets/DocumentReview/IssuesPanel.tsx @@ -13,10 +13,14 @@ export function SeverityIcon({ severity, style }: { severity: Severity; style?: ); } -type Props = { items: IssueListItem[]; onShow?: (key: string) => void }; +type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: (key: string) => void }; -/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */ -export function IssuesPanel({ items, onShow }: Props) { +/** + * Список предупреждений; пустой — не показывается. Длинный список прокручивается, как список нарушений. + * `statement` — форма ББ/ОФР: место записи и ссылка «Показать»; `document` (ОСВ, P6-19) — простые пункты без места и ссылок. + */ +export function IssuesPanel({ items, mode, onShow }: Props) { + const plain = mode === 'document'; if (items.length === 0) return null; return ( (
{severityLabel(item.severity)} - {item.message} ({item.place}) - {item.targetKey !== null && onShow && ( + {item.message} + {!plain && ` (${item.place})`} + {!plain && item.targetKey !== null && onShow && ( diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index ca6b1d7..573023c 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -6,6 +6,7 @@ import { ApiError } from '../../api/client'; import { confirmDocument, getAllEntries } from '../../services/api/documents'; import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api'; import { formatAmount } from '../../utils/money'; +import { IssuesPanel } from './IssuesPanel'; import { confirmButtonStyle } from './ReviewCard'; import { confirmErrorInfo } from './confirmError'; import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows'; @@ -116,6 +117,11 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]); // колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу» const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0); + // предупреждения документа (P6-19): все записи issues, без привязки к строкам + const issueItems = useMemo( + () => document.issues.map((i) => ({ id: i.id, severity: i.severity, message: i.message, place: '', targetKey: null })), + [document.issues], + ); const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK'; const confirm = async () => { @@ -165,6 +171,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on /> Строк: {rows.length}
+ {problem && ( setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))} /> - + {confirmMessage && } {emptySublines.size > 0 && ( diff --git a/src/widgets/FilesDrawer/FileWarningsPopover.tsx b/src/widgets/FilesDrawer/FileWarningsPopover.tsx new file mode 100644 index 0000000..5328f4b --- /dev/null +++ b/src/widgets/FilesDrawer/FileWarningsPopover.tsx @@ -0,0 +1,106 @@ +import { Popover, Spin, Tag } from 'antd'; +import { useState } from 'react'; +import { getDocument } from '../../services/api/documents'; +import type { IssueOut, Severity } from '../../types/api'; +import { severityLabel } from '../../utils/labels'; +import { SeverityIcon } from '../DocumentReview/IssuesPanel'; +import type { DocumentRow } from './documentRows'; +import { warningText } from './fileWarnings'; + +type IssuesState = { status: 'loading' } | { status: 'error' } | { status: 'done'; issues: IssueOut[] }; + +const tagColor = (s: Severity) => (s === 'error' ? 'error' : 'processing'); + +function DocumentIssues({ state }: { state: IssuesState | null }) { + if (state === null || state.status === 'loading') return ; + if (state.status === 'error') return Не удалось загрузить; + if (state.issues.length === 0) return Нет; + return ( + <> + {state.issues.map((i) => ( +
+ {severityLabel(i.severity)} + {i.message} +
+ ))} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */ +export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) { + const [issues, setIssues] = useState(null); + + if (!badge) return null; + + // предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново + const onOpenChange = (open: boolean) => { + if (!open) { + setIssues(null); + return; + } + if (!row.documentId) return; + setIssues({ status: 'loading' }); + getDocument(row.documentId) + .then((d) => setIssues({ status: 'done', issues: d.issues })) + .catch(() => setIssues({ status: 'error' })); + }; + + const content = ( +
+ {row.fileWarnings.length > 0 && ( +
+ {row.fileWarnings.map((w, i) => ( +
+ {severityLabel(w.severity)} + {warningText(w)} +
+ ))} +
+ )} + {row.mappingWarnings.length > 0 && ( +
+ {row.mappingWarnings.map((w) => ( +
+ {severityLabel('info')} + {w} +
+ ))} +
+ )} + {row.documentId && ( +
+ +
+ )} +
+ ); + + return ( + + + + ); +} diff --git a/src/widgets/FilesDrawer/FilesList.tsx b/src/widgets/FilesDrawer/FilesList.tsx index 8e4eaf8..cf8ae52 100644 --- a/src/widgets/FilesDrawer/FilesList.tsx +++ b/src/widgets/FilesDrawer/FilesList.tsx @@ -4,7 +4,8 @@ import type { ColumnsType } from 'antd/es/table'; import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import type { CheckResult, FileOut, FileType } from '../../types/api'; -import { type DocumentRow, type RowStatus, buildDocumentRows } from './documentRows'; +import { FileWarningsPopover } from './FileWarningsPopover'; +import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows'; const STATUS_STYLE: Record = { success: { color: '#237804', background: '#f6ffed', border: '#b7eb8f' }, @@ -94,7 +95,17 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load ) : null, }, - { title: head('Статус'), key: 'status', width: 160, render: (_, r) => }, + { + title: head('Статус'), + key: 'status', + width: 160, + render: (_, r) => ( +
+ + +
+ ), + }, ]; if (loading && files.length === 0) { diff --git a/src/widgets/FilesDrawer/documentRows.test.ts b/src/widgets/FilesDrawer/documentRows.test.ts index 5983204..59384ef 100644 --- a/src/widgets/FilesDrawer/documentRows.test.ts +++ b/src/widgets/FilesDrawer/documentRows.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { DocumentBrief, FileOut } from '../../types/api'; -import { buildDocumentRows } from './documentRows'; +import { buildDocumentRows, rowBadge } from './documentRows'; const doc = (over: Partial = {}): DocumentBrief => ({ id: 'd1', @@ -116,3 +116,61 @@ describe('buildDocumentRows', () => { expect(rows[1].subtitle.endsWith(' · вне консолидации')).toBe(true); }); }); + +const jsonFailed = { code: 'JSON_FAILED', severity: 'error' as const, message: 'm', locator: 'file:x_ofr.json' }; +const job = (over: Partial> = {}): FileOut['job'] => ({ + id: 'j', + status: 'DONE', + error_code: null, + error_message: null, + mapping_error: null, + warnings: [], + mapping_warnings: [], + finished_at: null, + ...over, +}); + +describe('buildDocumentRows: предупреждения', () => { + it('предупреждения файла попадают в обе строки файла', () => { + const rows = buildDocumentRows([ + file({ job: job({ warnings: [jsonFailed], mapping_warnings: ['w'] }), documents: [doc({ id: 'a' }), doc({ id: 'b' })] }), + ]); + expect(rows.map((r) => r.fileWarnings.length)).toEqual([1, 1]); + expect(rows[0].mappingWarnings).toEqual(['w']); + }); + + it('файл ERROR без документов', () => { + const rows = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })]); + expect(rows[0].fileWarnings).toEqual([jsonFailed]); + expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 }); + }); + + it('job null — пустые списки; старый формат строк не ломает', () => { + expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] }); + const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]); + expect(legacy[0].fileWarnings[0]).toMatchObject({ code: 'LEGACY_WARNING', message: 'старое' }); + }); + + it('issue_counts документа переносятся в issueCounts', () => { + const rows = buildDocumentRows([file({ documents: [doc({ issue_counts: { error: 1, info: 3 } })] })]); + expect(rows[0].issueCounts).toEqual({ error: 1, info: 3 }); + }); +}); + +describe('rowBadge', () => { + const row = (fileWarnings: unknown[], counts: { error: number; info: number }) => + buildDocumentRows([file({ job: job({ warnings: fileWarnings as never }), documents: [doc({ issue_counts: counts })] })])[0]; + + it('только счётчики info', () => { + expect(rowBadge(row([], { error: 0, info: 2 }))).toEqual({ count: 2, level: 'info' }); + }); + it('ошибка файла + info документа', () => { + expect(rowBadge(row([jsonFailed], { error: 0, info: 1 }))).toEqual({ count: 2, level: 'error' }); + }); + it('error документа без файла', () => { + expect(rowBadge(row([], { error: 1, info: 0 }))).toMatchObject({ level: 'error' }); + }); + it('нули — null', () => { + expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull(); + }); +}); diff --git a/src/widgets/FilesDrawer/documentRows.ts b/src/widgets/FilesDrawer/documentRows.ts index bfeef40..fbe68a6 100644 --- a/src/widgets/FilesDrawer/documentRows.ts +++ b/src/widgets/FilesDrawer/documentRows.ts @@ -1,7 +1,9 @@ -import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api'; +import type { CheckResult, DocumentStatus, FileOut, FileType, FileWarningOut, IssueCountsOut, Severity } from '../../types/api'; import { formatMoscowDate } from '../../utils/dates'; import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels'; +import { toFileWarnings, warningsLevel } from './fileWarnings'; + export type RowStatus = 'success' | 'warning' | 'processing' | 'error'; export interface DocumentRow { @@ -18,6 +20,19 @@ export interface DocumentRow { statusLabel: string; errorReason: string | null; linkable: boolean; + /** предупреждения разбора файла (общие для всех документов файла) */ + fileWarnings: FileWarningOut[]; + mappingWarnings: string[]; + /** счётчики issues документа; у строки файла без документов — нули */ + issueCounts: IssueCountsOut; +} + +/** Значок у статуса (P6-12): число предупреждений и цвет уровня; ничего нет — null. */ +export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null { + const count = row.fileWarnings.length + row.issueCounts.error + row.issueCounts.info; + if (count === 0) return null; + const level = warningsLevel(row.fileWarnings) === 'error' || row.issueCounts.error > 0 ? 'error' : 'info'; + return { count, level }; } const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']); @@ -41,6 +56,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s const extra = labelFor?.(f) ?? null; const name = f.original_name ?? 'без имени'; const date = formatMoscowDate(f.uploaded_at); + const fileWarnings = toFileWarnings(f.job?.warnings); + const mappingWarnings = f.job?.mapping_warnings ?? []; if (f.documents.length === 0) { const failed = f.status === 'ERROR'; return [ @@ -55,6 +72,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s statusLabel: failed ? 'Ошибка' : 'Обработка', errorReason: errorReason(f), linkable: false, + fileWarnings, + mappingWarnings, + issueCounts: { error: 0, info: 0 }, }, ]; } @@ -77,6 +97,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s statusLabel: documentStatusLabel(d.status), errorReason: null, linkable: LINKABLE.has(d.document_type), + fileWarnings, + mappingWarnings, + issueCounts: d.issue_counts, }; }); }); diff --git a/src/widgets/FilesDrawer/fileWarnings.test.ts b/src/widgets/FilesDrawer/fileWarnings.test.ts new file mode 100644 index 0000000..d0931ec --- /dev/null +++ b/src/widgets/FilesDrawer/fileWarnings.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { toFileWarnings, warningText, warningsLevel } from './fileWarnings'; + +describe('toFileWarnings', () => { + it('старый формат: строка становится LEGACY_WARNING', () => { + expect(toFileWarnings(['sheet=ОСВ 60 строки без родителя'])).toEqual([ + { code: 'LEGACY_WARNING', severity: 'info', message: 'sheet=ОСВ 60 строки без родителя', locator: null }, + ]); + }); + + it('смесь: мусор отбрасывается, объект без locator получает null', () => { + const res = toFileWarnings([ + 'a', + { code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' }, + 5, + { code: 'X' }, + { code: 'Y', severity: 'warn', message: 'z' }, + ]); + expect(res).toEqual([ + { code: 'LEGACY_WARNING', severity: 'info', message: 'a', locator: null }, + { code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' }, + ]); + }); + + it('объект без locator — locator null', () => { + expect(toFileWarnings([{ code: 'C', severity: 'info', message: 'm' }])[0].locator).toBeNull(); + }); + + it('не массив — пустой список', () => { + expect(toFileWarnings('строка')).toEqual([]); + expect(toFileWarnings(null)).toEqual([]); + expect(toFileWarnings(undefined)).toEqual([]); + }); +}); + +describe('warningsLevel', () => { + it('error важнее info', () => { + expect(warningsLevel([{ severity: 'info' }, { severity: 'error' }])).toBe('error'); + expect(warningsLevel([{ severity: 'info' }])).toBe('info'); + }); + it('пусто — null', () => { + expect(warningsLevel([])).toBeNull(); + }); +}); + +describe('warningText', () => { + it('с locator и без', () => { + expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: 'file:x_ofr.json' })).toBe('m — file:x_ofr.json'); + expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: null })).toBe('m'); + }); +}); diff --git a/src/widgets/FilesDrawer/fileWarnings.ts b/src/widgets/FilesDrawer/fileWarnings.ts new file mode 100644 index 0000000..aaa6a2e --- /dev/null +++ b/src/widgets/FilesDrawer/fileWarnings.ts @@ -0,0 +1,34 @@ +import type { FileWarningOut, Severity } from '../../types/api'; + +/** + * Приводит `job.warnings` к структурам (P6-13): старые строки становятся `LEGACY_WARNING` уровня info, + * записи без code/message/корректного severity и любые прочие значения отбрасываются. + */ +export function toFileWarnings(value: unknown): FileWarningOut[] { + if (!Array.isArray(value)) return []; + const result: FileWarningOut[] = []; + for (const item of value) { + if (typeof item === 'string') { + result.push({ code: 'LEGACY_WARNING', severity: 'info', message: item, locator: null }); + } else if (typeof item === 'object' && item !== null) { + const w = item as Record; + if (typeof w.code === 'string' && typeof w.message === 'string' && (w.severity === 'error' || w.severity === 'info')) { + result.push({ + code: w.code, + severity: w.severity, + message: w.message, + locator: typeof w.locator === 'string' ? w.locator : null, + }); + } + } + } + return result; +} + +/** Высший уровень списка: error, если есть хоть одно; пустой список — null. */ +export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null { + if (list.length === 0) return null; + return list.some((w) => w.severity === 'error') ? 'error' : 'info'; +} + +export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message);