Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44

Merged
aep merged 22 commits from feat/43-statements-v2 into test 2026-10-18 19:09:18 +03:00
10 changed files with 432 additions and 14 deletions
Showing only changes of commit d416422e73 - Show all commits

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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) }))

View File

@ -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,

View 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();
});
});

View 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 };
}

View 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);
});
});

View 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';
}

View File

@ -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']);
});
});

View File

@ -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),
);
}