Compare commits

...

4 Commits

Author SHA1 Message Date
Andrey Epifancev
b4b8c04693 openspec(statements-v2): решения по итогам прогона в браузере (T11.D-1…D-5, #43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:07:51 +04:00
Andrey Epifancev
aa3054e4ed docs(arm-web): экран формы v5.19 — README и карта модулей (#43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:06:02 +04:00
Andrey Epifancev
88a4920d7d fix(arm-web): нераспознанный текст суммы, пустые подстроки без расшифровок, свёрнутый список предупреждений (#43)
T11.5 valueCellText, T11.10 emptySublineKeys пропускает detail, T11.11 collapseIssues (3 записи + «Показать все»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:06:02 +04:00
Andrey Epifancev
a3199d636e fix(arm-web): вёрстка экрана проверки формы — ширины колонок, значки в ячейке, шапка, ссылка кнопкой (#43)
T11.1–T11.4, T11.6–T11.8: короткая подпись кнопки даты, фиксированная раскладка таблиц, код без переноса, значок уровня внутри ячейки, подсказка на всю ячейку, отступ child-строки, ссылка «Разберите строки без кода» — Button type=link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:06:02 +04:00
15 changed files with 153 additions and 36 deletions

View File

@ -44,6 +44,14 @@ SPA кредитного аналитика АРМ КА: справочники
- `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`).
- `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и `/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ (стол консолидации / все файлы ЮЛ, подпись консолидации, селектор `store/slices/consolidations/selectors.ts`)), `src/components/` — примитивы
(`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API.
- Экран проверки формы (`src/widgets/DocumentReview/`): вся логика — чистые модули с тестами рядом, компоненты `.tsx` без юнит-тестов.
`formLayout` — раскладка строк по разделам и статусам; `issuePlaces` — предупреждения по местам (ячейка / колонка / строка / поле шапки),
список для панели и `collapseIssues`; `statementDraft` — весь черновик правки и сборка тела `PATCH` (суммы, подтверждения, коды, даты, шапка);
`cellView` — цвета, подсказки, текст ячейки суммы и кода; `codeSuggestions` — подсказки кодов по справочнику; `lineMenu` — меню строки
(«Это расшифровка», «Назначить код»); `editRules` — что можно править и какая кнопка подтверждения нужна ячейке/колонке;
`confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать».
`src/widgets/FilesDrawer/fileWarnings` и `documentRows` — предупреждения файла и документа в панели файлов; `src/utils/percent` —
формат уверенности.
## Команды
- `make install` — `npm install`.

View File

@ -55,6 +55,15 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
Если на стенде раньше вручную сопоставляли контрагентов (K14), алиасы сохраняются между версиями, и
«несопоставленных» будет меньше (на стенде прогона — 1349); остальные цифры не меняются.
## Экран проверки формы ББ/ОФР (контракт 2.0, ядро v5.19)
Что нового (#43): строки формы показываются по статусам (`ok` / нераспознанный код / без кода / расшифровка) на своих местах;
предупреждения и нарушения стоят значками у ячеек, колонок, строк и полей шапки, а полный список свёрнут до трёх записей
(«Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату
колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка
«Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа.
Карта модулей — в `CLAUDE.md`, раздел «Архитектура».
## Ограничения прототипа
- Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов,

View File

@ -81,5 +81,10 @@
| T10.D-4 | Значок — `<button>` под плашкой статуса; без `documentId` раздел «Предупреждения документа» не показывается (дата 2026-10-18) | правила a11y | нет |
| T10.D-5 | Пустой список в разделе «Предупреждения документа» — «Нет» (дата 2026-10-18) | текст не из плана | только вид |
| UI-1 | Тексты, которых нет в плане: «Дата подтверждена (до сохранения)», «Подтверждение будет сохранено — нажмите, чтобы убрать», «Строка не найдена в форме», «Наименование организации — не длиннее 1000 символов» (дата 2026-10-18) | нужны для состояний, плана для них не было | только формулировки |
| T11.D-1 | Колонки значений 128px вместо 112 (поле даты из 10 символов и сумма с галочкой обрезались), `tableLayout='fixed'` (дата 2026-10-18) | замечание T11.1: колонка «прыгала» при появлении кнопки даты | только вид |
| T11.D-2 | Кнопка подтверждения даты — короткая «Подтвердить» / «Подтверждено», длинный текст «Подтвердить дату» / «Дата подтверждена (до сохранения)» в подсказке и `aria-label` (дата 2026-10-18) | фиксированная ширина колонки; отклонение от формулировки «Подтвердить дату» в P6-11 | только подпись |
| T11.D-3 | Ссылка «Разберите строки без кода: N» — `Button type=link` отдельной строкой над шапкой карточки, видна и в режиме правки (дата 2026-10-18) | клавиатурный доступ; строки нужно разбирать именно при правке | только вид |
| T11.D-4 | Список предупреждений документа показывает первые 3 записи (ошибки первыми) и «Показать все (N)»; то же на экране ОСВ (дата 2026-10-18) | таблица уезжала ниже сгиба | только вид |
| T11.D-5 | Значение со `string_value` без суммы показывается серым текстом, а не «-» (дата 2026-10-18) | аналитик должен видеть нераспознанный текст | только вид |
Решения контроллера и исполнителей поверх плана (`R-n`, `O-n`) дописываются сюда по ходу исполнения.

View File

@ -51,9 +51,18 @@ export function ColumnHeader({ col, issues, editing, draftDate, error = false, o
style={{ marginTop: 4, fontWeight: 400 }}
/>
{canConfirm && (
<Button size='small' style={{ marginTop: 4 }} disabled={pending} onClick={() => onConfirmDate?.(col)}>
{pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
</Button>
// короткая подпись, чтобы кнопка помещалась в колонку 112 px и ширина не прыгала (T11.1); длинный текст — в подсказке
<Tooltip title={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
<Button
size='small'
block
style={{ marginTop: 4, paddingInline: 0, fontSize: 12 }}
disabled={pending}
aria-label={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
onClick={() => onConfirmDate?.(col)}>
{pending ? 'Подтверждено' : 'Подтвердить'}
</Button>
</Tooltip>
)}
</div>
);

View File

@ -1,8 +1,11 @@
import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Alert, Button, Tag } from 'antd';
import { useState } from 'react';
import type { Severity } from '../../types/api';
import { severityLabel } from '../../utils/labels';
import type { IssueListItem } from './issuePlaces';
import { type IssueListItem, collapseIssues } from './issuePlaces';
const COLLAPSED_LIMIT = 3; // сколько записей видно в свёрнутом списке (T11.11)
/** Значок уровня (P6-15): error — красный, info — синий. */
export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) {
@ -21,7 +24,9 @@ type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?:
*/
export function IssuesPanel({ items, mode, onShow }: Props) {
const plain = mode === 'document';
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;
const { shown, hidden } = collapseIssues(items, expanded, COLLAPSED_LIMIT);
return (
<Alert
type={items.some((i) => i.severity === 'error') ? 'error' : 'info'}
@ -29,7 +34,7 @@ export function IssuesPanel({ items, mode, onShow }: Props) {
title={`Предупреждения (${items.length})`}
description={
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{items.map((item) => (
{shown.map((item) => (
<div key={item.id}>
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
{item.message}
@ -41,6 +46,11 @@ export function IssuesPanel({ items, mode, onShow }: Props) {
)}
</div>
))}
{(hidden > 0 || expanded) && (
<Button type='link' size='small' style={{ paddingInline: 0 }} onClick={() => setExpanded(!expanded)}>
{expanded ? 'Свернуть' : `Показать все (${items.length})`}
</Button>
)}
</div>
}
/>

View File

@ -45,7 +45,12 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
const reportDate = formatReportDate(header.report_date.value);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}>
<table style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse', fontSize: 13 }}>
{/* фиксированная раскладка: ширина блока не зависит от содержимого и подписи кнопки (T11.6) */}
<colgroup>
<col style={{ width: 170 }} />
<col />
</colgroup>
<tbody>
{headerFields(header, byField).map((r) => (
<tr key={r.field}>
@ -54,7 +59,6 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
style={{
...cell,
fontWeight: 600,
minWidth: r.field === 'organization_name' ? 320 : 160,
background: fieldBackground(r.modified, r.attention),
}}>
{r.issues.length > 0 && (
@ -74,9 +78,16 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
onChange={(e) => onDraftChange(r.field, e.target.value)}
/>
{r.canConfirm && onConfirmDate && (
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}>
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
</Button>
<Tooltip title={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
<Button
size='small'
style={{ marginTop: 4 }}
disabled={draft.confirm_report_date === true}
aria-label={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
onClick={onConfirmDate}>
{draft.confirm_report_date ? 'Подтверждено' : 'Подтвердить'}
</Button>
</Tooltip>
)}
</>
) : (

View File

@ -208,7 +208,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
} else if (confirmLines.length > 0) {
confirmDescription = (
<Typography.Link onClick={() => focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)}</Typography.Link>
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(confirmLines[0].line_id)}>
{unresolvedHint(confirmLines.length)}
</Button>
);
} else if (duplicateId) {
confirmDescription = (
@ -242,9 +244,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
onClick={confirm}>
Подтвердить данные
</Button>
{unresolved.length > 0 && (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
)}
</Space>
);
@ -259,6 +258,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<div style={{ height: '100%', paddingLeft: 6 }}>
<ReviewCard title='Результат распознавания' extra={cardActions}>
<Space orientation='vertical' size={12} style={{ width: '100%' }}>
{unresolved.length > 0 && (
// настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7)
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(unresolved[0].key)}>
{unresolvedHint(unresolved.length)}
</Button>
)}
<StatementHeaderBlock
header={statement.header}
documentType={statement.document.document_type}

View File

@ -40,6 +40,7 @@ type Props = {
onColumnDateConfirm?: (col: ColumnOut) => void;
};
const ICON_WIDTH = 18; // значок уровня с отступом: у ребёнка вычитается из отступа, чтобы текст не съезжал
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
function lineWeight(r: LayoutRow): number | undefined {
@ -89,7 +90,7 @@ export function StatementTable({
const rowIssues = places.byRow.get(r.key) ?? [];
const rowWorst = worstSeverity(rowIssues);
const text = (
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
<span style={{ fontWeight: lineWeight(r), display: 'inline-block' }}>
{r.displayName || '—'}
{r.secondaryName && (
<Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}>
@ -111,9 +112,11 @@ export function StatementTable({
) : (
namedText
);
if (!rowWorst) return named;
// отступ ребёнка — на общей обёртке: значок уровня остаётся внутри отступа и не сдвигает текст (T11.8)
const indent = r.child ? CHILD_INDENT : undefined;
if (!rowWorst) return <div style={{ paddingLeft: indent }}>{named}</div>;
return (
<>
<div style={{ paddingLeft: indent === undefined ? undefined : indent - ICON_WIDTH, display: 'flex', alignItems: 'center' }}>
<Tooltip
title={rowIssues.map((i) => (
<div key={i.id}>{i.message}</div>
@ -123,16 +126,16 @@ export function StatementTable({
</span>
</Tooltip>
{named}
</>
</div>
);
},
},
{
key: 'code',
title: 'Код',
width: editable ? 160 : 60,
width: editable ? 160 : 84,
align: 'center' as const,
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r), whiteSpace: 'nowrap' } }),
render: (_: unknown, r: LayoutRow) => {
if (r.kind !== 'line') return null;
const lineId = r.row.lineId;
@ -161,7 +164,7 @@ export function StatementTable({
onConfirmDate={onColumnDateConfirm}
/>
),
width: 112,
width: 128,
align: 'right' as const,
onCell: (r: LayoutRow) =>
r.kind === 'section'
@ -198,6 +201,7 @@ export function StatementTable({
return (
<Table<LayoutRow>
size='small'
tableLayout='fixed'
bordered
rowKey='key'
columns={tableColumns}

View File

@ -1,9 +1,9 @@
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'antd';
import { Button, Input, Tooltip, Typography } from 'antd';
import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { parseAmountInput } from '../../utils/money';
import { SeverityIcon } from './IssuesPanel';
import { cellTipLines } from './cellView';
import { cellTipLines, valueCellText } from './cellView';
import type { CellConfirmState } from './editRules';
import { worstSeverity } from './issuePlaces';
@ -51,17 +51,19 @@ export function ValueCell({
}: Props) {
const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
const corner = worst && <SeverityIcon severity={worst} style={{ position: 'absolute', top: 1, left: 2, fontSize: 10 }} />;
const shown = valueCellText(value);
// значок уровня — внутри ячейки, слева от суммы, по центру по вертикали (T11.3)
const corner = worst && <SeverityIcon severity={worst} style={{ fontSize: 11, flex: 'none' }} />;
const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined;
if (!editable) {
return (
// подсказка на всей ячейке: блок на всю ширину колонки (T11.4)
<Tooltip title={tip}>
<span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4, minHeight: 22 }}>
{corner}
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />}
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}
</span>
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />}
{shown.muted ? <Typography.Text type='secondary'>{shown.text}</Typography.Text> : shown.text}
</div>
</Tooltip>
);
}
@ -70,7 +72,7 @@ export function ValueCell({
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
{corner}
<Input
size='small'
@ -78,8 +80,7 @@ export function ValueCell({
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
value={text}
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
style={{ textAlign: 'right', minWidth: 0, flex: 1 }}
/>
{confirmState && (
<Tooltip title={CONFIRM_TITLE[confirmState]}>
@ -93,7 +94,7 @@ export function ValueCell({
/>
</Tooltip>
)}
</span>
</div>
</Tooltip>
);
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ValueOut } from '../../types/api';
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView';
import { formatAmount } from '../../utils/money';
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground, valueCellText } from './cellView';
import type { GridRow } from './statementGrid';
const val = (extra: Partial<ValueOut> = {}): ValueOut => ({
@ -138,3 +139,24 @@ describe('фон', () => {
expect(rowBackground(row())).toBeUndefined();
});
});
describe('valueCellText (T11.5)', () => {
it('сумма есть — обычный текст', () => {
expect(valueCellText(val({ amount: '1500.00' }))).toEqual({
text: formatAmount('1500.00', { fraction: 'auto', empty: '-' }),
muted: false,
});
});
it('суммы нет, есть string_value — он серым', () => {
expect(valueCellText(val({ amount: null, string_value: '211(1 |' }))).toEqual({ text: '211(1 |', muted: true });
});
it('ничего нет — прочерк', () => {
expect(valueCellText(val({ amount: null }))).toEqual({ text: '-', muted: false });
});
});
describe('cellTipLines и string_value (T11.5)', () => {
it('без суммы показывает нераспознанный string_value', () => {
expect(cellTipLines(val({ amount: null, string_value: 'abc' }))).toContain('Не распознано как сумма: «abc»');
});
});

View File

@ -34,11 +34,22 @@ export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[]
if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`);
const confidence = formatPercent(value.confidence);
if (confidence !== null) lines.push(`Уверенность: ${confidence}`);
if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') {
lines.push(`Не распознано как сумма: «${value.string_value}»`);
}
if (value.is_confirmed) lines.push('Подтверждено аналитиком');
for (const issue of issues) lines.push(issue.message);
return lines;
}
/** Текст ячейки суммы (T11.5): сумма; без неё нераспознанный string_value серым; иначе прочерк. */
export function valueCellText(value: ValueOut): { text: string; muted: boolean } {
if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') {
return { text: value.string_value, muted: true };
}
return { text: formatAmount(value.amount, { fraction: 'auto', empty: '-' }), muted: false };
}
/** Ячейка кода (P6-16): текст, серый ли он, подсказка. */
export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } {
switch (row.lineStatus) {

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { IssueOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces';
import { collapseIssues, issueListItems, issuePlaces, worstSeverity } from './issuePlaces';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as unknown as StatementOut;
@ -82,3 +82,14 @@ describe('worstSeverity', () => {
expect(worstSeverity([])).toBeNull();
});
});
describe('collapseIssues (T11.11)', () => {
const items = Array.from({ length: 6 }, (_, i) => ({ id: String(i) })) as never[];
it('свёрнуто: первые 3, скрыто 3', () => {
expect(collapseIssues(items, false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 3 });
});
it('развёрнуто или короткий список: всё', () => {
expect(collapseIssues(items, true, 3)).toEqual({ shown: items, hidden: 0 });
expect(collapseIssues(items.slice(0, 3), false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 0 });
});
});

View File

@ -118,3 +118,9 @@ export function worstSeverity(issues: IssueOut[]): Severity | null {
if (issues.length === 0) return null;
return issues.some((i) => i.severity === 'error') ? 'error' : 'info';
}
/** Свёрнутый список предупреждений (T11.11): первые `limit` (порядок уже «ошибки первыми») и сколько скрыто. */
export function collapseIssues<T>(items: T[], expanded: boolean, limit: number): { shown: T[]; hidden: number } {
if (expanded || items.length <= limit) return { shown: items, hidden: 0 };
return { shown: items.slice(0, limit), hidden: items.length - limit };
}

View File

@ -173,6 +173,10 @@ describe('parent_code и пустые строки детализации', () =
];
expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']);
});
it('расшифровка (lineStatus detail) с parent_code не считается пустой подстрокой (T11.10)', () => {
const detail = { ...gridRow('d1', '1110', { c1: cell({}) }), lineStatus: 'detail' as const };
expect([...emptySublineKeys([detail], {})]).toEqual([]);
});
it('подстрока с правкой в черновике не считается пустой', () => {
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']);

View File

@ -88,6 +88,7 @@ export function emptySublineKeys(
.filter(
(row) =>
row.parentCode !== null &&
row.lineStatus !== 'detail' &&
!edited.has(row.key) &&
!(row.lineId !== null && row.lineId in lineActions) &&
!Object.values(row.cells).some(hasValue),