From fd3bb79a9bfb71fd356082cd01a0875ada3b337d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 10:41:28 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=88=D0=B0=D1=85=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0=20=D1=81?= =?UTF-8?q?=D1=87=D0=B5=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D0=BC=D1=83=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=83,=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20css=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B0=20(AURORA-1538,=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toggleTreeCheck (utils/treeChecks): родитель отмечен, только если отмечены все потомки, на всех уровнях; скрытые поиском отметки берутся из состояния выпадашки — снятые подряд отметки больше не возвращаются; тесты. - Правило тегов из App.css → ConsolidationChessTableFilter.css (App.css пустой, как в main); класс с формы ЮЛ снят. - Спека: Known issues — остаток (одна сторона Дт/Кт) в техдолг AURORA-1799, решение (п), tasks. Co-Authored-By: Claude Opus 5.5 --- .../changes/intercompany-matrix-ui/design.md | 18 ++++-- .../changes/intercompany-matrix-ui/tasks.md | 7 +- web/src/App.css | 6 -- .../TreeFilterDropdown/TreeFilterDropdown.tsx | 64 +------------------ web/src/utils/treeChecks.test.ts | 45 +++++++++++++ web/src/utils/treeChecks.ts | 44 +++++++++++++ .../CatalogueJurForm/CatalogueJurForm.tsx | 1 - .../ConsolidationChessTableFilter.css | 7 ++ .../ConsolidationChessTableFilter.tsx | 3 +- 9 files changed, 115 insertions(+), 80 deletions(-) create mode 100644 web/src/utils/treeChecks.test.ts create mode 100644 web/src/utils/treeChecks.ts create mode 100644 web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.css diff --git a/openspec/changes/intercompany-matrix-ui/design.md b/openspec/changes/intercompany-matrix-ui/design.md index 35b62f3..52b451c 100644 --- a/openspec/changes/intercompany-matrix-ui/design.md +++ b/openspec/changes/intercompany-matrix-ui/design.md @@ -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 на экране прежние данные, выгрузка недоступна. diff --git a/openspec/changes/intercompany-matrix-ui/tasks.md b/openspec/changes/intercompany-matrix-ui/tasks.md index ce4b124..bc82b5f 100644 --- a/openspec/changes/intercompany-matrix-ui/tasks.md +++ b/openspec/changes/intercompany-matrix-ui/tasks.md @@ -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. diff --git a/web/src/App.css b/web/src/App.css index 4f7a3bb..e69de29 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -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; -} diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx index a0c4716..5e5f680 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -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 = ({ }; 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); diff --git a/web/src/utils/treeChecks.test.ts b/web/src/utils/treeChecks.test.ts new file mode 100644 index 0000000..3c1ec2f --- /dev/null +++ b/web/src/utils/treeChecks.test.ts @@ -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']); + }); +}); diff --git a/web/src/utils/treeChecks.ts b/web/src/utils/treeChecks.ts new file mode 100644 index 0000000..406695f --- /dev/null +++ b/web/src/utils/treeChecks.ts @@ -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) => { + 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); +}; diff --git a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx index fa3a1a1..bd865e0 100644 --- a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx +++ b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx @@ -213,7 +213,6 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId label='Альтернативные наименования' extra='Введите синоним и нажмите Enter. Можно добавить несколько значений.'>