fix(web): шахматка — отметки дерева счетов по полному дереву, стили тегов в css фильтра (AURORA-1538, ревью #158)

- toggleTreeCheck (utils/treeChecks): родитель отмечен, только если отмечены все потомки, на всех уровнях; скрытые
  поиском отметки берутся из состояния выпадашки — снятые подряд отметки больше не возвращаются; тесты.
- Правило тегов из App.css → ConsolidationChessTableFilter.css (App.css пустой, как в main); класс с формы ЮЛ снят.
- Спека: Known issues — остаток (одна сторона Дт/Кт) в техдолг AURORA-1799, решение (п), tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 10:41:28 +04:00
parent 79d4008fcd
commit fd3bb79a9b
9 changed files with 115 additions and 80 deletions

View File

@ -35,14 +35,12 @@
## Known issues
Открытые проблемы ветки (ревью, раунд 3), которые исправляет автор PR; спека описывает целевое поведение.
Открытая проблема на момент слияния (решение владельца 23.10.2026 — сливать, остаток в техдолг AURORA-1799):
1. Поиск в дереве счетов: отметки считаются по отфильтрованному дереву, отметка единственного видимого субсчёта отмечает
родителя, и на бэк уходит весь родительский счёт (`TreeFilterDropdown.tsx`).
2. Выбрана одна сторона Дт/Кт: таблица показывает прежние данные, подсказка — всплывающее уведомление и постоянный текст внутри
выпадающего окна, «Экспорт» активен и выгружает режим `all`.
3. Стили: правило тегов в `App.css` и класс на форме ЮЛ (`CatalogueJurForm.tsx`) — вне экрана; у селекта Кт класса нет;
`.resRow`, `.empty-cell` не под `.chessTable`; у `Tree` снят `className='filterTree'`.
1. Выбрана одна сторона Дт/Кт: «Экспорт» недоступен, но таблица показывает прежние данные, подсказка — всплывающее уведомление
и постоянный текст внутри выпадающего окна, текста у таблицы и у фильтров нет. Целевое поведение — решение (ж).
Пункты раунда 3 про поиск в дереве счетов и стили закрыты ревьюером (решение (п)).
## Decisions made without discussion
@ -91,3 +89,9 @@
- **2026-10-22 (о). Расхождение в скобках всегда**, в том числе нулевое — `(0,00)`: в API `variance_amount` — модуль разности
(неотрицательный), скобки в макете 31-01 — способ показа третьей строки, а не знак. Значок пропущенных строк ОСВ — оранжевый
`WarningOutlined` у первого числа. Подпись «Период» (не «Квартал») — как в макете. Цена ошибки: правка вида ячейки.
- **2026-10-23 (п). Остаток раунда 3 закрыт ревьюером, пункт про одну сторону Дт/Кт — в техдолг** (решение владельца: «доправим
сами, потом сольём»). Отметки дерева — чистая функция `utils/treeChecks.toggleTreeCheck` (правило (з) снизу вверх по полному
дереву на всех уровнях; скрытые поиском отметки берутся из состояния выпадашки, а не из применённого фильтра — иначе снятые
отметки возвращались), тесты `utils/treeChecks.test.ts`. Правило тегов из `App.css` перенесено в
`ConsolidationChessTableFilter.css`, класс с формы ЮЛ снят. Пустая таблица с подсказкой при одной стороне — AURORA-1799.
Цена ошибки: при одной стороне до AURORA-1799 на экране прежние данные, выгрузка недоступна.

View File

@ -3,10 +3,11 @@
- [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение).
- [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер).
- [x] 1.3 Желательные пункты ревью раунда 3 (ревьюер, решение (м) в `design.md`).
- [ ] 1.4 Автор исправляет «Known issues» из `design.md`.
- [ ] 1.5 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
- [ ] 1.6 Сверка экрана с макетом 31-01 / 31-01-01 в браузере (`docs/mockup/INDEX.md`), в том числе полноэкранный режим.
- [x] 1.4 «Known issues» раунда 3: автор (6b956f7, 79d4008) и ревьюер (решение (п)); пункт про одну сторону Дт/Кт — 2.2.
- [x] 1.5 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
- [x] 1.6 Сверка экрана с макетом 31-01 / 31-01-01 в браузере (`docs/mockup/INDEX.md`), в том числе полноэкранный режим.
## 2. После слияния
- [ ] 2.1 Итоги «Итого» из API вместо клиентских — AURORA-1756 (бэк), AURORA-1757 (фронт).
- [ ] 2.2 Одна сторона Дт/Кт — пустая таблица с подсказкой, без уведомления — AURORA-1799.

View File

@ -1,6 +0,0 @@
.custom-select-selection-item .ant-select-selection-item {
background-color: #ecfdf3 !important;
color: #1f5f35 !important;
box-shadow: inset 0 0 0 1px #2ea64b38 !important;
border-radius: 999px !important;
}

View File

@ -1,6 +1,7 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Space, Tree } from 'antd';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { toggleTreeCheck } from '../../utils/treeChecks';
import './TreeFilterDropdown.css';
export type OptionType = {
@ -98,68 +99,7 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
};
function checkHandler(keys: string[], info: { node: OptionType; checked: boolean }) {
const newKeys = new Set([...keys, ...selectedKeys]);
const getAllDescendants = (node: OptionType): string[] => {
let res: string[] = [];
if (node?.children) {
node.children.forEach((child) => {
res.push(child.key);
res = [...res, ...getAllDescendants(child)];
});
}
return res;
};
const findNodeByKey = (nodes: OptionType[], key: string): OptionType | undefined => {
for (const node of nodes) {
if (node.key === key) return node;
if (node.children) {
const found = findNodeByKey(node.children, key);
if (found) return found;
}
}
return undefined;
};
function findParentByKey(nodes: OptionType[], targetKey: string, parent: OptionType | null = null): OptionType | undefined {
for (const node of nodes) {
if (node.key === targetKey) {
return parent || undefined;
}
if (node.children) {
const foundParent = findParentByKey(node.children, targetKey, node);
if (foundParent) {
return foundParent;
}
}
}
return undefined;
}
const node = findNodeByKey(treeData, info.node.key);
const childrens = getAllDescendants(node as OptionType);
if (node?.key) {
if (info.checked) {
newKeys.add(node.key);
childrens?.forEach((elem) => newKeys.add(elem));
const parentNode = findParentByKey(treeData, node.key);
if (parentNode?.key) {
const childrens = getAllDescendants(parentNode as OptionType);
const isAllChildrenSelected = childrens.every((childKey) => newKeys.has(childKey));
if (!isAllChildrenSelected) {
newKeys.delete(parentNode.key);
}
}
} else {
const parentNode = findParentByKey(treeData, node.key);
if (parentNode?.key) {
newKeys.delete(parentNode?.key);
}
newKeys.delete(node.key);
childrens?.forEach((elem) => newKeys.delete(elem));
}
}
setCheckedKeys(Array.from(newKeys));
setCheckedKeys(toggleTreeCheck(treeData, checkedKeys, keys, info.node.key, info.checked));
}
useEffect(() => {
setCheckedKeys(selectedKeys);

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { type TreeCheckNode, toggleTreeCheck } from './treeChecks';
// 60 → 60.01, 60.02; 60.02 → 60.02.1, 60.02.2; 62 без детей
const tree: TreeCheckNode[] = [
{
key: '60',
children: [{ key: '60.01' }, { key: '60.02', children: [{ key: '60.02.1' }, { key: '60.02.2' }] }],
},
{ key: '62' },
];
const sorted = (keys: string[]) => [...keys].sort();
describe('toggleTreeCheck', () => {
it('отметка родителя отмечает всех потомков', () => {
expect(sorted(toggleTreeCheck(tree, [], [], '60', true))).toEqual(['60', '60.01', '60.02', '60.02.1', '60.02.2']);
});
it('единственный найденный субсчёт не отмечает родителя, даже если дерево поиска прислало родителя', () => {
// при поиске у 60 виден только 60.01, и Tree сам дописывает 60 в отмеченные
expect(sorted(toggleTreeCheck(tree, [], ['60', '60.01'], '60.01', true))).toEqual(['60.01']);
});
it('родитель отмечается, когда отмечены все его субсчета, на всех уровнях', () => {
const keys = toggleTreeCheck(tree, ['60.01', '60.02.1'], [], '60.02.2', true);
expect(sorted(keys)).toEqual(['60', '60.01', '60.02', '60.02.1', '60.02.2']);
});
it('снятие субсчёта снимает родителя и предков, остальные отметки остаются', () => {
const all = ['60', '60.01', '60.02', '60.02.1', '60.02.2', '62'];
expect(sorted(toggleTreeCheck(tree, all, [], '60.02.1', false))).toEqual(['60.01', '60.02.2', '62']);
});
it('снятые подряд отметки не возвращаются', () => {
const first = toggleTreeCheck(tree, ['60.01', '62'], ['62'], '60.01', false);
expect(first).toEqual(['62']);
expect(toggleTreeCheck(tree, first, [], '62', false)).toEqual([]);
});
it('отметки, скрытые поиском, сохраняются', () => {
// виден только 62, а 60.01 отмечен раньше и скрыт фильтром
expect(sorted(toggleTreeCheck(tree, ['60.01'], ['62'], '62', true))).toEqual(['60.01', '62']);
});
});

View File

@ -0,0 +1,44 @@
export type TreeCheckNode = {
key: string;
children?: TreeCheckNode[];
};
const descendantKeys = (node: TreeCheckNode): string[] => (node.children ?? []).flatMap((child) => [child.key, ...descendantKeys(child)]);
const findNode = (nodes: TreeCheckNode[], key: string): TreeCheckNode | undefined => {
for (const node of nodes) {
if (node.key === key) return node;
const found = node.children && findNode(node.children, key);
if (found) return found;
}
return undefined;
};
// Снизу вверх: родитель отмечен, только если отмечены все его дети (а значит, и все потомки)
const syncParents = (nodes: TreeCheckNode[], keys: Set<string>) => {
nodes.forEach((node) => {
if (!node.children?.length) return;
syncParents(node.children, keys);
if (node.children.every((child) => keys.has(child.key))) keys.add(node.key);
else keys.delete(node.key);
});
};
/**
* Новые отметки дерева после клика по узлу `key`.
* Отметки считаются по полному дереву, а не по найденному поиском: `treeKeys` (что прислал Tree по видимым узлам)
* может содержать родителя, у которого виден один субсчёт, — он снимается, если отмечены не все субсчета.
* Отметки, скрытые поиском, берутся из `prevKeys` (текущее состояние выпадашки, не применённый фильтр).
*/
export const toggleTreeCheck = (tree: TreeCheckNode[], prevKeys: string[], treeKeys: string[], key: string, checked: boolean): string[] => {
const keys = new Set([...prevKeys, ...treeKeys]);
const node = findNode(tree, key);
if (node) {
for (const k of [node.key, ...descendantKeys(node)]) {
if (checked) keys.add(k);
else keys.delete(k);
}
}
syncParents(tree, keys);
return Array.from(keys);
};

View File

@ -213,7 +213,6 @@ export const CatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, jurId
label='Альтернативные наименования'
extra='Введите синоним и нажмите Enter. Можно добавить несколько значений.'>
<Select
className='custom-select-selection-item'
mode='tags'
style={{ width: '100%' }}
placeholder='Добавить синоним'

View File

@ -0,0 +1,7 @@
/* Теги выбранных значений в селектах фильтра шахматки */
.custom-select-selection-item .ant-select-selection-item {
background-color: #ecfdf3 !important;
color: #1f5f35 !important;
box-shadow: inset 0 0 0 1px #2ea64b38 !important;
border-radius: 999px !important;
}

View File

@ -6,6 +6,7 @@ import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilter
import type { Account, MatrixCompany, Quarter } from '../../types/api';
import { quarterShortLabel } from '../../utils/labels';
import { useNotificationApi } from '../../utils/useNotification';
import './ConsolidationChessTableFilter.css';
interface ConsolidationChessTableFilterI {
companies: string[];
@ -148,7 +149,7 @@ export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilt
variant='underlined'
showSearch={false}
popupMatchSelectWidth={false}
className='custom-select-selection-item '
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Компании'