fix(#235): флажки «Учитывать в КФО» видны без наведения (AURORA-2033) #236

Merged
aep merged 2 commits from feat/235-kfo-include-flags into main 2026-11-10 12:16:52 +03:00
5 changed files with 139 additions and 19 deletions
Showing only changes of commit edb3a5303e - Show all commits

View File

@ -74,6 +74,16 @@ TBD - created by archiving change corrections-table-ui. Update Purpose after arc
(поставлен) и затем перечитывать таблицу целиком. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда
учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4).
Статус учёта SHALL читаться без наведения (AURORA-2033, замечание заказчика 09.11.2026; макет 23.10): рядом с флажком —
подпись «Да» (зелёная) или «Нет» (серая); у агрегата вместо бледного неактивного флажка — зелёная отметка «Да»; рамка флажка
нераспределённой строки SHALL быть контрастной к её приглушённому фону. Приглушение исключённой строки SHALL NOT касаться
ячейки флажка — «Нет» остаётся читаемым, строку можно вернуть.
#### Scenario: Статус учёта виден без наведения
- **WHEN** таблица показывает агрегат и нераспределённую строку с `excluded = true`
- **THEN** у агрегата — зелёная отметка «Да», у строки — снятый флажок с подписью «Нет» в полную яркость, хотя остальные ячейки
строки приглушены
#### Scenario: Исключить нераспределённую строку из КФО
- **WHEN** аналитик снимает флажок у нераспределённой строки «ЭПТ» по 76.09
- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...], side: "D"}`, таблица обновляется ответом, строка приглушена

View File

@ -1,15 +1,16 @@
// Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`.
// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`.
import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd';
import { Button, Dropdown, Table, Tag, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import type { ColumnGroupType, ColumnType } from 'antd/es/table';
import { type ReactNode, useMemo } from 'react';
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
import { IncludeFlag } from './IncludeFlag';
import { COLUMN_DEFS, type ColKey } from './columns';
import { percentText } from './editorView';
import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly';
import { allocationMenu, includeBody, includeView, menuBody } from './readOnly';
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
import type { visibleTotals } from './tableModel';
@ -111,19 +112,20 @@ export function CorrectionTable(props: Props) {
const redIf = (text: string): ReactNode =>
a.exceeds_threshold && text !== '—' ? <span style={{ color: COLOR_OVER }}>{text}</span> : text;
switch (key) {
case 'include':
// агрегат учитывается в КФО всегда: флажок отмечен и закреплён, лишняя строка убирается из правила
return withHint(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Checkbox disabled checked aria-label='Учитывать в КФО: агрегат учитывается всегда' />
case 'include': {
// агрегат учитывается в КФО всегда: галочка закреплена, лишняя строка убирается из правила
const view = includeView(row, readOnlyHint);
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, verticalAlign: 'middle' }}>
{view && <IncludeFlag view={view} ariaLabel='Учитывать в КФО' />}
{a.composition_changed && (
<Tooltip title='Состав изменился после корректировки'>
<WarningOutlined style={{ color: '#dc6803' }} />
</Tooltip>
)}
</span>,
AGGREGATE_FLAG_HINT,
</span>
);
}
case 'd_entity':
return a.company_1.name;
case 'k_entity':
@ -181,17 +183,19 @@ export function CorrectionTable(props: Props) {
const owned = sideSuffix(row); // 'D' | 'K'
const own = (side: 'D' | 'K') => owned === side;
switch (key) {
case 'include':
case 'include': {
// флажок — только у нераспределённой строки; строка агрегата учитывается вместе с агрегатом
if (row.kind !== 'unallocated') return null;
return withHint(
<Checkbox
const view = includeView(row, readOnlyHint);
if (!view) return null;
return (
<IncludeFlag
view={view}
disabled={!canAct}
checked={!row.entry.excluded}
aria-label={`Учитывать в КФО: ${e.account_code}`}
onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))}
/>,
ariaLabel={`Учитывать в КФО: ${e.account_code}`}
onChange={(checked) => actions.onAllocate(includeBody(e.entry_ids, e.side, checked))}
/>
);
}
case 'd_entity':
if (row.kind !== 'unallocated') return null;
return own('D') ? row.entry.owner.name : row.entry.counterparty.name;
@ -244,7 +248,8 @@ export function CorrectionTable(props: Props) {
onCell: (row) => ({
style: {
background: rowBg(row) ?? tint(key),
opacity: row.kind === 'unallocated' && row.entry.excluded ? 0.5 : 1,
// исключённая строка приглушена, но не ячейка флажка: «Нет» должно читаться, чтобы строку можно было вернуть
opacity: key !== 'include' && row.kind === 'unallocated' && row.entry.excluded ? 0.5 : 1,
},
}),
});

View File

@ -0,0 +1,54 @@
// Флажок «Учитывать в КФО» с подписью «Да»/«Нет» (AURORA-2033, макет 23.10). Статус читается по подписи и цвету даже
// там, где флажок неактивен; у агрегата вместо бледного неактивного флажка — зелёная галочка (учитывается всегда).
import { CheckSquareFilled } from '@ant-design/icons';
import { Checkbox, ConfigProvider, Tooltip } from 'antd';
import type { ThemeConfig } from 'antd';
import type { IncludeView } from './readOnly';
const COLOR_ON = '#2a8c3a';
const COLOR_OFF = '#667085';
// Рамка флажка по умолчанию (#d9d9d9) сливается с серой заливкой нераспределённой строки — делаем её темнее;
// неактивный флажок (правки закрыты) — тоже контрастнее стандартного. Объект вне компонента: один и тот же для всех ячеек.
const FLAG_THEME: ThemeConfig = {
components: { Checkbox: { colorBorder: '#98a2b3', colorBgContainerDisabled: '#eaecf0', colorTextDisabled: COLOR_OFF } },
};
type Props = {
view: IncludeView;
/** Флажок неактивен: правки закрыты или идёт запрос. У закреплённого агрегата не используется. */
disabled?: boolean;
/** Подпись для экранного диктора: что за строка. */
ariaLabel: string;
onChange?: (checked: boolean) => void;
};
export function IncludeFlag({ view, disabled = false, ariaLabel, onChange }: Props) {
// высота строки = высоте флажка (16px): иначе подпись стоит выше его центра
const text = (
<span style={{ fontSize: 12, fontWeight: 600, lineHeight: '16px', color: view.checked ? COLOR_ON : COLOR_OFF }}>{view.label}</span>
);
const node = view.locked ? (
<span
role='img'
aria-label={`${ariaLabel}: да, учитывается всегда`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, verticalAlign: 'middle' }}>
<CheckSquareFilled style={{ color: COLOR_ON, fontSize: 16 }} />
{text}
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, verticalAlign: 'middle' }}>
<ConfigProvider theme={FLAG_THEME}>
<Checkbox checked={view.checked} disabled={disabled} aria-label={ariaLabel} onChange={(e) => onChange?.(e.target.checked)} />
</ConfigProvider>
{text}
</span>
);
return view.hint ? (
<Tooltip title={view.hint}>
<span>{node}</span>
</Tooltip>
) : (
node
);
}

View File

@ -1,7 +1,17 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import { makeMockTable } from './mockData';
import { COMMENT_MAX_LENGTH, allocationMenu, errorOutcome, includeBody, menuBody, needsReload, readOnlyHint } from './readOnly';
import {
AGGREGATE_FLAG_HINT,
COMMENT_MAX_LENGTH,
allocationMenu,
errorOutcome,
includeBody,
includeView,
menuBody,
needsReload,
readOnlyHint,
} from './readOnly';
import type { TableRow } from './tableModel';
describe('readOnlyHint', () => {
@ -149,3 +159,30 @@ describe('errorOutcome', () => {
it('лимит комментария — 1000 (контракт §3.2)', () => {
expect(COMMENT_MAX_LENGTH).toBe(1000);
});
describe('includeView', () => {
const t = makeMockTable();
const aggregateRow: TableRow = { kind: 'aggregate', key: 'a', aggregate: t.aggregates[0] };
const unallocRow = (excluded: boolean): TableRow => ({ kind: 'unallocated', key: 'u', entry: { ...t.unallocated[0], excluded } });
it('агрегат: «Да», закреплён, подсказка — всегда учитывается', () => {
expect(includeView(aggregateRow, null)).toEqual({ checked: true, locked: true, label: 'Да', hint: AGGREGATE_FLAG_HINT });
});
it('агрегат: подсказка про агрегат и при закрытых правках', () => {
expect(includeView(aggregateRow, 'Возьмите консолидацию в работу')?.hint).toBe(AGGREGATE_FLAG_HINT);
});
it('нераспределённая учитывается: «Да», переключается', () => {
expect(includeView(unallocRow(false), null)).toEqual({ checked: true, locked: false, label: 'Да', hint: null });
});
it('нераспределённая исключена: «Нет»', () => {
expect(includeView(unallocRow(true), null)).toMatchObject({ checked: false, label: 'Нет' });
});
it('правки закрыты — подсказка «почему»', () => {
expect(includeView(unallocRow(false), 'Возьмите консолидацию в работу')?.hint).toBe('Возьмите консолидацию в работу');
});
it('строка ОСВ внутри агрегата флажка не имеет', () => {
const a = t.aggregates[0];
const entry = [...a.side_1.entries, ...a.side_2.entries][0];
expect(includeView({ kind: 'entry', key: 'e', parentKey: 'a', side: 1, entry }, null)).toBeNull();
});
});

View File

@ -40,6 +40,20 @@ export const COMMENT_MAX_LENGTH = 1000;
/** Подсказка у флажка агрегата: строки агрегата учитываются всегда, лишнюю строку из него убирают. */
export const AGGREGATE_FLAG_HINT = 'Агрегат всегда учитывается в КФО; лишнюю строку уберите из правила';
/**
* Что показать в колонке «Учитывать в КФО» (AURORA-2033, макет 23.10: флажок с подписью «Да»/«Нет»).
* Агрегат учитывается всегда — флажок закреплён (`locked`), подсказка объясняет почему; нераспределённая строка
* переключается, подсказка — причина «только чтение»; у строки ОСВ внутри агрегата флажка нет (`null`).
*/
export type IncludeView = { checked: boolean; locked: boolean; label: 'Да' | 'Нет'; hint: string | null };
export function includeView(row: TableRow, readOnlyHint: string | null): IncludeView | null {
if (row.kind === 'aggregate') return { checked: true, locked: true, label: 'Да', hint: AGGREGATE_FLAG_HINT };
if (row.kind !== 'unallocated') return null;
const checked = !row.entry.excluded;
return { checked, locked: false, label: checked ? 'Да' : 'Нет', hint: readOnlyHint };
}
/** Тело запроса для флажка «Учитывать в КФО» нераспределённой строки: поставлен — INCLUDE, снят — EXCLUDE. */
export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn {
return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' };