diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 4b2a377..1ab3ac7 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -1,5 +1,5 @@ import { CheckOutlined, EditOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, Splitter, Switch, Tooltip, Typography, message } from 'antd'; +import { Alert, Button, Space, Splitter, Switch, Typography, message } from 'antd'; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ApiError } from '../../api/client'; @@ -18,7 +18,7 @@ import { confirmErrorInfo } from './confirmError'; import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; import { issueListItems, issuePlaces } from './issuePlaces'; -import { applyLineMenu } from './lineMenu'; +import { applyLineMenu, showsCodeInput } from './lineMenu'; import { reviewActions } from './reviewView'; import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid'; @@ -83,10 +83,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut return result; }, [draftErrors, lineServerError]); const serverErrorOffScreen = - lineServerError !== null && !layout.some((item) => item.kind === 'line' && item.row.lineId === lineServerError.lineId); - const places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]); + lineServerError !== null && + !layout.some( + (item) => + item.kind === 'line' && + item.row.lineId === lineServerError.lineId && + editing && + showsCodeInput(item.row, draft.lines[lineServerError.lineId]), + ); + 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 ? issueListItems(statement.issues ?? [], places, grid.rows, grid.columns) : []), [grid, statement, places], ); // «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM @@ -224,21 +234,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut - 0 ? ( - focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} - ) : undefined - }> - - + + {unresolved.length > 0 && ( + focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} + )} ); diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index febd274..76fac75 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -47,7 +47,7 @@ function lineWeight(r: LayoutRow): number | undefined { if (r.grand) return 700; return r.total ? 600 : undefined; } -const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined; +const isUnresolved = (r: LayoutRow) => r.kind === 'line' && (r.row.lineStatus === 'unreadable' || r.row.lineStatus === 'missing'); // наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС») const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined); diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index c86a057..c5d920c 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -29,6 +29,13 @@ const CONFIRM_TITLE = { 'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать', 'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать', } as const; +// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке +const CONFIRM_LABEL = { + confirm: 'Подтвердить значение', + 'pending-confirm': 'Подтвердить значение', + unconfirm: 'Снять подтверждение', + 'pending-unconfirm': 'Снять подтверждение', +} as const; /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ export function ValueCell({ @@ -80,7 +87,7 @@ export function ValueCell({ size='small' type={pending ? 'primary' : 'default'} aria-pressed={pending} - aria-label={CONFIRM_TITLE[confirmState]} + aria-label={CONFIRM_LABEL[confirmState]} icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? : } onClick={() => onConfirmToggle?.(draftKey, confirmState)} /> diff --git a/src/widgets/DocumentReview/codeSuggestions.ts b/src/widgets/DocumentReview/codeSuggestions.ts index 76391dc..e2a8d03 100644 --- a/src/widgets/DocumentReview/codeSuggestions.ts +++ b/src/widgets/DocumentReview/codeSuggestions.ts @@ -1,5 +1,5 @@ import { ApiError } from '../../api/client'; -import type { EditionLineOut } from '../../types/api'; +import type { EditionLineOut, LineErrorDetail } from '../../types/api'; import type { GridRow } from './statementGrid'; export type CodeOption = { code: string; name: string }; @@ -40,7 +40,7 @@ const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVAL /** P6-9: к какой строке относится ошибка элемента `lines[]` ответа на сохранение; иная ошибка — null. */ export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null { if (!(error instanceof ApiError) || !error.code || !LINE_ERROR_CODES.has(error.code)) return null; - const detail = (error.body as { detail?: { line_id?: unknown; line_code?: unknown } } | undefined)?.detail; + const detail = (error.body as { detail?: Partial> } | undefined)?.detail; if (typeof detail?.line_id !== 'string') return null; return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null }; } diff --git a/src/widgets/DocumentReview/confirmBlockers.test.ts b/src/widgets/DocumentReview/confirmBlockers.test.ts index 6e7692a..b893052 100644 --- a/src/widgets/DocumentReview/confirmBlockers.test.ts +++ b/src/widgets/DocumentReview/confirmBlockers.test.ts @@ -12,6 +12,12 @@ describe('unresolvedLines', () => { it('фикстура: строки без кода со значениями', () => { expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']); }); + it('сортирует по row_index, без индекса — в конец, равные — как пришли', () => { + const withAmount = { ...emptyCell, amount: '1.00' } as ValueOut; + const mk = (lineId: string, rowIndex: number | null) => row({ lineId, rowIndex, lineStatus: 'unreadable', cells: { a: withAmount } }); + const shuffled = [mk('n1', null), mk('c', 30), mk('a', 10), mk('b', 20), mk('n2', null), mk('b2', 20)]; + expect(unresolvedLines(shuffled).map((r) => r.lineId)).toEqual(['a', 'b', 'b2', 'c', 'n1', 'n2']); + }); it('missing без значений не блокирует', () => { expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]); }); diff --git a/src/widgets/DocumentReview/confirmBlockers.ts b/src/widgets/DocumentReview/confirmBlockers.ts index f27e4e2..c650b63 100644 --- a/src/widgets/DocumentReview/confirmBlockers.ts +++ b/src/widgets/DocumentReview/confirmBlockers.ts @@ -1,12 +1,17 @@ import type { GridRow } from './statementGrid'; -/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */ +/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */ export function unresolvedLines(rows: GridRow[]): GridRow[] { - return rows.filter( + const found = rows.filter( (row) => (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') && Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)), ); + // порядок как в бланке: по row_index (без индекса — в конец), при равенстве — как пришло + return found + .map((row, i) => ({ row, i })) + .sort((a, b) => (a.row.rowIndex ?? Number.POSITIVE_INFINITY) - (b.row.rowIndex ?? Number.POSITIVE_INFINITY) || a.i - b.i) + .map(({ row }) => row); } export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`; diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index f031438..c846bf5 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -136,6 +136,13 @@ describe('formLayout — строки детализации (parent_code)', () const rows = [row('9990'), sub('99901', '9990')]; expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']); }); + it('строка без кода после строки из «Прочих строк» встаёт под ней', () => { + const rows = [ + { ...row('9990'), rowIndex: 5 }, + { ...row(null, 'нераспознанная'), key: 'u1', lineId: 'u1', lineStatus: 'unreadable' as const, rowIndex: 6 }, + ]; + expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', 'null']); + }); it('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => { const rows = [row('2110'), sub('21101', '2110'), row('2120'), sub('21201', '2120'), row('2100'), sub('29901', '2990')]; expect(shape(formLayout('INCOME', rows))).toEqual(['2110', '21101', '2120', '21201', '2100 (итог)', '29901']);