fix(statement): ссылка на строки без кода всегда видна, сортировка по row_index, Alert для ошибки скрытого поля кода, постоянный aria-label (T9.1, T9.2, T9.4, T7.1, T8.1, T3.3, M-3, #43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
d965fdc064
commit
4783593fe6
@ -1,5 +1,5 @@
|
|||||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
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 { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
@ -18,7 +18,7 @@ import { confirmErrorInfo } from './confirmError';
|
|||||||
import { switchesFor } from './focusTarget';
|
import { switchesFor } from './focusTarget';
|
||||||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||||||
import { issueListItems, issuePlaces } from './issuePlaces';
|
import { issueListItems, issuePlaces } from './issuePlaces';
|
||||||
import { applyLineMenu } from './lineMenu';
|
import { applyLineMenu, showsCodeInput } from './lineMenu';
|
||||||
import { reviewActions } from './reviewView';
|
import { reviewActions } from './reviewView';
|
||||||
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
|
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
|
||||||
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
|
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
|
||||||
@ -83,10 +83,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
return result;
|
return result;
|
||||||
}, [draftErrors, lineServerError]);
|
}, [draftErrors, lineServerError]);
|
||||||
const serverErrorOffScreen =
|
const serverErrorOffScreen =
|
||||||
lineServerError !== null && !layout.some((item) => item.kind === 'line' && item.row.lineId === lineServerError.lineId);
|
lineServerError !== null &&
|
||||||
const places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]);
|
!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(
|
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],
|
[grid, statement, places],
|
||||||
);
|
);
|
||||||
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
|
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
|
||||||
@ -224,21 +234,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
|
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
|
||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip
|
<Button
|
||||||
title={
|
type='primary'
|
||||||
unresolved.length > 0 ? (
|
icon={<CheckOutlined />}
|
||||||
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||||
) : undefined
|
disabled={!confirmEnabled}
|
||||||
}>
|
onClick={confirm}>
|
||||||
<Button
|
Подтвердить данные
|
||||||
type='primary'
|
</Button>
|
||||||
icon={<CheckOutlined />}
|
{unresolved.length > 0 && (
|
||||||
style={confirmEnabled ? confirmButtonStyle : undefined}
|
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
||||||
disabled={!confirmEnabled}
|
)}
|
||||||
onClick={confirm}>
|
|
||||||
Подтвердить данные
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@ function lineWeight(r: LayoutRow): number | undefined {
|
|||||||
if (r.grand) return 700;
|
if (r.grand) return 700;
|
||||||
return r.total ? 600 : undefined;
|
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) / фиолетовый («БАЛАНС»)
|
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
|
||||||
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
|
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,13 @@ const CONFIRM_TITLE = {
|
|||||||
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
|
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
|
||||||
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
|
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
|
||||||
} as const;
|
} as const;
|
||||||
|
// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке
|
||||||
|
const CONFIRM_LABEL = {
|
||||||
|
confirm: 'Подтвердить значение',
|
||||||
|
'pending-confirm': 'Подтвердить значение',
|
||||||
|
unconfirm: 'Снять подтверждение',
|
||||||
|
'pending-unconfirm': 'Снять подтверждение',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
|
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
|
||||||
export function ValueCell({
|
export function ValueCell({
|
||||||
@ -80,7 +87,7 @@ export function ValueCell({
|
|||||||
size='small'
|
size='small'
|
||||||
type={pending ? 'primary' : 'default'}
|
type={pending ? 'primary' : 'default'}
|
||||||
aria-pressed={pending}
|
aria-pressed={pending}
|
||||||
aria-label={CONFIRM_TITLE[confirmState]}
|
aria-label={CONFIRM_LABEL[confirmState]}
|
||||||
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
|
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
|
||||||
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
|
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import type { EditionLineOut } from '../../types/api';
|
import type { EditionLineOut, LineErrorDetail } from '../../types/api';
|
||||||
import type { GridRow } from './statementGrid';
|
import type { GridRow } from './statementGrid';
|
||||||
|
|
||||||
export type CodeOption = { code: string; name: string };
|
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. */
|
/** P6-9: к какой строке относится ошибка элемента `lines[]` ответа на сохранение; иная ошибка — null. */
|
||||||
export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | 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;
|
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<Record<keyof LineErrorDetail, unknown>> } | undefined)?.detail;
|
||||||
if (typeof detail?.line_id !== 'string') return null;
|
if (typeof detail?.line_id !== 'string') return null;
|
||||||
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
|
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,12 @@ describe('unresolvedLines', () => {
|
|||||||
it('фикстура: строки без кода со значениями', () => {
|
it('фикстура: строки без кода со значениями', () => {
|
||||||
expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']);
|
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 без значений не блокирует', () => {
|
it('missing без значений не блокирует', () => {
|
||||||
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
|
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import type { GridRow } from './statementGrid';
|
import type { GridRow } from './statementGrid';
|
||||||
|
|
||||||
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */
|
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */
|
||||||
export function unresolvedLines(rows: GridRow[]): GridRow[] {
|
export function unresolvedLines(rows: GridRow[]): GridRow[] {
|
||||||
return rows.filter(
|
const found = rows.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
|
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
|
||||||
Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)),
|
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}`;
|
export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`;
|
||||||
|
|||||||
@ -136,6 +136,13 @@ describe('formLayout — строки детализации (parent_code)', ()
|
|||||||
const rows = [row('9990'), sub('99901', '9990')];
|
const rows = [row('9990'), sub('99901', '9990')];
|
||||||
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']);
|
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('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => {
|
it('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => {
|
||||||
const rows = [row('2110'), sub('21101', '2110'), row('2120'), sub('21201', '2120'), row('2100'), sub('29901', '2990')];
|
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']);
|
expect(shape(formLayout('INCOME', rows))).toEqual(['2110', '21101', '2120', '21201', '2100 (итог)', '29901']);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user