Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -1,20 +1,71 @@
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { AutoComplete, Tag, Tooltip, Typography } from 'antd';
|
||||
import { codeCellView } from './cellView';
|
||||
import { type CodeOption, filterCodeOptions } from './codeSuggestions';
|
||||
import { showsCodeInput } from './lineMenu';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */
|
||||
export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) {
|
||||
/** Правка кода строки: действие черновика, справочник подсказок и ошибка (формат / ответ сервера). */
|
||||
export type CodeEditing = {
|
||||
action: LineAction | undefined;
|
||||
options: CodeOption[];
|
||||
error?: string;
|
||||
onCodeChange: (code: string) => void;
|
||||
};
|
||||
|
||||
const actionTag = (action: LineAction | undefined) => {
|
||||
if (action?.kind === 'detail') return '→ расшифровка';
|
||||
if (action?.kind === 'confirm') return action.confirmed ? '→ код подтверждён' : '→ подтверждение снято';
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Ячейка кода: просмотр (код, у нераспознанного — серый сырой текст) или, в режиме правки, поле кода с подсказками. */
|
||||
export function CodeCell({ row, fontWeight, editing }: { row: GridRow; fontWeight?: number; editing?: CodeEditing }) {
|
||||
const view = codeCellView(row);
|
||||
const tag = editing ? actionTag(editing.action) : null;
|
||||
if (editing && showsCodeInput(row, editing.action)) {
|
||||
const value = editing.action?.kind === 'code' ? editing.action.code : '';
|
||||
return (
|
||||
<Tooltip title={editing.error}>
|
||||
<span>
|
||||
<AutoComplete
|
||||
size='small'
|
||||
style={{ width: 140 }}
|
||||
status={editing.error ? 'error' : undefined}
|
||||
value={value}
|
||||
placeholder='Код'
|
||||
options={filterCodeOptions(editing.options, value).map((o) => ({ value: o.code, label: `${o.code} — ${o.name}` }))}
|
||||
onChange={editing.onCodeChange}
|
||||
/>
|
||||
{tag && (
|
||||
<Tag style={{ marginTop: 2 }} variant='filled'>
|
||||
{tag}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const content = view.muted ? (
|
||||
<Typography.Text type='secondary'>{view.text}</Typography.Text>
|
||||
) : (
|
||||
<span style={{ fontWeight }}>{view.text}</span>
|
||||
);
|
||||
if (!view.tip) return content;
|
||||
const withTag = tag ? (
|
||||
<>
|
||||
{content}
|
||||
<Tag style={{ marginLeft: 4 }} variant='filled'>
|
||||
{tag}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
if (!view.tip) return withTag;
|
||||
return (
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{view.tip}</span>}>
|
||||
{/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
|
||||
<span style={{ display: 'inline-block', minWidth: 16 }}>{content}</span>
|
||||
<span style={{ display: 'inline-block', minWidth: 16 }}>{withTag}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { type LineMenuKey, lineMenuItems } from './lineMenu';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Меню строки в режиме правки: кнопка «⋮» рядом с наименованием. Пусто — кнопки нет. */
|
||||
export function LineMenuButton({
|
||||
row,
|
||||
action,
|
||||
onSelect,
|
||||
}: {
|
||||
row: GridRow;
|
||||
action: LineAction | undefined;
|
||||
onSelect: (key: LineMenuKey) => void;
|
||||
}) {
|
||||
const items = lineMenuItems(row, action);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<Dropdown trigger={['click']} menu={{ items, onClick: ({ key }) => onSelect(key as LineMenuKey) }}>
|
||||
<Button type='text' size='small' icon={<MoreOutlined />} aria-label='Действия со строкой' style={{ marginLeft: 4 }} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@ -11,10 +11,12 @@ import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
||||
import { SourcePreview } from './SourcePreview';
|
||||
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
||||
import { StatementTable } from './StatementTable';
|
||||
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { switchesFor } from './focusTarget';
|
||||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||||
import { issueListItems, issuePlaces } from './issuePlaces';
|
||||
import { applyLineMenu } from './lineMenu';
|
||||
import { reviewActions } from './reviewView';
|
||||
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
|
||||
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
|
||||
@ -34,12 +36,15 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
const [showEmptySublines, setShowEmptySublines] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [highlightKey, setHighlightKey] = useState<string | null>(null);
|
||||
// ошибка элемента lines[] из ответа на сохранение (P6-9); строки на экране нет — текст над таблицей
|
||||
const [lineServerError, setLineServerError] = useState<{ lineId: string; text: string } | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
|
||||
const highlightTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
const setDraft = (update: (prev: StatementDraft) => StatementDraft) => {
|
||||
setDraftState(update);
|
||||
setDraftErrors([]);
|
||||
setLineServerError(null);
|
||||
};
|
||||
const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]);
|
||||
|
||||
@ -58,12 +63,22 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
|
||||
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
|
||||
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
|
||||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts) : new Set<string>()), [grid, draft]);
|
||||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
|
||||
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
|
||||
const layout = useMemo(
|
||||
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
|
||||
[fullLayout, showDetails, showEmptySublines, emptySublines],
|
||||
);
|
||||
const codeOptions = useMemo(() => (statement && grid ? buildCodeOptions(statement.edition_lines, grid.rows) : []), [statement, grid]);
|
||||
// красные поля кода: ошибки формата из черновика и текст сервера
|
||||
const lineErrors = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const error of draftErrors) if (error.kind === 'code' || error.kind === 'conflict') result[error.key] = error.message;
|
||||
if (lineServerError) result[lineServerError.lineId] = lineServerError.text;
|
||||
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]);
|
||||
const issueItems = useMemo(
|
||||
() => (grid && statement && places ? issueListItems(statement.issues, places, grid.rows, grid.columns) : []),
|
||||
@ -115,6 +130,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
message.success('Изменения сохранены');
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const target = lineErrorTarget(e);
|
||||
if (target) setLineServerError({ lineId: target.lineId, text: errorText(e, 'Не удалось сохранить строку') });
|
||||
message.error(errorText(e, 'Не удалось сохранить изменения'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@ -240,6 +257,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
|
||||
<StatementTable
|
||||
columns={grid.columns}
|
||||
layout={layout}
|
||||
@ -247,6 +265,13 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
highlightKey={highlightKey}
|
||||
editable={editing && actions.canSave}
|
||||
draft={draft.amounts}
|
||||
lineActions={draft.lines}
|
||||
codeOptions={codeOptions}
|
||||
lineErrors={lineErrors}
|
||||
onLineCodeChange={(row, code) =>
|
||||
row.lineId && setDraft((prev) => ({ ...prev, lines: { ...prev.lines, [row.lineId as string]: { kind: 'code', code } } }))
|
||||
}
|
||||
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
|
||||
errorKeys={errorKeys}
|
||||
onDraftChange={(key, text, original) =>
|
||||
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
|
||||
|
||||
@ -3,12 +3,16 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ColumnOut, IssueOut } from '../../types/api';
|
||||
import { CodeCell } from './CodeCell';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { LineMenuButton } from './LineMenuButton';
|
||||
import { ValueCell } from './ValueCell';
|
||||
import { COLORS, rowBackground, valueBackground } from './cellView';
|
||||
import type { CodeOption } from './codeSuggestions';
|
||||
import type { LayoutRow } from './formLayout';
|
||||
import { type IssuePlaces, worstSeverity } from './issuePlaces';
|
||||
import type { LineMenuKey } from './lineMenu';
|
||||
import { columnTitle } from './reviewView';
|
||||
import { cellKey } from './statementGrid';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import { type GridRow, cellKey } from './statementGrid';
|
||||
|
||||
type Props = {
|
||||
columns: ColumnOut[];
|
||||
@ -22,6 +26,12 @@ type Props = {
|
||||
places: IssuePlaces;
|
||||
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
|
||||
highlightKey?: string | null;
|
||||
/** действия над строками из черновика, подсказки кода и ошибки полей кода (lineId → текст) */
|
||||
lineActions?: Record<string, LineAction>;
|
||||
codeOptions?: CodeOption[];
|
||||
lineErrors?: Record<string, string>;
|
||||
onLineCodeChange?: (row: GridRow, code: string) => void;
|
||||
onLineMenu?: (row: GridRow, key: LineMenuKey) => void;
|
||||
};
|
||||
|
||||
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
|
||||
@ -51,7 +61,21 @@ function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatementTable({ columns, layout, editable, draft, onDraftChange, errorKeys, places, highlightKey = null }: Props) {
|
||||
export function StatementTable({
|
||||
columns,
|
||||
layout,
|
||||
editable,
|
||||
draft,
|
||||
onDraftChange,
|
||||
errorKeys,
|
||||
places,
|
||||
highlightKey = null,
|
||||
lineActions = {},
|
||||
codeOptions = [],
|
||||
lineErrors = {},
|
||||
onLineCodeChange,
|
||||
onLineMenu,
|
||||
}: Props) {
|
||||
const span = columns.length + 2;
|
||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
|
||||
@ -80,7 +104,19 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
const named = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
const namedText = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
const named = editable ? (
|
||||
<>
|
||||
{namedText}
|
||||
<LineMenuButton
|
||||
row={r.row}
|
||||
action={r.row.lineId ? lineActions[r.row.lineId] : undefined}
|
||||
onSelect={(key) => onLineMenu?.(r.row, key)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
namedText
|
||||
);
|
||||
if (!rowWorst) return named;
|
||||
return (
|
||||
<>
|
||||
@ -100,10 +136,23 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Код',
|
||||
width: 60,
|
||||
width: editable ? 160 : 60,
|
||||
align: 'center' as const,
|
||||
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
|
||||
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <CodeCell row={r.row} fontWeight={lineWeight(r)} /> : null),
|
||||
render: (_: unknown, r: LayoutRow) => {
|
||||
if (r.kind !== 'line') return null;
|
||||
const lineId = r.row.lineId;
|
||||
const editing =
|
||||
editable && lineId
|
||||
? {
|
||||
action: lineActions[lineId],
|
||||
options: codeOptions,
|
||||
error: lineErrors[lineId],
|
||||
onCodeChange: (code: string) => onLineCodeChange?.(r.row, code),
|
||||
}
|
||||
: undefined;
|
||||
return <CodeCell row={r.row} fontWeight={lineWeight(r)} editing={editing} />;
|
||||
},
|
||||
},
|
||||
...columns.map((col) => ({
|
||||
key: col.id,
|
||||
|
||||
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { codeOptions, filterCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||||
import { type GridRow, buildStatementGrid } from './statementGrid';
|
||||
|
||||
const statement = statementV2Json as StatementOut;
|
||||
const { rows } = buildStatementGrid(statement);
|
||||
|
||||
describe('codeOptions', () => {
|
||||
it('сначала справочник по порядку, коды без повторов', () => {
|
||||
const options = codeOptions(statement.edition_lines, rows);
|
||||
expect(options.slice(0, 3).map((o) => o.code)).toEqual(['1110', '1150', '1170']);
|
||||
expect(options.filter((o) => o.code === '1250')).toEqual([{ code: '1250', name: 'Денежные средства и денежные эквиваленты' }]);
|
||||
expect(options.filter((o) => o.code === '1370')).toHaveLength(1);
|
||||
});
|
||||
it('код ok-строки файла вне справочника — в конце, с её именем', () => {
|
||||
const base = rows.find((r) => r.key === 'l-1210');
|
||||
if (!base) throw new Error('нет строки l-1210');
|
||||
const extra: GridRow = { ...base, key: 'x', code: '1231', name: 'Прочая задолженность', referenceName: null };
|
||||
const options = codeOptions(statement.edition_lines, [...rows, extra]);
|
||||
expect(options.map((o) => o).pop()).toEqual({ code: '1231', name: 'Прочая задолженность' });
|
||||
});
|
||||
it('справочник пуст — только коды ok-строк; unreadable и detail не попадают', () => {
|
||||
const options = codeOptions([], rows);
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
expect(options.map((o) => o.code)).toContain('1210');
|
||||
expect(options.every((o) => /^\d{4,5}$/.test(o.code))).toBe(true);
|
||||
const codes = options.map((o) => o.code);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCodeOptions', () => {
|
||||
const options = codeOptions(statement.edition_lines, rows);
|
||||
it('по началу кода', () => {
|
||||
const found = filterCodeOptions(options, '12');
|
||||
expect(found.length).toBeGreaterThan(0);
|
||||
expect(found.every((o) => o.code.startsWith('12'))).toBe(true);
|
||||
});
|
||||
it('по названию без учёта регистра и ё/е', () => {
|
||||
expect(filterCodeOptions(options, 'денеж').map((o) => o.code)).toEqual(['1250']);
|
||||
expect(filterCodeOptions(options, 'ДЕНЁЖ').map((o) => o.code)).toEqual(['1250']);
|
||||
});
|
||||
it('пустой запрос — первые 20', () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ code: String(1000 + i), name: '' }));
|
||||
expect(filterCodeOptions(many, '')).toHaveLength(20);
|
||||
expect(filterCodeOptions(many, '', 5)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineErrorTarget', () => {
|
||||
it('409 LINE_CODE_TAKEN → строка и код', () => {
|
||||
const body = { detail: { code: 'LINE_CODE_TAKEN', message: 'Код занят', line_id: 'l-u7', line_code: '1170' } };
|
||||
expect(lineErrorTarget(new ApiError(409, 'LINE_CODE_TAKEN', 'Код занят', body))).toEqual({ lineId: 'l-u7', lineCode: '1170' });
|
||||
});
|
||||
it('422 INVALID_LINE_PATCH с line_code null', () => {
|
||||
const body = { detail: { code: 'INVALID_LINE_PATCH', message: 'm', line_id: 'l-u7', line_code: null } };
|
||||
expect(lineErrorTarget(new ApiError(422, 'INVALID_LINE_PATCH', 'm', body))).toEqual({ lineId: 'l-u7', lineCode: null });
|
||||
});
|
||||
it('LINES_UNRESOLVED и массив pydantic → null', () => {
|
||||
const unresolved = { detail: { code: 'LINES_UNRESOLVED', message: 'm', lines: [{ line_id: 'a' }] } };
|
||||
expect(lineErrorTarget(new ApiError(409, 'LINES_UNRESOLVED', 'm', unresolved))).toBeNull();
|
||||
expect(lineErrorTarget(new ApiError(422, undefined, 'msg', { detail: [{ msg: 'msg' }] }))).toBeNull();
|
||||
});
|
||||
it('не ApiError → null', () => {
|
||||
expect(lineErrorTarget(new Error('x'))).toBeNull();
|
||||
});
|
||||
});
|
||||
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { EditionLineOut } from '../../types/api';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
export type CodeOption = { code: string; name: string };
|
||||
|
||||
const LIST_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Подсказки кода (P6-1): сначала справочник редакции по порядку, затем коды строк `ok` файла вне справочника.
|
||||
* Без повторов; строки без кода (unreadable / missing / detail) не попадают.
|
||||
*/
|
||||
export function codeOptions(editionLines: EditionLineOut[], rows: GridRow[]): CodeOption[] {
|
||||
const result: CodeOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of editionLines) {
|
||||
if (seen.has(line.code)) continue;
|
||||
seen.add(line.code);
|
||||
result.push({ code: line.code, name: line.name });
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.lineStatus !== 'ok' || !row.code || seen.has(row.code)) continue;
|
||||
seen.add(row.code);
|
||||
result.push({ code: row.code, name: row.referenceName ?? row.name ?? '' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const fold = (text: string) => text.toLowerCase().replace(/ё/g, 'е');
|
||||
|
||||
/** Отбор подсказок: код начинается с запроса или имя содержит его (регистр и ё/е не важны). */
|
||||
export function filterCodeOptions(options: CodeOption[], query: string, limit = LIST_LIMIT): CodeOption[] {
|
||||
const q = fold(query.trim());
|
||||
if (q === '') return options.slice(0, limit);
|
||||
return options.filter((o) => o.code.startsWith(q) || fold(o.name).includes(q)).slice(0, limit);
|
||||
}
|
||||
|
||||
const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVALID_LINE_PATCH', 'UNKNOWN_LINE', 'LINE_CODE_REQUIRED']);
|
||||
|
||||
/** 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;
|
||||
if (typeof detail?.line_id !== 'string') return null;
|
||||
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
|
||||
}
|
||||
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { applyLineMenu, lineMenuItems, showsCodeInput } from './lineMenu';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import { type GridRow, buildStatementGrid } from './statementGrid';
|
||||
|
||||
const { rows } = buildStatementGrid(statementV2Json as StatementOut);
|
||||
const row = (key: string): GridRow => {
|
||||
const found = rows.find((r) => r.key === key);
|
||||
if (!found) throw new Error(key);
|
||||
return found;
|
||||
};
|
||||
const keys = (r: GridRow, action?: LineAction) => lineMenuItems(r, action).map((i) => i.key);
|
||||
|
||||
describe('lineMenuItems', () => {
|
||||
it('unreadable / missing — «Это расшифровка»', () => {
|
||||
expect(keys(row('l-u7'))).toEqual(['mark_detail']);
|
||||
expect(keys(row('l-m8'))).toEqual(['mark_detail']);
|
||||
expect(lineMenuItems(row('l-u7'), undefined)[0].label).toBe('Это расшифровка');
|
||||
});
|
||||
it('detail — «Назначить код»', () => {
|
||||
expect(lineMenuItems(row('l-d2'), undefined)).toEqual([{ key: 'assign_code', label: 'Назначить код' }]);
|
||||
});
|
||||
it('ok с needsReview — смена и подтверждение кода', () => {
|
||||
expect(lineMenuItems(row('l-1230'), undefined)).toEqual([
|
||||
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
|
||||
{ key: 'change_code', label: 'Сменить код' },
|
||||
{ key: 'confirm_code', label: 'Подтвердить код' },
|
||||
]);
|
||||
});
|
||||
it('ok подтверждённая — снять подтверждение', () => {
|
||||
const confirmed = { ...row('l-1230'), isConfirmed: true };
|
||||
expect(lineMenuItems(confirmed, undefined)).toEqual([
|
||||
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
|
||||
{ key: 'change_code', label: 'Сменить код' },
|
||||
{ key: 'unconfirm_code', label: 'Снять подтверждение кода' },
|
||||
]);
|
||||
});
|
||||
it('при действии последним — «Отменить изменение строки»', () => {
|
||||
const items = lineMenuItems(row('l-u7'), { kind: 'detail' });
|
||||
expect(items.map((i) => i).pop()).toEqual({ key: 'reset', label: 'Отменить изменение строки' });
|
||||
});
|
||||
it('шаблонная строка без id — пусто', () => {
|
||||
expect(keys(row('code-1250'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLineMenu', () => {
|
||||
it('confirm_code → confirm true', () => {
|
||||
expect(applyLineMenu(row('l-1230'), 'confirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: true } });
|
||||
expect(applyLineMenu(row('l-1230'), 'unconfirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: false } });
|
||||
});
|
||||
it('assign_code / change_code / mark_detail', () => {
|
||||
expect(applyLineMenu(row('l-d2'), 'assign_code', {})['l-d2']).toEqual({ kind: 'code', code: '' });
|
||||
expect(applyLineMenu(row('l-1230'), 'change_code', {})['l-1230']).toEqual({ kind: 'code', code: '1230' });
|
||||
expect(applyLineMenu(row('l-u7'), 'mark_detail', {})['l-u7']).toEqual({ kind: 'detail' });
|
||||
});
|
||||
it('reset убирает ключ, новое действие заменяет прежнее, вход не мутируется', () => {
|
||||
const before: Record<string, LineAction> = { 'l-u7': { kind: 'detail' }, other: { kind: 'detail' } };
|
||||
expect(applyLineMenu(row('l-u7'), 'reset', before)).toEqual({ other: { kind: 'detail' } });
|
||||
expect(applyLineMenu(row('l-1230'), 'confirm_code', { 'l-1230': { kind: 'code', code: '1231' } })['l-1230']).toEqual({
|
||||
kind: 'confirm',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(before['l-u7']).toEqual({ kind: 'detail' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('showsCodeInput', () => {
|
||||
it('unreadable / missing всегда; прочие — только при действии code', () => {
|
||||
expect(showsCodeInput(row('l-u7'), undefined)).toBe(true);
|
||||
expect(showsCodeInput(row('l-m8'), undefined)).toBe(true);
|
||||
expect(showsCodeInput(row('l-1230'), undefined)).toBe(false);
|
||||
expect(showsCodeInput(row('l-1230'), { kind: 'code', code: '1230' })).toBe(true);
|
||||
expect(showsCodeInput(row('l-d2'), { kind: 'code', code: '' })).toBe(true);
|
||||
});
|
||||
});
|
||||
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
export type LineMenuKey = 'mark_detail' | 'assign_code' | 'change_code' | 'confirm_code' | 'unconfirm_code' | 'reset';
|
||||
|
||||
const LABELS: Record<LineMenuKey, string> = {
|
||||
mark_detail: 'Сделать расшифровкой',
|
||||
assign_code: 'Назначить код',
|
||||
change_code: 'Сменить код',
|
||||
confirm_code: 'Подтвердить код',
|
||||
unconfirm_code: 'Снять подтверждение кода',
|
||||
reset: 'Отменить изменение строки',
|
||||
};
|
||||
|
||||
/** Пункты меню строки (P6-7). Строка без id (достроенная из шаблона) не меняется — меню пусто. */
|
||||
export function lineMenuItems(row: GridRow, action: LineAction | undefined): { key: LineMenuKey; label: string }[] {
|
||||
if (row.lineId === null) return [];
|
||||
const keys: LineMenuKey[] = [];
|
||||
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') {
|
||||
keys.push('mark_detail');
|
||||
} else if (row.lineStatus === 'detail') {
|
||||
keys.push('assign_code');
|
||||
} else if (row.origin === 'source') {
|
||||
keys.push('mark_detail', 'change_code');
|
||||
if (row.isConfirmed) keys.push('unconfirm_code');
|
||||
else if (row.needsReview) keys.push('confirm_code');
|
||||
}
|
||||
if (action && keys.length > 0) keys.push('reset');
|
||||
const items = keys.map((key) => ({ key, label: key === 'mark_detail' && row.lineStatus !== 'ok' ? 'Это расшифровка' : LABELS[key] }));
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Новый набор действий строк после выбора пункта; исходный объект не меняется. */
|
||||
export function applyLineMenu(row: GridRow, key: LineMenuKey, lines: Record<string, LineAction>): Record<string, LineAction> {
|
||||
const id = row.lineId;
|
||||
if (id === null) return lines;
|
||||
const { [id]: _removed, ...rest } = lines;
|
||||
switch (key) {
|
||||
case 'reset':
|
||||
return rest;
|
||||
case 'mark_detail':
|
||||
return { ...rest, [id]: { kind: 'detail' } };
|
||||
case 'assign_code':
|
||||
return { ...rest, [id]: { kind: 'code', code: '' } };
|
||||
case 'change_code':
|
||||
return { ...rest, [id]: { kind: 'code', code: row.code ?? '' } };
|
||||
case 'confirm_code':
|
||||
return { ...rest, [id]: { kind: 'confirm', confirmed: true } };
|
||||
default:
|
||||
return { ...rest, [id]: { kind: 'confirm', confirmed: false } };
|
||||
}
|
||||
}
|
||||
|
||||
/** Поле кода: у строк без кода — всегда, у остальных — когда выбрано действие «код». */
|
||||
export function showsCodeInput(row: GridRow, action: LineAction | undefined): boolean {
|
||||
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return true;
|
||||
return action?.kind === 'code';
|
||||
}
|
||||
@ -177,4 +177,8 @@ describe('parent_code и пустые строки детализации', () =
|
||||
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
|
||||
expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']);
|
||||
});
|
||||
it('подстрока с действием над строкой в черновике не считается пустой', () => {
|
||||
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
|
||||
expect([...emptySublineKeys(rows, {}, { '11101': { kind: 'confirm', confirmed: true } })]).toEqual(['11102']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
|
||||
import type { LineAction } from './statementDraft';
|
||||
|
||||
export type GridRow = {
|
||||
key: string;
|
||||
@ -73,13 +74,25 @@ export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue
|
||||
const hasValue = (value: ValueOut | undefined) => Boolean(value) && ((value?.amount ?? '') !== '' || (value?.string_value ?? '') !== '');
|
||||
|
||||
/**
|
||||
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки в черновике.
|
||||
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки (суммы или действия над строкой) в черновике.
|
||||
* Ноль — значение, такая строка не пуста. Только показ: данные и PATCH не затрагиваются.
|
||||
*/
|
||||
export function emptySublineKeys(rows: GridRow[], draft: Record<string, string>): Set<string> {
|
||||
export function emptySublineKeys(
|
||||
rows: GridRow[],
|
||||
draft: Record<string, string>,
|
||||
lineActions: Record<string, LineAction> = {},
|
||||
): Set<string> {
|
||||
const edited = new Set(Object.keys(draft).map((key) => key.slice(0, key.lastIndexOf(':'))));
|
||||
return new Set(
|
||||
rows.filter((row) => row.parentCode !== null && !edited.has(row.key) && !Object.values(row.cells).some(hasValue)).map((row) => row.key),
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.parentCode !== null &&
|
||||
!edited.has(row.key) &&
|
||||
!(row.lineId !== null && row.lineId in lineActions) &&
|
||||
!Object.values(row.cells).some(hasValue),
|
||||
)
|
||||
.map((row) => row.key),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user