Merge origin/main в AURORA-1589: шапка, «Единицы» и [31-00] — в страницу отчёта с вкладками #158 (ревью #166)

Конфликты после #158 (страница отчёта с вкладками по адресу):
- layouts/ConsolidationReportPage: пояснение статуса, тег, «Изменить статус» и модалка [33-01] из ConsolidatedOsvPage;
- widgets/ConsolidatedOsvContent: «Единицы / Знаков» и onUnmappedChange (unmapped_total наверх);
- OsvTabs: вкладки по адресу из main + модалка [31-00] при уходе с «ОСВ»;
- types/api.ts: обе части.
Спека consolidations-ui: решение (ц), Known issues 11 и 15 (lucide-react в зеркале есть), tasks 2.5, 2.8;
ARCHITECTURE.md и web/CLAUDE.md — новая страница отчёта.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 10:59:14 +04:00
commit 36224dd81c
37 changed files with 2282 additions and 278 deletions

View File

@ -0,0 +1,97 @@
## Context
Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`, AURORA-1538) как вторая вкладка страницы
отчёта по версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит
`layouts/ConsolidationReportPage.tsx`, содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений
поведения) и `widgets/ConsolidatedChessContent` (матрица `ConsolidationChessTable` и результирующая таблица
`ConsolidationChessResTable`). Бэкенд — change `2026-10-16-intercompany-matrix` (матрица, результирующая таблица) и
`2026-10-22-intercompany-result-table-export` (выгрузка результирующей таблицы); расчёт — чистая функция версии, ничего не
хранится.
## Decisions
- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет
последний сегмент адреса. Неизвестный `tab` заменяется на `osv` (`navigate(…, {replace: true})`).
- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка,
фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`).
- **D-3. Режим расчёта из выбора счетов:** `manual` — при непустых Дт и Кт, `all` — при пустых обоих; при одной выбранной
стороне запроса нет (бэкенд ответил бы 422 на `manual`, а `all` показал бы не то, что выбрано). Тот же признак выбирает
маршрут экспорта.
- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии.
Единицы — сдвиг запятой в десятичной строке (`utils/chessTableCellFormatValue.ts`), `Intl.NumberFormat` получает строку и
форматирует её как точное десятичное (без float).
- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`.
- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее.
- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока, состояние следует за `fullscreenchange`; выпадающие окна
antd рендерятся в `document.fullscreenElement` (`ConfigProvider getPopupContainer`).
- **D-8. Итоги — задача бэкенда.** Решение владельца 22.10.2026: агрегаты (строки «Итого») должен отдавать API. Перенос —
технический долг: AURORA-1756 (итоги и правило суммирования в API, ждёт аналитику AURORA-1373) и AURORA-1757 (вывод итогов из
API). До этого строки «Итого» остаются клиентскими (решение владельца: «особо не мешает»): точное сложение строк через
`sumAmounts`, под матрицей — `amount_a` по колонке, под результирующей таблицей — все статьи по колонке; колонка «Итого»
результирующей таблицы — `group_total` из ответа.
- **D-9. `useNotificationApi` мемоизирован** (`useMemo` по `notification` из `App.useApp()`, который antd сам мемоизирует):
объект уведомлений стабилен между рендерами, поэтому `loadData`/`loadResData` — `useCallback([notification])`, а эффекты
загрузки зависят от них без лишних перезапросов.
## Known issues
Открытая проблема на момент слияния (решение владельца 23.10.2026 — сливать, остаток в техдолг AURORA-1799):
1. Выбрана одна сторона Дт/Кт: «Экспорт» недоступен, но таблица показывает прежние данные, подсказка — всплывающее уведомление
и постоянный текст внутри выпадающего окна, текста у таблицы и у фильтров нет. Целевое поведение — решение (ж).
Пункты раунда 3 про поиск в дереве счетов и стили закрыты ревьюером (решение (п)).
## Decisions made without discussion
- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить
автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change.
- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Отменено 22.10.2026 (е). Обоснование: спека — единственный
источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена
ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть
и после исправления потребуют MODIFIED.
- **2026-10-21 (в). При восстановлении затёртого фронта** (коммит 175343b) из `origin/main` взяты целиком все файлы без
шахматочных изменений, а в `App.tsx`, `layouts/index.ts`, `api/client.ts` (параметр `init`), `OsvTabs.tsx`,
`ConsolidatedOsvContent.tsx` оставлены изменения шахматки. Возвращён `key={version_id}` (разделением страницы на обёртку и
содержимое — поведение как в main). Обоснование: изменения PR в этих файлах относятся к шахматке; цена ошибки — незамеченная
откатившаяся правка main в этих пяти файлах (построчно сверено с `origin/main`, расхождений кроме шахматочных нет).
- **2026-10-21 (г). Механические правки biome без смены поведения** (коммит e19291c): тип `IntercompanyMatrixResponse` у `let res`,
`Number.isNaN`, `key` у `span` в `OsvTabs`, `?? null` вместо пустого фрагмента. Предупреждения eslint (8 новых в коде шахматки:
`any`, deps хуков, `console`) не исправлялись — они входят в Known issues. Цена ошибки: `key` на `span` внутри `Tooltip`
безвреден, но избыточен.
- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст
`consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают
один компонент (`OsvTabs`).
- **2026-10-22 (е). Спека переписана на целевое поведение** (раунд 3 ревью): владелец решил, что спеки и тесты чужих PR ведём
мы, а автор правит продуктовый код; целевая спека даёт автору однозначный ориентир, расхождения кода — в «Known issues».
Цена ошибки: до исправлений спека и код ветки расходятся; PR не сливается, пока «Known issues» не закрыты.
- **2026-10-22 (ж). Выбрана одна сторона Дт/Кт — пустая таблица с текстом** «Для расчёта по счетам выберите счета и в Дт,
и в Кт» у таблицы и у фильтров, «Экспорт» матрицы недоступен. Альтернатива — оставить прежние данные с подсказкой; отвергнута:
на экране были бы данные, не соответствующие фильтру. Цена ошибки: правка текста и одного условия.
- **2026-10-22 (з). Правило отметок дерева по полному плану счетов** (родитель отмечен ⇔ отмечены все потомки), в том числе при
поиске. Обоснование: бэкенд разворачивает выбранный счёт в потомков, лишняя отметка родителя меняет расчёт. Цена ошибки: если
аналитик захочет «родитель + выбранные субсчета» как отдельный режим — MODIFIED требования.
- **2026-10-22 (и). Запасные имена файлов** `Шахматка_<ГК>_<год>_<квартал>.xlsx` и
`Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx`. Цена ошибки: низкая, имя из `Content-Disposition` приоритетнее.
- **2026-10-22 (к). Тесты форматтера** `utils/chessTableCellFormatValue.test.ts` написаны ревьюером: сдвиг единиц, точное
округление, большие суммы, «-0,00», пустые и нечисловые значения при любых единицах.
- **2026-10-22 (л). В ветку влит `main`** (PR #159), чтобы выгрузка результирующей таблицы работала на стенде из ветки.
- **2026-10-22 (м). Желательные пункты ревью закрыты ревьюером** (решение владельца): форматтер (проверка числа до сдвига
запятой, `Intl.StringNumericLiteral` через `ES2023.Intl` в `lib` tsconfig вместо `as any`, без мёртвого разбора скобок),
запасные имена (`utils/download.xlsxFilename`), отдельные флаги выгрузки, зависимости эффектов (D-9), «—» вместо выдуманного
нуля, ячейки по макету, «Год»/«Период» версии только для чтения, сервис `services/api/intercompanyMatrix.ts` с `withJsonBody`,
типы шахматки в `types/api.ts`, `Account.kind: 'A' | 'P' | 'AP' | null`. Цена ошибки: конфликты слияния у автора в
`ConsolidatedChessContent.tsx` и `ConsolidationChessTableFilter.tsx` — он правит те же файлы по «Known issues»; автору сказано
начать с `git pull`.
- **2026-10-22 (н). «-0,00» убирается проверкой готовой строки, а не опцией `signDisplay: 'negative'`.** Обоснование: на движке
без Intl.NumberFormat v3 неизвестное значение `signDisplay` бросает `RangeError` и ломает весь экран, а проверка строки
безопасна везде. Цена ошибки: нет.
- **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

@ -0,0 +1,49 @@
## Why
В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы
отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main
(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку и тесты ведёт ревьюер в ветке автора по решению
владельца (21–22.10.2026): спека задаёт целевое поведение экрана, расхождения кода ветки с ней — `design.md`, «Known issues».
## What Changes
Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core:
`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`,
`POST …/intercompany-matrix/manual/export`, `GET …/intercompany-matrix/result-table`,
`GET …/intercompany-matrix/result-table/export`, `GET /directories/accounts`.
- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidationReportPage`) с общей шапкой и
вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать.
- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение); итоги считает бэкенд (пока API их не отдаёт —
строки «Итого» нет).
- Результирующая таблица ВГО по статьям с колонкой «Итого» (`group_total`) и выгрузкой в XLSX.
- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`.
- Экспорт в XLSX (`all` / `manual`, результирующая таблица), полноэкранный режим.
- Новая спека `intercompany-matrix-ui` (ADDED Requirements).
## Non-goals
- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR; спеку и тесты ведёт ревьюер.
- Пресеты счетов — во фронте не реализованы.
- Итоги «Итого» в API и их вывод — AURORA-1756, AURORA-1757.
- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом.
- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5).
- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны.
## Capabilities
### New Capabilities
- `intercompany-matrix-ui`: экран «Шахматка» версии консолидации.
### Modified Capabilities
Нет: `consolidated-osv-ui` не правится (вкладки описаны в новой спеке, см. design.md).
## Impact
- `web/src/App.tsx`, `web/src/layouts/{ConsolidationReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`,
`web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`,
`web/src/widgets/ConsolidationChessTable/`, `web/src/widgets/ConsolidationChessResTable/`,
`web/src/widgets/ConsolidationChessFormatTableFilter/`, `web/src/components/TreeFilterDropdown/`,
`web/src/utils/chessTableCellFormatValue.ts`, `web/src/services/consodilation/intercompanyMatrix.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`).
- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается.

View File

@ -0,0 +1,179 @@
## ADDED Requirements
### Requirement: Вкладки страницы отчёта по версии
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий»,
«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
#### Scenario: Переход на шахматку
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка
#### Scenario: Прежний адрес сводной ОСВ
- **WHEN** открыт адрес `…/versions/{id}/osv`
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
#### Scenario: Неизвестная вкладка
- **WHEN** открыт адрес `…/versions/{id}/abc`
- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
### Requirement: Таблица шахматки ЮЛ × ЮЛ
Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
и расхождение `variance_amount` в скобках; расхождение выделяется красным, если `exceeds_threshold = true` или
`one_sided = true` (пара без встречной стороны; правило подсветки — как в API и xlsx, AURORA-1651). Если у пары есть
строки ОСВ без сопоставленного счёта (`skipped_osv_entry_count > 0`), рядом с первым числом SHALL показываться значок
предупреждения с подсказкой «Строк ОСВ без сопоставленного счёта: N; в суммы не вошли». Если ячейки пары в ответе нет
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы закреплена строка «Итого»: до
появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением десятичных строк — сумма
`amount_a` по колонке видимых ячеек; итог, который не удалось сложить, показывается прочерком «—», а не нулём. Пока идёт
загрузка, таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера. Панель фильтров
показывает год и квартал версии неактивными полями «Год» и «Период» (например, `2024` и `К1`): версия задаётся адресом.
#### Scenario: Ячейка с расхождением выше порога
- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом
#### Scenario: Пара без встречной стороны
- **WHEN** для пары `(A, Б)` сервер вернул `amount_b = 0`, `one_sided = true`, `exceeds_threshold = false`
- **THEN** третье число ячейки (расхождение) показано красным
#### Scenario: Пары нет в ответе
- **WHEN** в `cells[]` нет ячейки для пары компаний
- **THEN** на их пересечении показан прочерк «—»
#### Scenario: Пропущенные строки ОСВ
- **WHEN** у ячейки `skipped_osv_entry_count = 2`
- **THEN** у первого числа ячейки виден значок предупреждения, подсказка — «Строк ОСВ без сопоставленного счёта: 2; в суммы не вошли»
### Requirement: Результирующая таблица ВГО
Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки
(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`,
`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и
колонка «Итого» со значением `group_total` из ответа; пустая сумма — прочерк «—». Ниже таблицы закреплена строка «Итого»: до
появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением сумм всех статей по
колонке (итог, который не удалось сложить, — «—»). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от
матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка API (в том числе
422 `RESULT_ACCOUNTS_MISSING`) показывается уведомлением с текстом сервера.
#### Scenario: Колонка «Итого» из ответа
- **WHEN** сервер вернул статью «Дебиторская задолженность» с `group_total = "1296510.00"`, выбраны «тыс. руб.» и 2 знака
- **THEN** в колонке «Итого» этой статьи показано `1 296,51`
#### Scenario: Нет обязательного счёта
- **WHEN** сервер ответил 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** показано уведомление с текстом сервера, матрица шахматки при этом показывается как обычно
### Requirement: Единицы и знаки
Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков
после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина —
от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение,
округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как
число, SHALL показываться как пришло — при любых единицах.
#### Scenario: Смена единиц
- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
- **THEN** в ячейке показано `2,3`
#### Scenario: Точное округление
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"`
- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой)
#### Scenario: Маленькая отрицательная сумма
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"`
- **THEN** показано `0,00`, а не `-0,00`
### Requirement: Фильтр компаний
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и
колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
применения и сброса.
#### Scenario: Две компании из периметра
- **WHEN** выбраны две компании из пяти
- **THEN** матрица содержит две строки и две колонки, «Итого» считается по оставшимся ячейкам
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
отметки при отметке видимых узлов сохраняются.
Режим расчёта определяется выбором:
- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`);
- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix/manual` с `{debit_account_ids,
credit_account_ids}` (режим `manual`);
- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать
текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не
всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна.
Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на
последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры»
появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не
сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
#### Scenario: Обе стороны выбраны
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ
#### Scenario: Выбрана одна сторона
- **WHEN** выбран счёт только в Дт
- **THEN** запрос матрицы не отправляется, таблица пуста и показывает «Для расчёта по счетам выберите счета и в Дт, и в Кт»,
тот же текст виден у фильтров, «Экспорт» матрицы недоступен, выбор Дт остаётся в фильтре
#### Scenario: Выбрана одна сторона во время загрузки
- **WHEN** выбраны Дт и Кт, запрос `manual` ещё выполняется, и пользователь очищает Кт
- **THEN** индикатор загрузки снят, ответ прежнего запроса на экран не выводится, таблица в состоянии «выбрана одна сторона»
#### Scenario: Поиск и отметка субсчёта
- **WHEN** в дереве Дт введён поиск, видна ветка 60 с единственным субсчётом 60.02, и отмечен 60.02
- **THEN** выбранным остаётся только 60.02; после сброса поиска счёт 60 и его прочие субсчета не отмечены
#### Scenario: Снятие субсчёта отмеченного родителя
- **WHEN** отмечен счёт 60 (отмечены 60, 60.01, 60.02) и снята отметка с 60.01
- **THEN** выбранным остаётся 60.02, счёт 60 не отмечен
#### Scenario: Сброс
- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
### Requirement: Экспорт в XLSX
Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` —
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` —
`GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и
знаки в запрос экспорта не передаются.
#### Scenario: Экспорт по всем счетам
- **WHEN** счета не выбраны и нажато «Экспорт» матрицы
- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
#### Scenario: Экспорт результирующей таблицы
- **WHEN** нажато «Экспорт» результирующей таблицы
- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
### Requirement: Полноэкранный режим
Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в
полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть».
Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать
обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением.
#### Scenario: Вход и выход
- **WHEN** нажато «На весь экран», затем Esc
- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»
#### Scenario: Фильтр в полноэкранном режиме
- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании»
- **THEN** выпадающее окно видно поверх полноэкранного блока

View File

@ -0,0 +1,13 @@
## 1. Спека и ревью (RSHB/arm-core#158, AURORA-1538)
- [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение).
- [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер).
- [x] 1.3 Желательные пункты ревью раунда 3 (ревьюер, решение (м) в `design.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

@ -4,7 +4,7 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
список консолидаций ГК [34] (`widgets/ConsoleTable/`), модалку «Взять в работу» и перехват [35]
(`widgets/ConsoleTable/LockModal.tsx`), панель «Организации» (`widgets/ConsoleLegalEntities/`), модалку «Новая
консолидация» [42] (`widgets/NewConsoleModal/`, строка ЮЛ — `CompanyOptionRow.tsx`), кнопку «Изменить статус» в шапке
карточки версии [30] (`layouts/ConsolidatedOsvPage.tsx`) и модалку [33-01] (`widgets/StatusChangeModal/ChangeStatusModal.tsx`).
карточки версии [30] (`layouts/ConsolidationReportPage.tsx` — общая страница отчёта с вкладками после #158) и модалку [33-01] (`widgets/StatusChangeModal/ChangeStatusModal.tsx`).
Бэкенд — спека `consolidations-api` (список с `in_work_only`, «Взять в работу, перехват, снятие», «Статусы Среза 4»); API не
меняется. В ветку добавлена вторая библиотека иконок `lucide-react` (иконки макета: меню «⋯», «Взять в работу», «К
отчётности», боковое меню).
@ -70,16 +70,15 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
обрезки), «К3» через `quarterShortLabel`, опечатки исправлены, ФИО с инициалами без второй точки (`endSentence`).
10. ~~Пояснение под заголовком~~ — закрыто: `CONS_STATUS_DESCRIPTION` / `consStatusDescription` (тексты спеки; нет текста —
пусто, название статуса не повторяется).
11. ~~Модалка [31-00]~~ — закрыто: `UnmappedInnModal` и проверка в `OsvTabs` (`needsUnmappedWarning`). Вкладки шагов
пока неактивны, поэтому на стенде модалка проверена на временной копии с активной «Шахматкой» (решение (м)).
11. ~~Модалка [31-00]~~ — закрыто: `UnmappedInnModal` и проверка в `OsvTabs` (`needsUnmappedWarning`); после влива
main (#158) срабатывает при уходе с «ОСВ» на «Шахматку» (решение (ц)).
12. ~~«+ Создать» при одном ЮЛ~~ — закрыто: `MIN_PERIMETER = 2`, кнопка неактивна, у поля — «Выберите не меньше двух ЮЛ».
13. ~~Модалка «Изменить статус»: загрузка без обработки ошибок, `comment.trim()` на `undefined`~~ — закрыто: модалка
карточку не грузит, получает её со страницы (`card`), после 200 страница берёт карточку из ответа (D-4);
`canSubmitStatusChange` принимает `undefined`; ошибки — текст сервера, модалка открыта.
14. ~~Тестов нет~~ — закрыто: `web/src/utils/consolidations.test.ts`, `formatScaledAmount` в `web/src/utils/money.test.ts`.
15. **`lucide-react@0.263.1` в зеркале npm банка не проверен** (открыто). Пакет в `dependencies`, снимок контура его
оставляет (`docs/dev/contour-transfer-guide.md` §3.2); если версии нет в Nexus, сборка в контуре упадёт. Вопрос автору
PR / платформе.
15. ~~`lucide-react@0.263.1` в зеркале npm банка не проверен~~ — закрыто 23.10.2026: автор PR подтвердил владельцу, что
пакет в зеркале есть. Пакет в `dependencies`, снимок контура его оставляет (`docs/dev/contour-transfer-guide.md` §3.2).
16. **Предупреждение браузера о несохранённых правках** — частично: сделано для трёх модалок экранов консолидаций
(требование «Предупреждение о несохранённых правках в модалках консолидаций», решение (л)). Открыто: нужно ли оно в
остальных формах приложения (справочники, проверка документов) и при переходе по меню вне модалок (у `BrowserRouter`
@ -129,7 +128,7 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
модалке»; модалки этого PR — понятное место, маска модалки и так закрывает меню; формы других экранов и переход по
меню вне модалок требуют решения владельца (Known issues 16). Цена ошибки: если предупреждение нужно и в других формах —
подключить тот же хук; если не нужно вовсе — убрать три вызова.
- **2026-10-23 (м). Модалка [31-00] — в `OsvTabs` с параметрами `onNavigate` / `enabledTabs`**; пока шагов нет, вкладки
- **2026-10-23 (м). Модалка [31-00] — в `OsvTabs` с параметрами `onNavigate` / `enabledTabs`** (заменено решением (ц)); пока шагов нет, вкладки
неактивны и модалка не вызывается, на стенде она проверена на временной копии с активной «Шахматкой». Обоснование: PR
шахматки (#158) только передаёт переход, модалку не пишет. Цена ошибки: подключение вкладки — две строки.
- **2026-10-23 (н). Ошибки в модалках — текст сервера** (`ApiError.message`, по-русски) вместо карты фронтовых текстов;
@ -156,3 +155,10 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
из `widgets/ConsoleTable/consts.ts` (там остались цвета бейджей). Цена ошибки: низкая.
- **2026-10-23 (х). Отступ секций бокового меню — `padding: 12px 0`** (у автора закомментирован `12px`): по вертикали —
как в макете [34], по горизонтали пункты остаются на одной линии с кнопкой «Новая консолидация». Цена ошибки: CSS.
- **2026-10-23 (ц). Влив main после #158 — перенос в страницу отчёта с вкладками** (решение владельца: «вливаем main и
переносим сами»). #158 заменил `layouts/ConsolidatedOsvPage.tsx` на `layouts/ConsolidationReportPage.tsx` (шапка + вкладки
по адресу `/versions/:version_id/:tab`) и `widgets/ConsolidatedOsvContent/` (содержимое вкладки «ОСВ»). Шапка этого PR
(пояснение статуса, тег, «Изменить статус» и модалка [33-01]) перенесена в `ConsolidationReportPage`, «Единицы / Знаков»
— в `OsvContent`. Модалка [31-00] встроена в вкладки по адресу: `OsvTabs({ unmappedTotal })` спрашивает только при уходе
с «ОСВ», а `OsvContent` сообщает странице `unmapped_total` сводки через `onUnmappedChange` (при уходе с вкладки — 0);
параметры `onNavigate` / `enabledTabs` из (м) не нужны. Цена ошибки: если [31-00] нужна и на «Назад» — это решение (д).

View File

@ -63,7 +63,8 @@
- `web/src/widgets/ConsoleTable/` (`ConsoleTable.tsx`, `LockModal.tsx`, `config.tsx`, `consts.ts`, `flatten.ts`),
`web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx`, `web/src/widgets/NewConsoleModal/`
(`NewConsoleModal.tsx`, `CompanyOptionRow.tsx`), `web/src/widgets/ConsoleLegalEntities/`,
`web/src/layouts/ConsolidatedOsvPage.tsx`, `web/src/layouts/ConsolidationsList.tsx`,
`web/src/layouts/ConsolidationReportPage.tsx` (шапка, после влива #158), `web/src/widgets/ConsolidatedOsvContent/`
(«Единицы»), `web/src/layouts/ConsolidationsList.tsx`,
`web/src/services/api/consolidations.ts`, `web/src/store/slices/consolidations/thunks.ts`, `web/src/types/`,
`web/src/widgets/Sidebar/` (иконки), `web/package.json` (`lucide-react`).
- Права: действия требуют `CONSOLIDATION_EDIT` на сервере; меню по правам не скрывается.

View File

@ -19,8 +19,9 @@
`parseInWorkOnly`, `canTakeInWork`, `needsUnmappedWarning`, `endSentence`).
- [x] 2.4 Предупреждение браузера о несохранённых правках — в модалках экранов консолидаций (решение (л)); остальные
формы — вопрос владельцу (Known issues 16).
- [ ] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) или заменить иконки на
`@ant-design/icons` — вопрос автору PR.
- [x] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) — есть, подтвердил автор PR.
- [x] 2.8 Влив main после #158: шапка, «Единицы» и [31-00] перенесены в страницу отчёта с вкладками (решение (ц));
`tsc`, `biome`, `npm run lint`, `vitest`, `make web-check` — зелёные; сверка на стенде.
- [x] 2.6 Проверки в копии ветки с `lucide-react`: `tsc --noEmit`, `biome check`, `prettier --check`, `eslint` (0 ошибок,
предупреждений меньше, чем на main), `vitest run`, `npm run build` — зелёные.
- [x] 2.7 Сверка на стенде: список [34] (флажок, «Версия», «⋯»), [35] (свободная версия, перехват и гонка — с подменой

View File

@ -0,0 +1,184 @@
# intercompany-matrix-ui Specification
## Purpose
Аналитик видит шахматку ВГО версии [31-01] (матрица Дт/Кт по компаниям с фильтрами компаний и счетов, единицами и полноэкранным режимом) и результирующую таблицу ВГО [31-01-01], выгружает обе в xlsx; суммы и итоги приходят с бэка, фронт их только форматирует.
## Requirements
### Requirement: Вкладки страницы отчёта по версии
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий»,
«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
#### Scenario: Переход на шахматку
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка
#### Scenario: Прежний адрес сводной ОСВ
- **WHEN** открыт адрес `…/versions/{id}/osv`
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
#### Scenario: Неизвестная вкладка
- **WHEN** открыт адрес `…/versions/{id}/abc`
- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
### Requirement: Таблица шахматки ЮЛ × ЮЛ
Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
и расхождение `variance_amount` в скобках; расхождение выделяется красным, если `exceeds_threshold = true` или
`one_sided = true` (пара без встречной стороны; правило подсветки — как в API и xlsx, AURORA-1651). Если у пары есть
строки ОСВ без сопоставленного счёта (`skipped_osv_entry_count > 0`), рядом с первым числом SHALL показываться значок
предупреждения с подсказкой «Строк ОСВ без сопоставленного счёта: N; в суммы не вошли». Если ячейки пары в ответе нет
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы закреплена строка «Итого»: до
появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением десятичных строк — сумма
`amount_a` по колонке видимых ячеек; итог, который не удалось сложить, показывается прочерком «—», а не нулём. Пока идёт
загрузка, таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера. Панель фильтров
показывает год и квартал версии неактивными полями «Год» и «Период» (например, `2024` и `К1`): версия задаётся адресом.
#### Scenario: Ячейка с расхождением выше порога
- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом
#### Scenario: Пара без встречной стороны
- **WHEN** для пары `(A, Б)` сервер вернул `amount_b = 0`, `one_sided = true`, `exceeds_threshold = false`
- **THEN** третье число ячейки (расхождение) показано красным
#### Scenario: Пары нет в ответе
- **WHEN** в `cells[]` нет ячейки для пары компаний
- **THEN** на их пересечении показан прочерк «—»
#### Scenario: Пропущенные строки ОСВ
- **WHEN** у ячейки `skipped_osv_entry_count = 2`
- **THEN** у первого числа ячейки виден значок предупреждения, подсказка — «Строк ОСВ без сопоставленного счёта: 2; в суммы не вошли»
### Requirement: Результирующая таблица ВГО
Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки
(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`,
`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и
колонка «Итого» со значением `group_total` из ответа; пустая сумма — прочерк «—». Ниже таблицы закреплена строка «Итого»: до
появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением сумм всех статей по
колонке (итог, который не удалось сложить, — «—»). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от
матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка API (в том числе
422 `RESULT_ACCOUNTS_MISSING`) показывается уведомлением с текстом сервера.
#### Scenario: Колонка «Итого» из ответа
- **WHEN** сервер вернул статью «Дебиторская задолженность» с `group_total = "1296510.00"`, выбраны «тыс. руб.» и 2 знака
- **THEN** в колонке «Итого» этой статьи показано `1 296,51`
#### Scenario: Нет обязательного счёта
- **WHEN** сервер ответил 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** показано уведомление с текстом сервера, матрица шахматки при этом показывается как обычно
### Requirement: Единицы и знаки
Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков
после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина —
от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение,
округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как
число, SHALL показываться как пришло — при любых единицах.
#### Scenario: Смена единиц
- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
- **THEN** в ячейке показано `2,3`
#### Scenario: Точное округление
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"`
- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой)
#### Scenario: Маленькая отрицательная сумма
- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"`
- **THEN** показано `0,00`, а не `-0,00`
### Requirement: Фильтр компаний
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и
колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
применения и сброса.
#### Scenario: Две компании из периметра
- **WHEN** выбраны две компании из пяти
- **THEN** матрица содержит две строки и две колонки, «Итого» считается по оставшимся ячейкам
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
отметки при отметке видимых узлов сохраняются.
Режим расчёта определяется выбором:
- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`);
- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix/manual` с `{debit_account_ids,
credit_account_ids}` (режим `manual`);
- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать
текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не
всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна.
Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на
последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры»
появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не
сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
#### Scenario: Обе стороны выбраны
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ
#### Scenario: Выбрана одна сторона
- **WHEN** выбран счёт только в Дт
- **THEN** запрос матрицы не отправляется, таблица пуста и показывает «Для расчёта по счетам выберите счета и в Дт, и в Кт»,
тот же текст виден у фильтров, «Экспорт» матрицы недоступен, выбор Дт остаётся в фильтре
#### Scenario: Выбрана одна сторона во время загрузки
- **WHEN** выбраны Дт и Кт, запрос `manual` ещё выполняется, и пользователь очищает Кт
- **THEN** индикатор загрузки снят, ответ прежнего запроса на экран не выводится, таблица в состоянии «выбрана одна сторона»
#### Scenario: Поиск и отметка субсчёта
- **WHEN** в дереве Дт введён поиск, видна ветка 60 с единственным субсчётом 60.02, и отмечен 60.02
- **THEN** выбранным остаётся только 60.02; после сброса поиска счёт 60 и его прочие субсчета не отмечены
#### Scenario: Снятие субсчёта отмеченного родителя
- **WHEN** отмечен счёт 60 (отмечены 60, 60.01, 60.02) и снята отметка с 60.01
- **THEN** выбранным остаётся 60.02, счёт 60 не отмечен
#### Scenario: Сброс
- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
### Requirement: Экспорт в XLSX
Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` —
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` —
`GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и
знаки в запрос экспорта не передаются.
#### Scenario: Экспорт по всем счетам
- **WHEN** счета не выбраны и нажато «Экспорт» матрицы
- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
#### Scenario: Экспорт результирующей таблицы
- **WHEN** нажато «Экспорт» результирующей таблицы
- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
### Requirement: Полноэкранный режим
Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в
полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть».
Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать
обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением.
#### Scenario: Вход и выход
- **WHEN** нажато «На весь экран», затем Esc
- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»
#### Scenario: Фильтр в полноэкранном режиме
- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании»
- **THEN** выпадающее окно видно поверх полноэкранного блока

View File

@ -35,7 +35,7 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне
| `../hooks/useBeforeUnload.ts` | — | предупреждение браузера о несохранённых правках в открытой модалке |
| `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист |
| `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой |
| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidatedOsvPage.tsx` |
| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidationReportPage.tsx` |
## Границы
- Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается.

View File

@ -49,7 +49,7 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп
- `src/auth/` — `AuthGate` (профиль `/me`), `DevLogin` (`POST /dev/token`, только не в production), `token.ts`.
- `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`).
- `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и
`/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ
`/groups/:group_id/versions/:version_id/:tab` — страница отчёта с вкладками `osv`, `chess`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ
со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ, `ConsoleTable` — список консолидаций ГК и
«Взять в работу», `StatusChangeModal` — «Изменить статус» версии), `src/components/` — примитивы
(`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API.

View File

@ -10,7 +10,6 @@ import {
AuditLogging,
CatalogueGroup,
CatalogueJur,
ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
@ -18,6 +17,7 @@ import {
Main,
Mono,
} from './layouts/';
import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
@ -37,7 +37,7 @@ function App() {
<Route path='/groups'>
<Route index element={<GroupsLayout />} />
<Route path=':group_id' element={<ConsolidationPage />} />
<Route path=':group_id/versions/:version_id/osv' element={<ConsolidatedOsvPage />} />
<Route path=':group_id/versions/:version_id/:tab' element={<ConsolidationReportPage />} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />

View File

@ -127,13 +127,19 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
}
/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */
export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> {
const response = await send(path, {});
export async function apiFetchBlob(
path: string,
init: RequestInit = {}, // Добавили init
): Promise<{ blob: Blob; filename: string | null }> {
const response = await send(path, init);
const blob = await response.blob();
return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) };
return {
blob,
filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')),
};
}
function withJsonBody(method: string, body: unknown): RequestInit {
export function withJsonBody(method: string, body: unknown): RequestInit {
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
}

View File

@ -0,0 +1,3 @@
.filterTree .ant-tree-switcher-noop {
display: none !important;
}

View File

@ -0,0 +1,152 @@
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 = {
title: string;
key: string;
code?: string;
children?: OptionType[];
};
interface TreeFilterDropdownProps {
title: string;
placeholder?: string;
treeData: OptionType[];
selectedKeys: string[];
onChange: (keys: string[]) => void;
onClose: () => void;
warningBlock?: ReactNode;
}
export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
title,
placeholder = 'Введите значение',
treeData,
selectedKeys,
onChange,
onClose,
warningBlock,
}) => {
const [query, setQuery] = useState('');
const [checkedKeys, setCheckedKeys] = useState<string[]>(selectedKeys);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const filteredTreeData = useMemo(() => {
if (!query) return treeData;
const filterNode = (node: OptionType): OptionType | null => {
const titleStr = String(node.title).toLowerCase();
const isMatch = titleStr.includes(query.toLowerCase());
let filteredChildren: OptionType[] = [];
if (node.children) {
filteredChildren = node.children.map(filterNode).filter((child): child is OptionType => child !== null);
}
if (isMatch || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren.length > 0 ? filteredChildren : undefined,
};
}
return null;
};
return treeData.map(filterNode).filter((node): node is OptionType => node !== null);
}, [treeData, query]);
useEffect(() => {
if (query) {
const getAllKeys = (nodes: OptionType[]): string[] => {
let keys: string[] = [];
nodes.forEach((node) => {
keys.push(node.key);
if (node.children) keys = [...keys, ...getAllKeys(node.children)];
});
return keys;
};
setExpandedKeys(getAllKeys(filteredTreeData));
}
}, [query, filteredTreeData]);
const getAllNodeKeys = (nodes: OptionType[]): string[] => {
let keys: string[] = [];
nodes.forEach((node) => {
keys.push(node.key);
if (node.children) keys = [...keys, ...getAllNodeKeys(node.children)];
});
return keys;
};
const allFilteredKeys = getAllNodeKeys(filteredTreeData);
const visibleCheckedKeys = checkedKeys.filter((key) => allFilteredKeys.includes(key));
const handleSelectAll = () => {
setCheckedKeys(getAllNodeKeys(treeData));
};
const handleClear = () => {
setCheckedKeys([]);
};
const handleCancel = () => {
setCheckedKeys(selectedKeys);
onClose();
};
function checkHandler(keys: string[], info: { node: OptionType; checked: boolean }) {
setCheckedKeys(toggleTreeCheck(treeData, checkedKeys, keys, info.node.key, info.checked));
}
useEffect(() => {
setCheckedKeys(selectedKeys);
}, [selectedKeys]);
return (
<div className='filterTree' style={{ padding: 12, width: 380 }}>
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 16 }}>{title}</div>
{warningBlock}
<div style={{ fontSize: 12, marginBottom: 4 }}>Поиск</div>
<Input
prefix={<SearchOutlined />}
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Space style={{ marginBottom: 12, justifyContent: 'space-between', width: '100%' }}>
<Button onClick={handleSelectAll}>Выбрать все</Button>
<Button onClick={handleClear}>Очистить</Button>
</Space>
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 8 }}>
<Tree
checkable
selectable={false}
treeData={filteredTreeData}
checkedKeys={visibleCheckedKeys}
onCheck={(keys, test) => checkHandler(keys as string[], test)}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }}>
<Button onClick={handleCancel}>Отмена</Button>
<Button
type='primary'
style={{ background: '#2a8c3a' }}
onClick={() => {
onChange(checkedKeys);
onClose();
}}>
Применить
</Button>
</div>
</div>
);
};

View File

@ -1,206 +0,0 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Select, Space, Tooltip, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import { useCurrentUser } from '../auth/MeContext';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import { exportOsv } from '../services/api/osv';
import type { ConsolidatedRowOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
import { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping } from '../widgets/ConsoleTable/consts';
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
const [statusModalOpen, setModalOpen] = useState(false);
const [exporting, setExporting] = useState(false);
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId);
const currentUser = useCurrentUser();
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
useEffect(() => {
let actual = true;
getVersionCard(versionId)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}, [versionId]);
const download = async () => {
setExporting(true);
try {
const { blob, filename } = await exportOsv(versionId, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
} finally {
setExporting(false);
}
};
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : '';
// под заголовком — пояснение статуса, не его название
const description = card ? consStatusDescription(card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const statusChangeAllowed = card ? canChangeStatus(card.status, card.lock, currentUser.id) : false;
const ownLock = card ? isLockedBy(card.lock, currentUser.id) : false;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={description}
extra={
card && (
<Space size={8} style={{ display: 'flex', gap: 4 }}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag
style={{
padding: '3px 10px',
width: 158,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
}}
text={russianStatus}
{...preset}
/>
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
<Button
size='middle'
style={{ fontSize: 12 }}
type='default'
disabled={!statusChangeAllowed}
onClick={() => setModalOpen(true)}>
Изменить статус
</Button>
</Tooltip>
</Space>
)
}
/>
<OsvTabs unmappedTotal={unmapped} />
<main
style={{
flex: '1 1 auto',
minHeight: 0,
background: 'white',
borderRadius: 12,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Единицы</div>
<Select variant='underlined' value={unit} onChange={setUnit} options={UNITS} style={{ width: 140 }} />
</div>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select
variant='underlined'
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision}
onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }}
/>
</div>
</div>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт
</Button>
</div>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
background: '#fffaeb',
border: '1px solid #fedf89',
borderRadius: 12,
padding: '10px 12px',
color: '#93370d',
fontSize: 13,
}}>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
icon={<FilterOutlined />}
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
</Button>
</div>
)}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
</main>
{mapping && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
</div>
);
}
export function ConsolidatedOsvPage() {
const { version_id = '' } = useParams();
// key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново
return <OsvContent key={version_id} versionId={version_id} />;
}

View File

@ -0,0 +1,123 @@
import { Button, Space, Tooltip, message } from 'antd';
import { type ReactNode, useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import { useCurrentUser } from '../auth/MeContext';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping } from '../widgets/ConsoleTable/consts';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
function ReportContent() {
const [card, setCard] = useState<VersionCard | null>(null);
const [statusModalOpen, setModalOpen] = useState(false);
// unmapped_total сводки ОСВ — для [31-00] при уходе с вкладки «ОСВ»
const [unmappedTotal, setUnmappedTotal] = useState(0);
const currentUser = useCurrentUser();
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const navigation = useNavigate();
const { pathname } = useLocation();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} onUnmappedChange={setUnmappedTotal} />,
chess: <ChessContent card={card} />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : '';
// под заголовком — пояснение статуса, не его название
const description = card ? consStatusDescription(card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const statusChangeAllowed = card ? canChangeStatus(card.status, card.lock, currentUser.id) : false;
const ownLock = card ? isLockedBy(card.lock, currentUser.id) : false;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
useEffect(() => {
if (version_id) {
let actual = true;
getVersionCard(version_id)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}
}, [version_id]);
useEffect(() => {
const isValidTab = !tab || tab === 'osv' || tab === 'chess';
if (!isValidTab) {
const pathParts = pathname.split('/');
pathParts[pathParts.length - 1] = 'osv';
const newPath = pathParts.join('/');
navigation(newPath, { replace: true });
}
}, [tab, navigation, pathname]);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={description}
extra={
card && (
<Space size={8} style={{ display: 'flex', gap: 4 }}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag
style={{
padding: '3px 10px',
width: 158,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
}}
text={russianStatus}
{...preset}
/>
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
<Button
size='middle'
style={{ fontSize: 12 }}
type='default'
disabled={!statusChangeAllowed}
onClick={() => setModalOpen(true)}>
Изменить статус
</Button>
</Tooltip>
</Space>
)
}
/>
<OsvTabs unmappedTotal={unmappedTotal} />
</div>
{ActiveTab}
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
</div>
);
}
export function ConsolidationReportPage() {
const { version_id = '' } = useParams<{ version_id: string }>();
// key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново
return <ReportContent key={version_id} />;
}

View File

@ -9,4 +9,4 @@ export { GroupsLayout } from './Groups';
export { AuditLogging } from './AuditLogging';
export { Admin } from './Admin';
export { DocumentPage } from './DocumentPage';
export { ConsolidatedOsvPage } from './ConsolidatedOsvPage';
export { ConsolidationReportPage } from './ConsolidationReportPage';

View File

@ -0,0 +1,19 @@
import { api, apiFetchBlob, withJsonBody } from '../../api/client';
import type { ChessManualRequest, IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
const base = (versionId: string) => `/consolidations/versions/${versionId}/intercompany-matrix`;
export const fetchChessConsolidations = (versionId: string) => api.get<IntercompanyMatrixResponse>(`${base(versionId)}/all`);
export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest) =>
api.post<IntercompanyMatrixResponse>(`${base(versionId)}/manual`, data);
export const exportChessTableAll = (versionId: string) => apiFetchBlob(`${base(versionId)}/all/export`);
export const exportChessTableManual = (versionId: string, data: ChessManualRequest) =>
apiFetchBlob(`${base(versionId)}/manual/export`, withJsonBody('POST', data));
export const fetchChessConsolidationsResult = (versionId: string) =>
api.get<IntercompanyMatrixResultResponse>(`${base(versionId)}/result-table`);
export const exportChessConsolidationsResult = (versionId: string) => apiFetchBlob(`${base(versionId)}/result-table/export`);

View File

@ -401,3 +401,62 @@ export interface VersionStatusUpdate {
to_status: ConsStatus;
comment: string;
}
export interface Account {
id: string;
code: string;
parent_id: string | null;
name: string;
kind: 'A' | 'P' | 'AP' | null;
row_type: string;
is_off_balance: boolean;
is_debit: boolean;
is_credit: boolean;
}
/** Шахматка ВГО (`IntercompanyMatrixOut`): компании периметра и направленные ячейки пар. */
export interface MatrixCompany {
id: string;
name: string;
}
export interface MatrixCell {
company_a_id: string;
company_b_id: string;
amount_a: string;
amount_b: string;
variance_amount: string;
exceeds_threshold: boolean;
one_sided: boolean;
variance_percent: string | null;
/** Строки ОСВ пары без сопоставленного счёта; в суммы не вошли. */
skipped_osv_entry_count: number;
}
export interface IntercompanyMatrixResponse {
companies: MatrixCompany[];
cells: MatrixCell[];
}
export type ChessManualRequest = {
debit_account_ids: string[];
credit_account_ids: string[];
};
/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */
export interface MatrixAmount {
company_id: string;
amount: string;
}
export interface MatrixResCell {
code: string;
name: string;
group_total: string;
amounts: MatrixAmount[];
}
export interface IntercompanyMatrixResultResponse {
companies: MatrixCompany[];
articles: MatrixResCell[];
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { chessTableCellFormatValue as fmt } from './chessTableCellFormatValue';
const NBSP = ' ';
describe('chessTableCellFormatValue', () => {
it('руб.: разряды, запятая, заданное число знаков', () => {
expect(fmt('4454151.93', 'rub', 2)).toBe(`4${NBSP}454${NBSP}151,93`);
expect(fmt('100', 'rub', 2)).toBe('100,00');
expect(fmt('123.456', 'rub', 0)).toBe('123');
expect(fmt('123.4', 'rub', 3)).toBe('123,400');
});
it('тыс./млн/млрд — сдвиг запятой в строке', () => {
expect(fmt('2346459.05', 'thousand', 2)).toBe(`2${NBSP}346,46`);
expect(fmt('2346459.05', 'million', 1)).toBe('2,3');
expect(fmt('2346459.05', 'billion', 3)).toBe('0,002');
expect(fmt('12', 'thousand', 3)).toBe('0,012');
});
it('округление точное, без двоичной погрешности float', () => {
// 1005 руб. = 1,005 тыс.: через Number(...).toFixed(2) вышло бы «1,00»
expect(fmt('1005', 'thousand', 2)).toBe('1,01');
expect(fmt('0.005', 'rub', 2)).toBe('0,01');
});
it('большие суммы не теряют копейки', () => {
expect(fmt('12345678901234567.89', 'rub', 2)).toBe(`12${NBSP}345${NBSP}678${NBSP}901${NBSP}234${NBSP}567,89`);
});
it('отрицательные', () => {
expect(fmt('-1234567.1', 'rub', 2)).toBe(`-1${NBSP}234${NBSP}567,10`);
expect(fmt('-2346459.05', 'thousand', 2)).toBe(`-2${NBSP}346,46`);
});
it('отрицательная сумма, округлённая до нуля, — без минуса', () => {
expect(fmt('-4.00', 'thousand', 2)).toBe('0,00');
expect(fmt('-0.001', 'rub', 0)).toBe('0');
});
it('пустое значение — пустая строка', () => {
expect(fmt(null, 'rub', 2)).toBe('');
expect(fmt(undefined, 'thousand', 2)).toBe('');
expect(fmt('', 'million', 2)).toBe('');
});
it('нечисловое значение — как пришло, при любых единицах', () => {
expect(fmt('abc', 'rub', 2)).toBe('abc');
expect(fmt('abc', 'thousand', 2)).toBe('abc');
expect(fmt('(123.45)', 'million', 2)).toBe('(123.45)');
});
});

View File

@ -0,0 +1,34 @@
const UNIT_SHIFT: Record<string, number> = { rub: 0, thousand: 3, million: 6, billion: 9 };
const DECIMAL = /^-?\d+(\.\d+)?$/;
/** Сдвиг запятой влево на `shift` разрядов в десятичной строке, без перевода в число. */
function shiftDecimal(value: string, shift: number): string {
if (shift === 0) {
return value;
}
const negative = value.startsWith('-');
const [intPart, fracPart = ''] = (negative ? value.slice(1) : value).split('.');
const padded = intPart.padStart(shift + 1, '0');
return `${negative ? '-' : ''}${padded.slice(0, -shift)}.${padded.slice(-shift)}${fracPart}`;
}
/**
* Сумма из API (десятичная строка) в выбранных единицах и знаках для экрана шахматки
* (спека intercompany-matrix-ui, «Единицы и знаки»): точное округление без float, ноль без минуса,
* пустое — пустая строка, нечисловое — как пришло.
*/
export const chessTableCellFormatValue = (val: string | number | null | undefined, units: string, decimals: number): string => {
if (val === undefined || val === null || val === '') return '';
const str = String(val).trim();
if (!DECIMAL.test(str)) return String(val);
// строка форматируется Intl как точное десятичное (Intl.NumberFormat v3)
const formatted = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(shiftDecimal(str, UNIT_SHIFT[units] ?? 0) as Intl.StringNumericLiteral);
// «-0,00» после округления малой отрицательной суммы показываем как ноль
return /^-[0,]+$/.test(formatted) ? formatted.slice(1) : formatted;
};

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { exportFilename, filenameFromContentDisposition } from './download';
import { exportFilename, filenameFromContentDisposition, xlsxFilename } from './download';
describe('filenameFromContentDisposition', () => {
it('RFC 5987 filename*', () => {
@ -22,3 +22,14 @@ describe('exportFilename', () => {
expect(exportFilename('ГК «Русский бисквит»/Север', 2024, 'Q3')).toBe('Сводная_ОСВ_ГК_«Русский_бисквит»_Север_2024_Q3.xlsx');
});
});
describe('xlsxFilename', () => {
it('префикс, ГК, год и квартал', () => {
expect(xlsxFilename('Шахматка', { groupName: 'ГК «Русский бисквит»', year: 2024, quarter: 'Q1' })).toBe(
'Шахматка_ГК_«Русский_бисквит»_2024_Q1.xlsx',
);
});
it('без карточки версии — только префикс', () => {
expect(xlsxFilename('Результирующая_таблица_ВГО', null)).toBe('Результирующая_таблица_ВГО.xlsx');
});
});

View File

@ -15,10 +15,19 @@ export function filenameFromContentDisposition(header: string | null): string |
return plain ? plain[1].trim() : null;
}
const safeName = (name: string) => name.replace(/[\\/:*?"<>|\s]+/g, '_');
/** Запасное имя экспорта свода, если Content-Disposition недоступен. */
export function exportFilename(groupName: string, year: number, quarter: string): string {
const safe = groupName.replace(/[\\/:*?"<>|\s]+/g, '_');
return `Сводная_ОСВ_${safe}_${year}_${quarter}.xlsx`;
return `Сводная_ОСВ_${safeName(groupName)}_${year}_${quarter}.xlsx`;
}
/** Запасное имя xlsx-выгрузки версии: `<префикс>_<ГК>_<год>_<квартал>.xlsx`, без карточки версии — `<префикс>.xlsx`. */
export function xlsxFilename(prefix: string, version: { groupName: string; year: number; quarter: string } | null): string {
if (!version) {
return `${prefix}.xlsx`;
}
return `${prefix}_${safeName(version.groupName)}_${version.year}_${version.quarter}.xlsx`;
}
/** Сохранить blob файлом через временную ссылку. */

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

@ -1,5 +1,5 @@
import { App } from 'antd';
import type { ReactNode } from 'react';
import { type ReactNode, useMemo } from 'react';
type NotifyArgs = {
title?: ReactNode;
@ -11,14 +11,18 @@ type NotifyArgs = {
export function useNotificationApi() {
const { notification } = App.useApp();
return {
error: ({ title = 'Ошибка', description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.error({ title: title, description, placement, duration }),
success: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.success({ title: title, description, placement, duration }),
info: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.info({ title: title, description, placement, duration }),
warning: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.warning({ title: title, description, placement, duration }),
};
// один объект на всё время жизни App: можно класть в зависимости эффектов и useCallback
return useMemo(
() => ({
error: ({ title = 'Ошибка', description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.error({ title: title, description, placement, duration }),
success: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.success({ title: title, description, placement, duration }),
info: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.info({ title: title, description, placement, duration }),
warning: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.warning({ title: title, description, placement, duration }),
}),
[notification],
);
}

View File

@ -0,0 +1,322 @@
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, ConfigProvider } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import {
exportChessConsolidationsResult,
exportChessTableAll,
exportChessTableManual,
fetchChessConsolidations,
fetchChessConsolidationsManual,
fetchChessConsolidationsResult,
} from '../../services/api/intercompanyMatrix';
import type { IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { saveBlob, xlsxFilename } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification';
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable';
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter';
export const ChessContent = ({ card }: { card: VersionCard | null }) => {
const { version_id } = useParams<{ version_id: string }>();
const notification = useNotificationApi();
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
const [loading, setLoading] = useState(false);
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
const [resExportLoading, setResExportLoading] = useState(false);
const [companies, setCompanies] = useState<string[]>([]);
const [decimals, setDecimals] = useState(2);
const [units, setUnits] = useState('thousand');
const [debit, setDebit] = useState<string[]>([]);
const [credit, setCredit] = useState<string[]>([]);
const [chessResData, setChessResData] = useState<IntercompanyMatrixResultResponse | null>(null);
const [chessResUnits, setChessResUnits] = useState('thousand');
const [chessResDecimals, setChessResDecimals] = useState(2);
const [chessResLoading, setChessResLoading] = useState(false);
const chessTableRef = useRef<HTMLDivElement>(null);
const resTableRef = useRef<HTMLDivElement>(null);
const [isChessFullscreen, setIsChessFullscreen] = useState(false);
const [isResFullscreen, setIsResFullscreen] = useState(false);
const toggleFullscreen = async (ref: React.RefObject<HTMLDivElement>, setFullscreen: (val: boolean) => void) => {
if (!ref.current) return;
try {
if (!document.fullscreenElement) {
await ref.current.requestFullscreen();
setFullscreen(true);
} else {
await document.exitFullscreen();
setFullscreen(false);
}
} catch (e) {
notification.error({
description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим',
});
}
};
useEffect(() => {
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
setIsChessFullscreen(false);
setIsResFullscreen(false);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const isHalfFiltered = (debit.length > 0 && credit.length === 0) || (debit.length === 0 && credit.length > 0);
const loadData = useCallback(
async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => {
if (isHalfFiltered) {
notification.info({ description: 'Для сравнений по Дт и Кт нужно заполнить оба фильтра' });
return;
}
setLoading(true);
try {
let res: IntercompanyMatrixResponse;
if (dIds?.length && cIds?.length) {
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
} else {
res = await fetchChessConsolidations(vId);
}
if (isActual()) {
setData(res);
}
} catch (e) {
if (isActual() && e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
if (isActual()) {
setLoading(false);
}
}
},
[notification, isHalfFiltered],
);
const loadResData = useCallback(
async (vid: string) => {
setChessResLoading(true);
try {
const res = await fetchChessConsolidationsResult(vid);
setChessResData(res);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setChessResLoading(false);
}
},
[notification],
);
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
const download = async () => {
if (!version_id) {
return;
}
setMatrixExportLoading(true);
try {
if (debit?.length && credit?.length) {
const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit });
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
} else {
const { blob, filename } = await exportChessTableAll(version_id);
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
}
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setMatrixExportLoading(false);
}
};
const downloadResTable = async () => {
if (!version_id) {
return;
}
setResExportLoading(true);
try {
const { blob, filename } = await exportChessConsolidationsResult(version_id);
saveBlob(blob, filename ?? xlsxFilename('Результирующая_таблица_ВГО', versionInfo));
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setResExportLoading(false);
}
};
const filteredData = useMemo(() => {
if (!data) return null;
if (companies.length === 0) return data;
const filteredCompanies = data.companies.filter((c) => companies.includes(c.id));
const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id));
return { companies: filteredCompanies, cells: filteredCells };
}, [data, companies]);
useEffect(() => {
if (!version_id) return;
let isMounted = true;
const isActual = () => isMounted;
loadData(version_id, debit, credit, isActual);
return () => {
isMounted = false;
setLoading(false);
};
}, [version_id, debit, credit, loadData]);
useEffect(() => {
if (!version_id) return;
loadResData(version_id);
}, [version_id, loadResData]);
return (
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
<div
style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
overflowX: 'hidden',
gap: 24,
paddingBottom: 32,
width: '100%',
}}>
<div
ref={chessTableRef}
style={{
backgroundColor: isChessFullscreen ? '#fff' : 'transparent',
padding: isChessFullscreen ? 6 : 0,
height: isChessFullscreen ? '100vh' : 'auto',
overflow: isChessFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessTable
data={filteredData}
loading={loading}
decimals={decimals}
units={units}
isFullscreen={isChessFullscreen}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessTableFilter
companies={companies}
onCompaniesChange={setCompanies}
debit={debit}
onDebitChange={setDebit}
credit={credit}
onCreditChange={setCredit}
allCompanies={data?.companies || []}
year={card?.year}
quarter={card?.quarter}
/>
<div
style={{
width: 1,
height: 50,
backgroundColor: '#e5e7eb',
}}
/>
<ConsolidationChessFormatTableFilter
decimals={decimals}
units={units}
onDecimalsChange={setDecimals}
onUnitsChange={setUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button disabled={isHalfFiltered} loading={matrixExportLoading} onClick={() => download()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(chessTableRef, setIsChessFullscreen)}
icon={isChessFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isChessFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
<div
ref={resTableRef}
style={{
backgroundColor: isResFullscreen ? '#fff' : 'transparent',
padding: isResFullscreen ? 6 : 0,
height: isResFullscreen ? '100vh' : 'auto',
overflow: isResFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessResTable
data={chessResData}
loading={chessResLoading}
decimals={chessResDecimals}
units={chessResUnits}
isFullscreen={isResFullscreen}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessFormatTableFilter
decimals={chessResDecimals}
units={chessResUnits}
onDecimalsChange={setChessResDecimals}
onUnitsChange={setChessResUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button loading={resExportLoading} onClick={() => downloadResTable()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(resTableRef, setIsResFullscreen)}
icon={isResFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isResFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
</div>
</ConfigProvider>
);
};

View File

@ -1,66 +1,110 @@
import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { needsUnmappedWarning } from '../../utils/consolidations';
const SOON = 'Будет в следующих срезах';
const OSV = 'ОСВ';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
{ key: 'chess', label: 'Шахматка' },
{
key: 'correction',
label: 'Таблица корректировки',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'goodwill',
label: 'Гудвилл',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo',
label: 'КФО',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_common',
label: 'КФО Общий',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_confirm',
label: 'Подтверждение КФО клиента',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
];
type Props = {
/** `unmapped_total` сводки: больше нуля — при уходе с «ОСВ» показывается модалка [31-00]. */
unmappedTotal: number;
/** Переход на вкладку шага. Пока шагов нет — вкладки неактивны, переход не задан (шахматка — PR #158). */
onNavigate?: (tab: string) => void;
/** Вкладки, по которым уже можно перейти (остальные — «Будет в следующих срезах»). */
enabledTabs?: readonly string[];
/** `unmapped_total` сводки ОСВ: больше нуля — уход с «ОСВ» через модалку [31-00]. */
unmappedTotal?: number;
};
/** Ряд вкладок шагов консолидации (макет [30]): «ОСВ» — текущая; уход с неё при несопоставленных строках — через [31-00]. */
export function OsvTabs({ unmappedTotal, onNavigate, enabledTabs = [] }: Props) {
/** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */
export function OsvTabs({ unmappedTotal = 0 }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { tab } = useParams<{ tab?: string }>();
const [pending, setPending] = useState<string | null>(null);
const select = (tab: string) => {
if (tab === OSV || !onNavigate) return;
if (needsUnmappedWarning(unmappedTotal)) {
setPending(tab);
const activeKey = tab || 'osv';
const go = (newKey: string) => {
const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`);
navigate(newPath);
};
const handleTabChange = (value: string | number) => {
const newKey = String(value);
if (newKey === activeKey) return;
if (activeKey === 'osv' && needsUnmappedWarning(unmappedTotal)) {
setPending(newKey);
} else {
onNavigate(tab);
go(newKey);
}
};
const options = TAB_CONFIG.map((tabItem) => {
const isActive = tabItem.key === activeKey;
const label = (
<span
key={tabItem.key}
style={{
color: isActive ? '#2a8c3a' : undefined,
fontWeight: isActive ? 600 : 400,
}}>
{tabItem.label}
</span>
);
return {
value: tabItem.key,
disabled: tabItem.disabled,
label: tabItem.tooltip ? <Tooltip title={tabItem.tooltip}>{label}</Tooltip> : label,
};
});
return (
<>
<Segmented
value={OSV}
onChange={(v) => select(String(v))}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
options={[
{ value: OSV, label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>{OSV}</span> },
...TABS.map((t) => {
const enabled = enabledTabs.includes(t) && !!onNavigate;
return {
value: t,
disabled: !enabled,
label: enabled ? (
t
) : (
<Tooltip title={SOON}>
<span>{t}</span>
</Tooltip>
),
};
}),
]}
value={activeKey}
onChange={handleTabChange}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={options}
/>
<UnmappedInnModal
open={pending !== null}
unmappedTotal={unmappedTotal}
onStay={() => setPending(null)}
onGo={() => {
const tab = pending;
const next = pending;
setPending(null);
if (tab && onNavigate) onNavigate(tab);
if (next) go(next);
}}
/>
</>

View File

@ -0,0 +1,144 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Select, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
type Props = {
card: VersionCard | null;
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
onUnmappedChange?: (total: number) => void;
};
export function OsvContent({ card, onUnmappedChange }: Props) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(version_id ?? '');
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
const download = async () => {
if (!version_id) {
return;
}
setExporting(true);
try {
const { blob, filename } = await exportOsv(version_id, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
} finally {
setExporting(false);
}
};
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
useEffect(() => {
onUnmappedChange?.(unmapped);
}, [unmapped, onUnmappedChange]);
// вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
return (
<>
<main
style={{
flex: '1 1 auto',
minHeight: 0,
background: 'white',
borderRadius: 12,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Единицы</div>
<Select variant='underlined' value={unit} onChange={setUnit} options={UNITS} style={{ width: 140 }} />
</div>
<div>
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select
variant='underlined'
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision}
onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }}
/>
</div>
</div>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Экспорт
</Button>
</div>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
background: '#fffaeb',
border: '1px solid #fedf89',
borderRadius: 12,
padding: '10px 12px',
color: '#93370d',
fontSize: 13,
}}>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
icon={<FilterOutlined />}
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
</Button>
</div>
)}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
</main>
{mapping && version_id && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={version_id}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
</>
);
}

View File

@ -0,0 +1,51 @@
import { Select, Space } from 'antd';
import type React from 'react';
interface ConsolidationChessFormatTableFilterI {
decimals: number;
onDecimalsChange: (val: number) => void;
units: string;
onUnitsChange: (val: string) => void;
}
export const ConsolidationChessFormatTableFilter: React.FC<ConsolidationChessFormatTableFilterI> = ({
decimals,
onDecimalsChange,
units,
onUnitsChange,
}) => {
const UNIT_OPTIONS = [
{ value: 'rub', label: 'руб.' },
{ value: 'thousand', label: 'тыс. руб.' },
{ value: 'million', label: 'млн. руб.' },
{ value: 'billion', label: 'млрд руб.' },
];
const DECIMALS_OPTIONS = [
{ value: 0, label: '0' },
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
];
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
<Space size='middle' wrap>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Единицы</span>
<Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' />
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Знаков после запятой</span>
<Select
style={{ minWidth: 150 }}
value={decimals}
onChange={(v) => onDecimalsChange(v || 0)}
options={DECIMALS_OPTIONS}
variant='underlined'
/>
</Space>
</Space>
</div>
);
};

View File

@ -0,0 +1,11 @@
.chessTable .ant-table-summary {
border-end-end-radius: 8px !important;
border-end-start-radius: 8px !important;
}
.chessTable .ant-table-cell-fix {
background-color: #f8fafc !important;
}
.chessTable .resRow {
background-color: #e6f4ea !important ;
}

View File

@ -0,0 +1,134 @@
import { Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type React from 'react';
import { useMemo } from 'react';
import type { IntercompanyMatrixResultResponse, MatrixResCell } from '../../types/api';
import './ConsolidatedChessResTable.css';
import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue';
import { sumAmounts } from '../../utils/money';
const { Text } = Typography;
const DASH = <span style={{ color: '#bfbfbf' }}>—</span>;
interface ConsolidationChessResTableI {
data: IntercompanyMatrixResultResponse | null;
loading: boolean;
decimals: number;
units: string;
toolbar?: React.ReactNode;
isFullscreen?: boolean;
}
export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> = ({
data,
loading,
decimals,
units,
toolbar,
isFullscreen = false,
}) => {
const matrixData = useMemo(() => {
if (!data?.articles) return {};
const result: Record<string, Record<string, string>> = {};
data.articles.forEach((article) => {
result[article.code] = {};
article.amounts.forEach((amount) => {
result[article.code][amount.company_id] = amount.amount;
});
});
return result;
}, [data?.articles]);
const columns: ColumnsType<MatrixResCell> = useMemo(() => {
if (!data?.companies) return [];
const firstColumn: ColumnsType<MatrixResCell>[number] = {
title: 'Статья',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 250,
};
const companyColumns = data.companies.map((company) => ({
title: company.name,
dataIndex: company.id,
key: company.id,
width: 150,
align: 'right' as const,
render: (_: unknown, row: MatrixResCell) => {
const formatted = chessTableCellFormatValue(matrixData[row.code]?.[company.id], units, decimals);
return formatted || DASH;
},
}));
const totalColumn: ColumnsType<MatrixResCell>[number] = {
title: 'Итого',
dataIndex: 'group_total',
key: 'group_total',
width: 150,
align: 'right',
render: (val: string) => <Text strong>{chessTableCellFormatValue(val, units, decimals) || DASH}</Text>,
};
return [firstColumn, ...companyColumns, totalColumn];
}, [data?.companies, matrixData, decimals, units]);
const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436;
return (
<Table<MatrixResCell>
className='chessTable'
dataSource={data?.articles || []}
columns={columns}
rowKey='code'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: scrollY }}
size='medium'
bordered
title={() => toolbar}
summary={() => {
if (!data?.articles) return null;
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
{data.companies.map((company, index) => {
const amountsToSum: string[] = [];
data.articles.forEach((article) => {
const amount = matrixData[article.code]?.[company.id];
if (amount) amountsToSum.push(amount);
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
const colTotal = sumAmounts(amountsToSum) ?? '';
return (
<Table.Summary.Cell key={company.id} index={index + 1} align='right'>
<Text strong>{chessTableCellFormatValue(colTotal, units, decimals) || DASH}</Text>
</Table.Summary.Cell>
);
})}
<Table.Summary.Cell index={data.companies.length + 1} align='right'>
<Text strong>
{chessTableCellFormatValue(sumAmounts(data.articles.map((a) => a.group_total)) ?? '', units, decimals) || DASH}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
);
};

View File

@ -0,0 +1,18 @@
.chessTable .empty-cell {
background-color: #f2f4f7;
}
.chessTable .ant-table-tbody > tr:hover > td.empty-cell {
background-color: #f2f4f7 !important;
}
.chessTable .ant-table-summary {
border-end-end-radius: 8px !important;
border-end-start-radius: 8px !important;
}
.chessTable .ant-table-cell-fix {
background-color: #f8fafc !important;
}
.chessTable .resRow {
background-color: #e6f4ea !important ;
}

View File

@ -0,0 +1,158 @@
import { WarningOutlined } from '@ant-design/icons';
import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type React from 'react';
import { useMemo } from 'react';
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/api';
import './ConsolidatedChessTable.css';
import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue';
import { sumAmounts } from '../../utils/money';
const { Text } = Typography;
interface ConsolidationChessTableI {
data: IntercompanyMatrixResponse | null;
loading: boolean;
decimals: number;
units: string;
toolbar?: React.ReactNode;
isFullscreen?: boolean;
}
type TotalRow = {
id: string;
name: string;
[key: string]: string; // Здесь будут храниться суммы по ID компаний
};
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
data,
loading,
decimals,
units,
toolbar,
isFullscreen = false,
}) => {
const matrixData = useMemo(() => {
if (!data?.cells) return {};
const result: Record<string, Record<string, MatrixCell>> = {};
data.cells.forEach((cell) => {
if (!result[cell.company_a_id]) result[cell.company_a_id] = {};
result[cell.company_a_id][cell.company_b_id] = cell;
});
return result;
}, [data?.cells]);
const columns: ColumnsType<MatrixCompany> = useMemo(() => {
if (!data?.companies) return [];
const firstColumn: ColumnsType<MatrixCompany>[number] = {
title: 'ДБ / КТ',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
};
const companyColumns = data.companies.map((company) => ({
title: company.name,
dataIndex: company.id,
key: company.id,
width: 150,
align: 'right' as const,
onCell: (row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) return { className: 'empty-cell' };
return {};
},
render: (_: unknown, row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) return <span style={{ color: '#bfbfbf' }}>—</span>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 13 }}>
<span>
{cell.skipped_osv_entry_count > 0 && (
<Tooltip title={`Строк ОСВ без сопоставленного счёта: ${cell.skipped_osv_entry_count}; в суммы не вошли`}>
<WarningOutlined style={{ color: '#d46b08', marginRight: 4 }} />
</Tooltip>
)}
{chessTableCellFormatValue(cell.amount_a, units, decimals)}
</span>
<span>{chessTableCellFormatValue(cell.amount_b, units, decimals)}</span>
{/* подсветка как в API и xlsx: порог > 5 % или пара без встречной стороны (AURORA-1651) */}
<span style={{ color: cell.exceeds_threshold || cell.one_sided ? 'red' : 'inherit' }}>
({chessTableCellFormatValue(cell.variance_amount, units, decimals)})
</span>
</div>
);
},
}));
return [firstColumn, ...companyColumns];
}, [data?.companies, matrixData, decimals, units]);
const totalRowData = useMemo(() => {
if (!data?.companies) return null;
const totalRow: TotalRow = {
id: 'total-row',
name: 'Итого',
};
data.companies.forEach((colCompany) => {
const amountsToSum: string[] = [];
data.companies.forEach((rowCompany) => {
const cell = matrixData[rowCompany.id]?.[colCompany.id];
if (cell?.amount_a) {
amountsToSum.push(cell.amount_a);
}
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
totalRow[colCompany.id] = sumAmounts(amountsToSum) ?? '';
});
return totalRow as MatrixCompany;
}, [data?.companies, matrixData]);
const scrollY = isFullscreen ? 'calc(100vh - 150px)' : 436;
return (
<Table<MatrixCompany>
className='chessTable'
dataSource={data?.companies || []}
columns={columns}
rowKey='id'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: scrollY }}
size='small'
bordered
title={() => toolbar}
summary={() => {
if (!totalRowData) return null;
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
{data?.companies.map((company, index) => (
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
<div>
<Text strong>{chessTableCellFormatValue((totalRowData as TotalRow)[company.id], units, decimals) || '—'}</Text>
</div>
</Table.Summary.Cell>
))}
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
);
};

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

@ -0,0 +1,230 @@
import { Button, Select, Space } from 'antd';
import type React from 'react';
import { useEffect, useState } from 'react';
import { ApiError, api } from '../../api/client';
import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown';
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[];
onCompaniesChange: (val: string[]) => void;
debit: string[];
onDebitChange: (val: string[]) => void;
credit: string[];
onCreditChange: (val: string[]) => void;
allCompanies: MatrixCompany[];
/** Год и квартал версии — только для показа: версия задаётся адресом. */
year?: number;
quarter?: Quarter;
}
const buildTree = (items: Account[]): OptionType[] => {
const map: Record<string, OptionType> = {};
const roots: OptionType[] = [];
items.forEach((item) => {
map[item.id] = {
title: `${item.code} - ${item.name}`,
key: item.id,
code: item.code,
children: [],
};
});
items.forEach((item) => {
if (item.parent_id && map[item.parent_id]) {
map[item.parent_id].children?.push(map[item.id]);
} else {
roots.push(map[item.id]);
}
});
const cleanUp = (nodes: OptionType[]): OptionType[] => {
return nodes.map((node) => {
if (node.children && node.children.length > 0) {
return { ...node, children: cleanUp(node.children) };
}
return { title: node.title, key: node.key, code: node.code };
});
};
return cleanUp(roots);
};
export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilterI> = ({
companies,
onCompaniesChange,
debit,
onDebitChange,
credit,
onCreditChange,
allCompanies,
year,
quarter,
}) => {
const [companyOpen, setCompanyOpen] = useState(false);
const [debitOpen, setDebitOpen] = useState(false);
const [creditOpen, setCreditOpen] = useState(false);
const [optionsLoading, setOptionsLoading] = useState(false);
const [debitTreeData, setDebitTreeData] = useState<OptionType[]>([]);
const [creditTreeData, setCreditTreeData] = useState<OptionType[]>([]);
const notification = useNotificationApi();
const companyOptions = allCompanies.map((c) => ({ label: c.name, value: c.id }));
const companyTreeData = allCompanies.map((c) => ({ title: c.name, key: c.id }));
const flattenTree = (nodes: OptionType[]): { label: string; value: string }[] => {
let result: { label: string; value: string }[] = [];
nodes.forEach((node) => {
result.push({ label: String(node.code), value: String(node.key) });
if (node.children) {
result = [...result, ...flattenTree(node.children)];
}
});
return result;
};
function dropFiltersHandler() {
onCompaniesChange([]);
onDebitChange([]);
onCreditChange([]);
}
useEffect(() => {
const loadAccounts = async () => {
setOptionsLoading(true);
try {
const res = await api.get<Account[]>('/directories/accounts');
setDebitTreeData(buildTree(res.filter((acc) => acc.is_debit)));
setCreditTreeData(buildTree(res.filter((acc) => acc.is_credit)));
} catch (e) {
notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось загрузить счета' });
} finally {
setOptionsLoading(false);
}
};
loadAccounts();
}, [notification]);
const warningBlock = <span style={{ color: '#d46b08', fontSize: 13 }}>Выберите счета по Дт и Кт для фильтрации</span>;
return (
<Space size='middle' wrap>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Год</span>
<Select
disabled
style={{ minWidth: 100 }}
value={year ?? null}
options={year ? [{ value: year, label: String(year) }] : []}
variant='underlined'
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Период</span>
<Select
disabled
style={{ minWidth: 100 }}
value={quarter ?? null}
options={quarter ? [{ value: quarter, label: quarterShortLabel(quarter) }] : []}
variant='underlined'
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Компании</span>
<Select
mode='multiple'
style={{ minWidth: 200 }}
placeholder='Все компании'
options={companyOptions}
value={companies}
onChange={onCompaniesChange}
maxTagCount='responsive'
open={companyOpen}
onOpenChange={setCompanyOpen}
variant='underlined'
showSearch={false}
popupMatchSelectWidth={false}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Компании'
placeholder='Введите название компании'
treeData={companyTreeData}
selectedKeys={companies}
onChange={onCompaniesChange}
onClose={() => setCompanyOpen(false)}
/>
)}
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Дт</span>
<Select
mode='multiple'
style={{ minWidth: 150 }}
placeholder='Все счета'
value={debit}
onChange={onDebitChange}
maxTagCount='responsive'
variant='underlined'
open={debitOpen}
onOpenChange={setDebitOpen}
showSearch={false}
popupMatchSelectWidth={false}
options={flattenTree(debitTreeData)}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Дебетовые счета'
placeholder='Введите номер или название счета'
treeData={debitTreeData}
selectedKeys={debit}
onChange={onDebitChange}
onClose={() => setDebitOpen(false)}
warningBlock={warningBlock}
/>
)}
loading={optionsLoading}
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Кт</span>
<Select
mode='multiple'
style={{ minWidth: 150 }}
placeholder='Все счета'
value={credit}
onChange={onCreditChange}
maxTagCount='responsive'
variant='underlined'
open={creditOpen}
onOpenChange={setCreditOpen}
showSearch={false}
popupMatchSelectWidth={false}
options={flattenTree(creditTreeData)}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Кредитовые счета'
placeholder='Введите номер или название счета'
treeData={creditTreeData}
selectedKeys={credit}
onChange={onCreditChange}
onClose={() => setCreditOpen(false)}
warningBlock={warningBlock}
/>
)}
loading={optionsLoading}
/>
</Space>
{companies?.length || debit?.length || credit?.length ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
</Space>
);
};

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["DOM", "ES2020"],
"lib": ["DOM", "ES2020", "ES2023.Intl"],
"jsx": "react-jsx",
"target": "ES2020",
"noEmit": true,