Merge pull request 'fix(#235): флажки «Учитывать в КФО» видны без наведения (AURORA-2033)' (#236) from feat/235-kfo-include-flags into main
This commit is contained in:
commit
59ac0d1880
@ -74,6 +74,16 @@ TBD - created by archiving change corrections-table-ui. Update Purpose after arc
|
|||||||
(поставлен) и затем перечитывать таблицу целиком. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда
|
(поставлен) и затем перечитывать таблицу целиком. Флажок агрегата SHALL быть отмечен и неактивен с подсказкой «Агрегат всегда
|
||||||
учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4).
|
учитывается в КФО; лишнюю строку уберите из правила»; у строк агрегата флажка SHALL NOT быть (ответ Лысенко 29.10, п. 4).
|
||||||
|
|
||||||
|
Статус учёта SHALL читаться без наведения (AURORA-2033, замечание заказчика 09.11.2026; макет 23.10): рядом с флажком —
|
||||||
|
подпись «Да» (зелёная) или «Нет» (серая); у агрегата вместо бледного неактивного флажка — зелёная отметка «Да»; рамка флажка
|
||||||
|
нераспределённой строки SHALL быть контрастной к её приглушённому фону. Приглушение исключённой строки SHALL NOT касаться
|
||||||
|
ячейки флажка — «Нет» остаётся читаемым, строку можно вернуть.
|
||||||
|
|
||||||
|
#### Scenario: Статус учёта виден без наведения
|
||||||
|
- **WHEN** таблица показывает агрегат и нераспределённую строку с `excluded = true`
|
||||||
|
- **THEN** у агрегата — зелёная отметка «Да», у строки — снятый флажок с подписью «Нет» в полную яркость, хотя остальные ячейки
|
||||||
|
строки приглушены
|
||||||
|
|
||||||
#### Scenario: Исключить нераспределённую строку из КФО
|
#### Scenario: Исключить нераспределённую строку из КФО
|
||||||
- **WHEN** аналитик снимает флажок у нераспределённой строки «ЭПТ» по 76.09
|
- **WHEN** аналитик снимает флажок у нераспределённой строки «ЭПТ» по 76.09
|
||||||
- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...], side: "D"}`, таблица обновляется ответом, строка приглушена
|
- **THEN** уходит `{action: "EXCLUDE", entry_ids: [...], side: "D"}`, таблица обновляется ответом, строка приглушена
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
// Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`.
|
// Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`.
|
||||||
// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`.
|
// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`.
|
||||||
import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons';
|
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 { MenuProps } from 'antd';
|
||||||
import type { ColumnGroupType, ColumnType } from 'antd/es/table';
|
import type { ColumnGroupType, ColumnType } from 'antd/es/table';
|
||||||
import { type ReactNode, useMemo } from 'react';
|
import { type ReactNode, useMemo } from 'react';
|
||||||
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
|
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
|
||||||
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
|
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
|
||||||
|
import { IncludeFlag } from './IncludeFlag';
|
||||||
import { COLUMN_DEFS, type ColKey } from './columns';
|
import { COLUMN_DEFS, type ColKey } from './columns';
|
||||||
import { percentText } from './editorView';
|
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 DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
|
||||||
import type { visibleTotals } from './tableModel';
|
import type { visibleTotals } from './tableModel';
|
||||||
|
|
||||||
@ -111,19 +112,20 @@ export function CorrectionTable(props: Props) {
|
|||||||
const redIf = (text: string): ReactNode =>
|
const redIf = (text: string): ReactNode =>
|
||||||
a.exceeds_threshold && text !== '—' ? <span style={{ color: COLOR_OVER }}>{text}</span> : text;
|
a.exceeds_threshold && text !== '—' ? <span style={{ color: COLOR_OVER }}>{text}</span> : text;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'include':
|
case 'include': {
|
||||||
// агрегат учитывается в КФО всегда: флажок отмечен и закреплён, лишняя строка убирается из правила
|
// агрегат учитывается в КФО всегда: галочка закреплена, лишняя строка убирается из правила
|
||||||
return withHint(
|
const view = includeView(row, readOnlyHint);
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
return (
|
||||||
<Checkbox disabled checked aria-label='Учитывать в КФО: агрегат учитывается всегда' />
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, verticalAlign: 'middle' }}>
|
||||||
|
{view && <IncludeFlag view={view} ariaLabel='Учитывать в КФО' />}
|
||||||
{a.composition_changed && (
|
{a.composition_changed && (
|
||||||
<Tooltip title='Состав изменился после корректировки'>
|
<Tooltip title='Состав изменился после корректировки'>
|
||||||
<WarningOutlined style={{ color: '#dc6803' }} />
|
<WarningOutlined style={{ color: '#dc6803' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</span>,
|
</span>
|
||||||
AGGREGATE_FLAG_HINT,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'd_entity':
|
case 'd_entity':
|
||||||
return a.company_1.name;
|
return a.company_1.name;
|
||||||
case 'k_entity':
|
case 'k_entity':
|
||||||
@ -181,17 +183,19 @@ export function CorrectionTable(props: Props) {
|
|||||||
const owned = sideSuffix(row); // 'D' | 'K'
|
const owned = sideSuffix(row); // 'D' | 'K'
|
||||||
const own = (side: 'D' | 'K') => owned === side;
|
const own = (side: 'D' | 'K') => owned === side;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'include':
|
case 'include': {
|
||||||
// флажок — только у нераспределённой строки; строка агрегата учитывается вместе с агрегатом
|
// флажок — только у нераспределённой строки; строка агрегата учитывается вместе с агрегатом
|
||||||
if (row.kind !== 'unallocated') return null;
|
const view = includeView(row, readOnlyHint);
|
||||||
return withHint(
|
if (!view) return null;
|
||||||
<Checkbox
|
return (
|
||||||
|
<IncludeFlag
|
||||||
|
view={view}
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
checked={!row.entry.excluded}
|
ariaLabel={`Учитывать в КФО: ${e.account_code}`}
|
||||||
aria-label={`Учитывать в КФО: ${e.account_code}`}
|
onChange={(checked) => actions.onAllocate(includeBody(e.entry_ids, e.side, checked))}
|
||||||
onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))}
|
/>
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'd_entity':
|
case 'd_entity':
|
||||||
if (row.kind !== 'unallocated') return null;
|
if (row.kind !== 'unallocated') return null;
|
||||||
return own('D') ? row.entry.owner.name : row.entry.counterparty.name;
|
return own('D') ? row.entry.owner.name : row.entry.counterparty.name;
|
||||||
@ -244,7 +248,8 @@ export function CorrectionTable(props: Props) {
|
|||||||
onCell: (row) => ({
|
onCell: (row) => ({
|
||||||
style: {
|
style: {
|
||||||
background: rowBg(row) ?? tint(key),
|
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 { describe, expect, it } from 'vitest';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { makeMockTable } from './mockData';
|
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';
|
import type { TableRow } from './tableModel';
|
||||||
|
|
||||||
describe('readOnlyHint', () => {
|
describe('readOnlyHint', () => {
|
||||||
@ -149,3 +159,30 @@ describe('errorOutcome', () => {
|
|||||||
it('лимит комментария — 1000 (контракт §3.2)', () => {
|
it('лимит комментария — 1000 (контракт §3.2)', () => {
|
||||||
expect(COMMENT_MAX_LENGTH).toBe(1000);
|
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 = 'Агрегат всегда учитывается в КФО; лишнюю строку уберите из правила';
|
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. */
|
/** Тело запроса для флажка «Учитывать в КФО» нераспределённой строки: поставлен — INCLUDE, снят — EXCLUDE. */
|
||||||
export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn {
|
export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn {
|
||||||
return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' };
|
return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user