Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -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
|
||||
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={
|
||||
unresolved.length > 0 ? (
|
||||
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
||||
) : undefined
|
||||
}>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<CheckOutlined />}
|
||||
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={confirm}>
|
||||
Подтвердить данные
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<CheckOutlined />}
|
||||
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={confirm}>
|
||||
Подтвердить данные
|
||||
</Button>
|
||||
{unresolved.length > 0 && (
|
||||
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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' ? <UndoOutlined /> : <CheckOutlined />}
|
||||
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
|
||||
/>
|
||||
|
||||
@ -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<Record<keyof LineErrorDetail, unknown>> } | 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 };
|
||||
}
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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']);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user