diff --git a/src/widgets/DocumentReview/ChecksPanel.tsx b/src/widgets/DocumentReview/ChecksPanel.tsx index 082aa38..5fff10c 100644 --- a/src/widgets/DocumentReview/ChecksPanel.tsx +++ b/src/widgets/DocumentReview/ChecksPanel.tsx @@ -6,20 +6,33 @@ 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; dirty: boolean; onAccept: (comment: string) => Promise }; +type Props = { + checks: ChecksOut; + editable: boolean; + dirty: boolean; + onAccept: (comment: string) => Promise; + onShow?: (key: string) => void; +}; // soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления; // подтверждение они не блокируют // длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран -const failureList = (list: FailedCheckOut[]) => ( +const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
{list.map((f) => ( -
{failureText(f)}
+
+ {failureText(f)} + {onShow && f.line_ids.length > 0 && ( + + )} +
))}
); -export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { +export function ChecksPanel({ checks, editable, dirty, onAccept, onShow }: Props) { const [open, setOpen] = useState(false); const [comment, setComment] = useState(''); const [saving, setSaving] = useState(false); @@ -44,14 +57,14 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { )} {checks.failed.length > 0 && ( - + )} {checks.soft_failed.length > 0 && ( )} {checks.cross_form && ( diff --git a/src/widgets/DocumentReview/IssuesPanel.tsx b/src/widgets/DocumentReview/IssuesPanel.tsx new file mode 100644 index 0000000..e95152d --- /dev/null +++ b/src/widgets/DocumentReview/IssuesPanel.tsx @@ -0,0 +1,43 @@ +import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { Alert, Button, Tag } from 'antd'; +import type { Severity } from '../../types/api'; +import { severityLabel } from '../../utils/labels'; +import type { IssueListItem } from './issuePlaces'; + +/** Значок уровня (P6-15): error — красный, info — синий. */ +export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) { + return severity === 'error' ? ( + + ) : ( + + ); +} + +type Props = { items: IssueListItem[]; onShow?: (key: string) => void }; + +/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */ +export function IssuesPanel({ items, onShow }: Props) { + if (items.length === 0) return null; + return ( + i.severity === 'error') ? 'error' : 'info'} + showIcon + title={`Предупреждения (${items.length})`} + description={ +
+ {items.map((item) => ( +
+ {severityLabel(item.severity)} + {item.message} ({item.place}) + {item.targetKey !== null && onShow && ( + + )} +
+ ))} +
+ } + /> + ); +} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 5bcc742..608abc6 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -1,17 +1,20 @@ import { CheckOutlined, EditOutlined } from '@ant-design/icons'; import { Alert, Button, Space, Splitter, Switch, message } from 'antd'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, 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 { IssuesPanel } from './IssuesPanel'; import { ReviewCard, confirmButtonStyle } from './ReviewCard'; import { SourcePreview } from './SourcePreview'; import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock'; import { StatementTable } from './StatementTable'; import { confirmErrorInfo } from './confirmError'; +import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; +import { issueListItems, issuePlaces } from './issuePlaces'; import { reviewActions } from './reviewView'; import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid'; @@ -28,6 +31,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [confirmMessage, setConfirmMessage] = useState(null); const [showEmptySublines, setShowEmptySublines] = useState(false); const [showDetails, setShowDetails] = useState(false); + const [highlightKey, setHighlightKey] = useState(null); + const [pendingFocus, setPendingFocus] = useState(null); + const highlightTimer = useRef(undefined); const load = useCallback(async () => { try { @@ -50,6 +56,28 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut () => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set() : emptySublines }), [fullLayout, showDetails, showEmptySublines, emptySublines], ); + 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 actions = reviewActions(statement?.document.status ?? 'ERROR', editing); // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён @@ -138,7 +166,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut if (loadError) { return void load()}>Повторить} />; } - if (!statement || !grid) { + if (!statement || !grid || !places) { return null; } @@ -189,7 +217,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut draft={header} onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} /> - + + {confirmMessage && ( setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 7e8e4d6..45d7758 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -1,10 +1,12 @@ import { Table, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import type { ColumnOut } from '../../types/api'; +import type { ColumnOut, IssueOut } from '../../types/api'; import { CodeCell } from './CodeCell'; +import { SeverityIcon } from './IssuesPanel'; import { ValueCell } from './ValueCell'; import { COLORS, rowBackground, valueBackground } from './cellView'; import type { LayoutRow } from './formLayout'; +import { type IssuePlaces, worstSeverity } from './issuePlaces'; import { columnTitle } from './reviewView'; import { cellKey } from './statementGrid'; @@ -14,6 +16,10 @@ type Props = { editable: boolean; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** предупреждения и нарушения у мест таблицы */ + places: IssuePlaces; + /** строка, которую нужно подсветить рамкой после перехода «Показать» */ + highlightKey?: string | null; }; const CHILD_INDENT = 20; // отступ названия строки детализации под родителем @@ -27,7 +33,23 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== // наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС») const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined); -export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) { +function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) { + const worst = worstSeverity(issues); + if (!worst) return <>{columnTitle(col)}; + return ( + ( +
{i.message}
+ ))}> + + + {columnTitle(col)} + +
+ ); +} + +export function StatementTable({ columns, layout, editable, draft, onDraftChange, places, highlightKey = null }: Props) { const span = columns.length + 2; // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); @@ -44,6 +66,8 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange }), render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return r.title; + const rowIssues = places.byRow.get(r.key) ?? []; + const rowWorst = worstSeverity(rowIssues); const text = ( {r.displayName || '—'} @@ -54,7 +78,21 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange )} ); - return r.fullName ? {text} : text; + const named = r.fullName ? {text} : text; + if (!rowWorst) return named; + return ( + <> + ( +
{i.message}
+ ))}> + + + +
+ {named} + + ); }, }, { @@ -67,7 +105,7 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange }, ...columns.map((col) => ({ key: col.id, - title: columnTitle(col), + title: , width: 112, align: 'right' as const, onCell: (r: LayoutRow) => @@ -86,7 +124,16 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange const value = r.row.cells[col.id]; if (!value) return null; const draftKey = cellKey(r.row.key, col.id); - return ; + return ( + + ); }, })), ]; @@ -98,7 +145,11 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange columns={tableColumns} dataSource={layout} pagination={false} - onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})} + onRow={(r) => + r.kind === 'line' + ? { id: `row-${r.key}`, style: r.key === highlightKey ? { outline: '2px solid #1677ff', outlineOffset: -2 } : undefined } + : {} + } /> ); } diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index d1ba587..6f3d315 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -1,8 +1,10 @@ import { CheckOutlined } from '@ant-design/icons'; import { Input, Tooltip } from 'antd'; -import type { ValueOut } from '../../types/api'; +import type { IssueOut, ValueOut } from '../../types/api'; import { formatAmount, parseAmountInput } from '../../utils/money'; +import { SeverityIcon } from './IssuesPanel'; import { cellTipLines } from './cellView'; +import { worstSeverity } from './issuePlaces'; type Props = { value: ValueOut; @@ -11,17 +13,25 @@ type Props = { draftKey: string; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** записи issues, привязанные к этой ячейке */ + issues?: IssueOut[]; }; /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ -export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) { - const lines = cellTipLines(value); +export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) { + const lines = cellTipLines(value, issues); + const worst = worstSeverity(issues); + // значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы + const corner = worst && ; const tip = lines.length > 0 ? lines.map((line, i) =>
{line}
) : undefined; if (!editable) { return ( - {value.is_confirmed && } - {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} + + {corner} + {value.is_confirmed && } + {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} + ); } @@ -29,14 +39,17 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: P const invalid = draftKey in draft && !parseAmountInput(text).ok; return ( - : undefined} - value={text} - onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} - style={{ textAlign: 'right' }} - /> + + {corner} + : undefined} + value={text} + onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} + style={{ textAlign: 'right' }} + /> + ); } diff --git a/src/widgets/DocumentReview/focusTarget.test.ts b/src/widgets/DocumentReview/focusTarget.test.ts new file mode 100644 index 0000000..707ba34 --- /dev/null +++ b/src/widgets/DocumentReview/focusTarget.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import type { StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { switchesFor } from './focusTarget'; +import { buildStatementGrid } from './statementGrid'; + +const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut); +const off = { showDetails: false, showEmptySublines: false, emptySublines: new Set() }; + +describe('switchesFor', () => { + it('скрытая расшифровка — включить расшифровки', () => { + expect(switchesFor('l-d13', rows, off)).toEqual({ details: true, emptySublines: false }); + expect(switchesFor('l-d13', rows, { ...off, showDetails: true })).toEqual({ details: false, emptySublines: false }); + }); + it('скрытая пустая подстрока — включить пустые строки', () => { + const state = { ...off, emptySublines: new Set(['l-1210']) }; + expect(switchesFor('l-1210', rows, state)).toEqual({ details: false, emptySublines: true }); + expect(switchesFor('l-1210', rows, { ...state, showEmptySublines: true })).toEqual({ details: false, emptySublines: false }); + }); + it('обычная и неизвестная строка — ничего', () => { + expect(switchesFor('l-1110', rows, off)).toEqual({ details: false, emptySublines: false }); + expect(switchesFor('нет-такой', rows, off)).toEqual({ details: false, emptySublines: false }); + }); +}); diff --git a/src/widgets/DocumentReview/focusTarget.ts b/src/widgets/DocumentReview/focusTarget.ts new file mode 100644 index 0000000..e09ff50 --- /dev/null +++ b/src/widgets/DocumentReview/focusTarget.ts @@ -0,0 +1,21 @@ +// Что нужно включить, чтобы строку с ключом key можно было показать на экране (переход «Показать»). +import type { GridRow } from './statementGrid'; + +export interface FocusSwitches { + details: boolean; + emptySublines: boolean; +} + +/** Какие переключатели нужно включить для строки key; уже включённые и неизвестный ключ — false. */ +export function switchesFor( + key: string, + rows: GridRow[], + state: { showDetails: boolean; showEmptySublines: boolean; emptySublines: ReadonlySet }, +): FocusSwitches { + const row = rows.find((r) => r.key === key); + if (!row) return { details: false, emptySublines: false }; + return { + details: row.lineStatus === 'detail' && !state.showDetails, + emptySublines: state.emptySublines.has(key) && !state.showEmptySublines, + }; +} diff --git a/src/widgets/DocumentReview/issuePlaces.test.ts b/src/widgets/DocumentReview/issuePlaces.test.ts new file mode 100644 index 0000000..111cd9f --- /dev/null +++ b/src/widgets/DocumentReview/issuePlaces.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import type { IssueOut, StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces'; +import { buildStatementGrid, cellKey } from './statementGrid'; + +const statement = statementV2Json as unknown as StatementOut; +const { rows, columns } = buildStatementGrid(statement); +const ids = (list: IssueOut[] | undefined) => (list ?? []).map((i) => i.id); +const issue = (patch: Partial): IssueOut => ({ + id: 'x', + kind: 'warning', + code: 'X', + severity: 'info', + message: 'm', + line_ids: [], + column_id: null, + column_index: null, + field: null, + source: 'service', + relation: null, + codes: [], + expected: null, + actual: null, + ...patch, +}); + +describe('issuePlaces', () => { + it('привязка фикстуры', () => { + const p = issuePlaces(statement.issues, rows, columns); + expect(ids(p.byRow.get('l-1370a'))).toContain('i1'); + expect(ids(p.byRow.get('l-1370b'))).toContain('i1'); + for (const k of ['l-1200', 'l-1210', 'l-1230', 'l-1240']) expect(ids(p.byCell.get(cellKey(k, 'c1')))).toEqual(['i6']); + expect(ids(p.byColumn.get('c2'))).toEqual(['i2']); + expect(ids(p.byField.get('organization_name'))).toEqual(['i3']); + expect(ids(p.byField.get('report_date'))).toEqual(['i5']); + expect(ids(p.document)).toEqual(['i4']); + expect(ids(p.byRow.get('l-1230'))).toEqual(['i7']); + }); + it('неизвестные строки и поля — у документа', () => { + const gone = issue({ id: 'g', line_ids: ['l-gone'] }); + const mixed = issue({ id: 'm', line_ids: ['l-gone', 'l-1210'] }); + const okpo = issue({ id: 'o', field: 'okpo' }); + const p = issuePlaces([gone, mixed, okpo], rows, columns); + expect(ids(p.document)).toEqual(['g', 'o']); + expect(ids(p.byRow.get('l-1210'))).toEqual(['m']); + expect(p.byRow.size).toBe(1); + }); +}); + +describe('issueListItems', () => { + const p = issuePlaces(statement.issues, rows, columns); + const items = issueListItems(statement.issues, p, rows, columns); + it('6 пунктов без relation, error первыми', () => { + expect(items).toHaveLength(6); + expect(items.slice(0, 2).map((i) => i.id)).toEqual(['i2', 'i5']); + expect(items.every((i) => i.id !== 'i6')).toBe(true); + }); + it('места и цели', () => { + const by = (id: string) => items.find((i) => i.id === id); + expect(by('i4')).toMatchObject({ place: 'столбец 6 вне колонок формы', targetKey: null }); + expect(by('i1')).toMatchObject({ place: 'строка 1370', targetKey: 'l-1370a' }); + expect(by('i3')?.place).toBe('поле «Организация»'); + expect(by('i2')?.place).toMatch(/^колонка «/); + }); + it('расшифровка', () => { + const d = issue({ id: 'd', line_ids: ['l-d13'] }); + const list = issueListItems([d], issuePlaces([d], rows, columns), rows, columns); + expect(list[0]).toMatchObject({ place: 'расшифровка «справочно»', targetKey: 'l-d13' }); + }); + it('строка без кода', () => { + const u = issue({ id: 'u', line_ids: ['l-u7'] }); + const m = issue({ id: 'mm', line_ids: ['l-m8'] }); + const list = issueListItems([u, m], issuePlaces([u, m], rows, columns), rows, columns); + expect(list.map((i) => i.place)).toEqual(['строка без кода «ИЧ,250»', 'строка без кода']); + }); +}); + +describe('worstSeverity', () => { + it('уровни', () => { + expect(worstSeverity([issue({ severity: 'info' }), issue({ severity: 'error' })])).toBe('error'); + expect(worstSeverity([])).toBeNull(); + }); +}); diff --git a/src/widgets/DocumentReview/issuePlaces.ts b/src/widgets/DocumentReview/issuePlaces.ts new file mode 100644 index 0000000..3a126f2 --- /dev/null +++ b/src/widgets/DocumentReview/issuePlaces.ts @@ -0,0 +1,119 @@ +// Куда в интерфейсе положить каждую запись issues (P6-5): у строки, ячейки, колонки, поля шапки или у документа. +// Только показ: данные не меняются. +import type { ColumnOut, IssueOut, Severity } from '../../types/api'; +import { columnTitle } from './reviewView'; +import { type GridRow, cellKey } from './statementGrid'; + +export const HEADER_BLOCK_FIELDS = ['organization_name', 'inn', 'report_date', 'units'] as const; + +export interface IssuePlaces { + byRow: Map; + /** ключ — cellKey(rowKey, columnId) */ + byCell: Map; + byColumn: Map; + byField: Map; + document: IssueOut[]; +} + +export interface IssueListItem { + id: string; + severity: Severity; + message: string; + place: string; + targetKey: string | null; +} + +const push = (map: Map, key: string, issue: IssueOut) => { + const list = map.get(key); + if (list) list.push(issue); + else map.set(key, [issue]); +}; + +export function issuePlaces( + issues: IssueOut[], + rows: GridRow[], + columns: ColumnOut[], + fields: readonly string[] = HEADER_BLOCK_FIELDS, +): IssuePlaces { + const places: IssuePlaces = { byRow: new Map(), byCell: new Map(), byColumn: new Map(), byField: new Map(), document: [] }; + const rowByLineId = new Map(); + for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r); + const columnIds = new Set(columns.map((c) => c.id)); + for (const issue of issues) { + if (issue.field !== null) { + if (fields.includes(issue.field)) push(places.byField, issue.field, issue); + else places.document.push(issue); + continue; + } + const known = issue.line_ids.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined); + const columnId = issue.column_id !== null && columnIds.has(issue.column_id) ? issue.column_id : null; + if (issue.line_ids.length > 0 && known.length > 0) { + for (const r of known) { + if (columnId !== null) push(places.byCell, cellKey(r.key, columnId), issue); + else push(places.byRow, r.key, issue); + } + } else if (issue.line_ids.length === 0 && columnId !== null) { + push(places.byColumn, columnId, issue); + } else { + places.document.push(issue); + } + } + return places; +} + +const FIELD_LABELS: Record = { + organization_name: 'Организация', + inn: 'ИНН', + report_date: 'Дата отчёта', + units: 'Единица измерения', +}; + +const rowTitle = (row: GridRow): string => { + switch (row.lineStatus) { + case 'ok': + return `строка ${row.code ?? ''}`.trim(); + case 'detail': + return `расшифровка «${row.name ?? ''}»`; + default: { + const text = row.codeText?.trim(); + return text ? `строка без кода «${text}»` : 'строка без кода'; + } + } +}; + +const SEVERITY_ORDER: Record = { error: 0, info: 1 }; + +/** Список предупреждений документа: только kind = warning, сначала error, затем info, внутри — как пришло. */ +export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[] { + const rowByLineId = new Map(); + for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r); + const columnById = new Map(columns.map((c) => [c.id, c])); + const inDocument = new Set(places.document); + const items = issues + .filter((issue) => issue.kind === 'warning') + .map((issue): IssueListItem => { + const first = issue.line_ids.map((id) => rowByLineId.get(id)).find((r) => r !== undefined); + const column = issue.column_id !== null ? columnById.get(issue.column_id) : undefined; + let place: string; + let targetKey: string | null = null; + if (inDocument.has(issue)) { + place = + issue.column_index !== null && issue.column_id === null ? `столбец ${issue.column_index + 1} вне колонок формы` : 'документ'; + } else if (issue.field !== null) { + place = `поле «${FIELD_LABELS[issue.field] ?? issue.field}»`; + } else if (first) { + targetKey = first.key; + place = column ? `${rowTitle(first)}, колонка «${columnTitle(column)}»` : rowTitle(first); + } else { + place = column ? `колонка «${columnTitle(column)}»` : 'документ'; + } + return { id: issue.id, severity: issue.severity, message: issue.message, place, targetKey }; + }); + // сортировка устойчивая: порядок issues внутри уровня сохраняется + return items.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} + +export function worstSeverity(issues: IssueOut[]): Severity | null { + if (issues.length === 0) return null; + return issues.some((i) => i.severity === 'error') ? 'error' : 'info'; +}