fix(#235): флажки «Учитывать в КФО» видны без наведения (AURORA-2033) #236
@ -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"}`, таблица обновляется ответом, строка приглушена
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
54
web/src/widgets/CorrectionTable/IncludeFlag.tsx
Normal file
54
web/src/widgets/CorrectionTable/IncludeFlag.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user