Compare commits

...

3 Commits

Author SHA1 Message Date
Andrey Epifancev
91fdc6c0f2 docs(openspec): change intercompany-matrix-ui — экран шахматки (ревью #158)
proposal, design (Known issues — открытые дефекты ветки за автором; Decisions made without discussion), tasks и спека
intercompany-matrix-ui: вкладки по маршруту :tab, таблица ЮЛ x ЮЛ, фильтры компаний и счетов Дт/Кт, режимы all/manual,
единицы и знаки, экспорт XLSX, полноэкранный режим. Описано фактическое поведение ветки; дефекты в требования не вошли.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:06:40 +04:00
Andrey Epifancev
e19291c886 chore(web): format и biome для шахматки (ревью #158)
- npm run format (eslint --fix, biome check --write, prettier --write) по файлам шахматки;
- biome: import type и сортировка импортов; явный тип IntercompanyMatrixResponse у `let res`
  (ConsolidatedChessContent); Number.isNaN вместо isNaN (ConsolidationChessTable); key у span в OsvTabs;
  `?? null` вместо пустого фрагмента (ConsolidatioReportPage). Поведение не менялось.

Остаются предупреждения eslint в коде шахматки (any, deps хуков, console) — за автором.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:05:23 +04:00
Andrey Epifancev
175343b843 fix(web): вернуть синхронизацию фронта #139/#140, затёртую в ветке шахматки (ревью #158)
Коммит 7891430 откатил синхронизацию фронта (5bec434, #139/#140) и форматирование Prettier (#137).
Из origin/main восстановлены файлы, не относящиеся к шахматке:
- index.tsx (тема antd: Layout.fontSize, Form.labelFontSize/labelColor);
- layouts/DocumentPage.tsx (переключатель документа, комментарий к документу);
- services/api: consolidations.ts (getChecklist), documents.ts (postComment), files.ts (removeFile),
  directories/catalogue_jur.ts; types/consolidations/version_members.ts (ChecklistBackendShape);
- widgets: ConsoleLegalEntities (ChecklistModal, checklist_utils), FilesDrawer (DeleteModal, ReplaceDocModal,
  documentRows), DocumentReview (CommentModal, StatementReview, reviewCounters, reviewView),
  CatalogueJurForm, CatalogueJurTable, CompanyGroupsList;
- hooks/useVirtualList.tsx и components/TableTag/presets.tsx (форматирование Prettier).

В файлах шахматки оставлены её изменения: App.tsx, layouts/index.ts, api/client.ts (init в apiFetchBlob), OsvTabs.tsx,
ConsolidatedOsvContent.tsx. Из main возвращён key={version_id}: страница отчёта разделена на обёртку с key и
содержимое, чтобы при смене версии состояние (карточка, фильтры, страница) создавалось заново, как в ConsolidatedOsvPage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:04:19 +04:00
45 changed files with 1229 additions and 204 deletions

View File

@ -0,0 +1,67 @@
## Context
Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`) как вторая вкладка страницы отчёта по
версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит `layouts/ConsolidatioReportPage.tsx`,
содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений поведения) и
`widgets/ConsolidatedChessContent`. Бэкенд шахматки — change `2026-10-16-intercompany-matrix`; расчёт — чистая функция
версии, ничего не хранится.
## Decisions (как устроено в ветке)
- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет
последний сегмент адреса. Страница выбирает содержимое по таблице `tab → компонент`; неизвестный `tab` даёт пустое содержимое.
- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка,
фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`).
- **D-3. Режим `all` / `manual` выводится из выбора счетов:** `manual` только при непустых Дт и Кт (иначе бэкенд ответит 422),
в остальных случаях `all`. Один и тот же признак выбирает маршрут экспорта.
- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии.
- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`.
- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее.
- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока шахматки, состояние следует за `fullscreenchange`.
## Known issues
Открытые проблемы ветки, которые исправляет автор PR; спека их НЕ закрепляет как требования.
1. Глобальный CSS (`App.css`, `ConsolidatedChessTable.css`, `TreeFilterDropdown.css`) с `!important` на классах antd — влияет на
все таблицы и деревья приложения; стили должны быть локальными (CSS-модули или `className` экрана).
2. В полноэкранном режиме выпадающие списки фильтров (Компании, Дт, Кт, Единицы, Знаки) не видны: popup рендерится в `body`,
вне полноэкранного элемента; нужен `getPopupContainer` на контейнер шахматки.
3. Суммы форматируются через `number` / `parseFloat` / `toFixed` (float в денежном домене), без разделителей тысяч и без «руб.»;
`utils/money.ts` работает со строками — использовать его. Деление на единицы делать над десятичной строкой.
4. Строка «Итого» суммирует расхождения (`variance_amount`), а в макете 31-01 в итогах — верхние числа (суммы стороны A); итоги нужны
по каждой из трёх величин либо по макету. Знак `(…)` разбирается эвристикой `parseAmount`, хотя API отдаёт десятичные строки.
5. Подписи дочерних счетов в выборе Дт/Кт — UUID: `Select` получает `options` только корневых узлов, выбранные дочерние ключи
выводятся как идентификаторы; нужны подписи «код - название» для любых узлов.
6. Выбор счетов только с одной стороны молча даёт режим `all`: пользователь видит выбранный счёт, но данные по всем счетам;
нужны подсказка/блокировка либо явное поведение.
7. Гонка ответов: `loadData` не отменяет и не отбрасывает устаревшие ответы (последний пришедший побеждает, не последний
запрошенный); нужен `AbortController` или счётчик запросов. Эффект зависит от `loadData`/`notification` не по правилам хуков.
8. Нет результирующей таблицы ВГО (`GET …/intercompany-matrix/result-table`), пресетов выбора счетов и выбора года/квартала
(заглушки `disabled` в панели фильтров), экран макета 31-01 реализован не полностью.
9. Прочее по ревью: `any` в таблице и дереве, `console.log` в обработчике полноэкранного режима, ошибки не `ApiError` молча
глотаются (`loadData`, `download`), экспорт не передаёт единицы/знаки/компании, пустой `Select` для года/квартала,
опечатки в именах (`ConsolidatioReportPage`, `consodilation`, `expoerLoading`).
После исправления дефектов требования соответствующих разделов спеки уточняются отдельным change.
## Decisions made without discussion
- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить
автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change.
- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Обоснование: спека — единственный
источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена
ошибки: отдельные требования (например, режим `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`).

View File

@ -0,0 +1,45 @@
## Why
В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы
отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main
(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку пишет ревьюер в ветке автора по решению владельца
(21.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 /directories/accounts`.
- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidatioReportPage`) с общей шапкой и
вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать.
- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение), строка «Итого».
- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`.
- Экспорт в XLSX (`all` / `manual`), полноэкранный режим.
- Новая спека `intercompany-matrix-ui` (ADDED Requirements).
## Non-goals
- Исправление дефектов экрана (перечень — `design.md`, «Known issues»): они остаются автору PR.
- Результирующая таблица ВГО (`GET …/intercompany-matrix/result-table`) и пресеты счетов — во фронте не реализованы.
- Выбор года и квартала на экране: селекторы заглушены (`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/{ConsolidatioReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`,
`web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`,
`web/src/widgets/ConsolidationChessTable/`, `web/src/components/TreeFilterDropdown/`,
`web/src/services/consodilation/chess.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`).
- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается.

View File

@ -0,0 +1,97 @@
## ADDED Requirements
### Requirement: Вкладки страницы отчёта по версии
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Вкладки «Таблица корректировки»,
«Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах».
При смене `version_id` всё состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
#### Scenario: Переход на шахматку
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка
#### Scenario: Прежний адрес сводной ОСВ
- **WHEN** открыт адрес `…/versions/{id}/osv`
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
### Requirement: Таблица шахматки ЮЛ × ЮЛ
Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
и расхождение `variance_amount`; расхождение выделяется красным, если `exceeds_threshold = true`. Если ячейки пары в ответе нет
(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы SHALL закрепляться строка «Итого»
с одним числом по каждой колонке. Пока идёт загрузка, таблица показывает индикатор загрузки; ошибка API показывается
уведомлением с текстом сервера.
#### Scenario: Ячейка с расхождением выше порога
- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом
#### Scenario: Пары нет в ответе
- **WHEN** в `cells[]` нет ячейки для пары компаний
- **THEN** на их пересечении показан прочерк «—»
### Requirement: Единицы и знаки
Экран SHALL позволять выбрать единицы суммы — «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков после
запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
значение делится на 1000 / 1 000 000 / 1 000 000 000 и показывается с выбранным числом знаков, десятичный разделитель — запятая.
Значение, которое не удаётся прочитать как число, SHALL показываться как пришло.
#### Scenario: Смена единиц
- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
- **THEN** в ячейке показано `2,3`
### Requirement: Фильтр компаний
Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать таблицу на клиенте: остаются строки и
колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
применения и сброса.
#### Scenario: Две компании из периметра
- **WHEN** выбраны две компании из пяти
- **THEN** таблица содержит две строки и две колонки, и «Итого» считается по оставшимся ячейкам
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название». Выбор счетов Дт и Кт —
множественный, пустой выбор — «Все счета».
Режим расчёта определяется выбором: если выбраны счета и в Дт, и в Кт, SHALL выполняться
`POST …/intercompany-matrix/manual` с `{debit_account_ids, credit_account_ids}` (режим `manual`); в остальных случаях
(ничего не выбрано либо выбрана только одна сторона) SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`). Каждое
изменение выбора счетов перезапрашивает матрицу. Кнопка «Сбросить фильтры» появляется, когда выбрано что-либо в компаниях, Дт
или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
#### Scenario: Обе стороны выбраны
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ
#### Scenario: Выбрана одна сторона
- **WHEN** выбран счёт только в Дт
- **THEN** отправляется `GET …/intercompany-matrix/all` (режим `all`); выбор Дт остаётся в фильтре
#### Scenario: Сброс
- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
### Requirement: Экспорт шахматки в XLSX
Кнопка «Экспорт» SHALL скачивать XLSX в том же режиме, что и таблица: при выбранных счетах и в Дт, и в Кт —
`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, иначе
`GET …/intercompany-matrix/all/export`. Файл сохраняется под именем из `Content-Disposition`, при его отсутствии — «Шахматка».
Пока идёт выгрузка, кнопка в состоянии загрузки; ошибка API показывается уведомлением. Фильтр компаний, единицы и знаки
в запрос экспорта не передаются.
#### Scenario: Экспорт по всем счетам
- **WHEN** счета не выбраны и нажато «Экспорт»
- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
### Requirement: Полноэкранный режим
Кнопка «На весь экран» SHALL переводить блок шахматки (панель фильтров и таблицу) в полноэкранный режим средствами Fullscreen API
с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть». Выход из полноэкранного режима клавишей Esc
SHALL возвращать обычный вид и подпись кнопки.
#### Scenario: Вход и выход
- **WHEN** нажато «На весь экран», затем Esc
- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»

View File

@ -0,0 +1,6 @@
## 1. Спека и ревью (RSHB/arm-core#158)
- [x] 1.1 Спека `intercompany-matrix-ui` по фактическому поведению ветки `feat/chessTable`.
- [ ] 1.2 Автор исправляет пункты «Known issues» из `design.md` и обновляет требования спеки отдельным коммитом.
- [ ] 1.3 Перед PR: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
- [ ] 1.4 Сверка экрана с макетом 31-01 в браузере (`docs/mockup/INDEX.md`).

View File

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

@ -17,9 +17,9 @@ import {
Main,
Mono,
} from './layouts/';
import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage';
// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами.
function App() {
@ -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/:tab' element={<ConsolidatioReportPage/>} />
<Route path=':group_id/versions/:version_id/:tab' element={<ConsolidatioReportPage />} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />

View File

@ -129,13 +129,13 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */
export async function apiFetchBlob(
path: string,
init: RequestInit = {} // Добавили init
init: RequestInit = {}, // Добавили init
): Promise<{ blob: Blob; filename: string | null }> {
const response = await send(path, init);
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')),
};
}

View File

@ -40,8 +40,8 @@ export const TAG_PRESETS = {
color: 'rgb(146, 64, 14)',
},
gray: {
color: 'rgb(71, 84, 103)',
bgColor: 'rgb(248, 250, 252)',
borderColor: 'rgba(15, 23, 42, 0.08)',
color: 'rgb(71, 84, 103)',
bgColor: 'rgb(248, 250, 252)',
borderColor: 'rgba(15, 23, 42, 0.08)',
},
} as const;

View File

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

View File

@ -1,12 +1,12 @@
import { useState, useMemo, useEffect } from 'react';
import { Input, Button, Tree, Space } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Space, Tree } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import './TreeFilterDropdown.css';
export type OptionType = {
title: string;
key: string;
code?:string;
code?: string;
children?: OptionType[];
};
@ -31,7 +31,7 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
const [checkedKeys, setCheckedKeys] = useState<string[]>(selectedKeys);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
useEffect(() => {
setCheckedKeys(selectedKeys);
}, [selectedKeys]);
@ -94,7 +94,7 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
};
return (
<div style={{ padding: 12, width: 380}}>
<div style={{ padding: 12, width: 380 }}>
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 16 }}>{title}</div>
<div style={{ fontSize: 12, marginBottom: 4 }}>Поиск</div>
@ -107,12 +107,8 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
/>
<Space style={{ marginBottom: 12, justifyContent: 'space-between', width: '100%' }}>
<Button onClick={handleSelectAll}>
Выбрать все
</Button>
<Button onClick={handleClear}>
Очистить
</Button>
<Button onClick={handleSelectAll}>Выбрать все</Button>
<Button onClick={handleClear}>Очистить</Button>
</Space>
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 8 }}>
@ -131,9 +127,7 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }}>
<Button onClick={handleCancel}>
Отмена
</Button>
<Button onClick={handleCancel}>Отмена</Button>
<Button
type='primary'
style={{ background: '#2a8c3a' }}
@ -146,4 +140,4 @@ export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
</div>
</div>
);
};
};

View File

@ -1,51 +1,46 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from 'react';
type Options = {
/** высота одного элемента в px (фиксированная) */
itemHeight: number;
/** высота контейнера в px (видимая область) */
containerHeight: number;
/** сколько элементов рендерить дополнительно сверху/снизу */
overscan?: number;
/** высота одного элемента в px (фиксированная) */
itemHeight: number;
/** высота контейнера в px (видимая область) */
containerHeight: number;
/** сколько элементов рендерить дополнительно сверху/снизу */
overscan?: number;
};
export function useVirtualList<T>(items: T[], options: Options) {
const { itemHeight, containerHeight, overscan = 5 } = options;
const { itemHeight, containerHeight, overscan = 5 } = options;
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);
const totalHeight = items.length * itemHeight;
const totalHeight = items.length * itemHeight;
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan,
);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan,
);
const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);
const visibleItems = useMemo(
() => items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
})),
[items, startIndex, endIndex],
);
const visibleItems = useMemo(
() =>
items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
})),
[items, startIndex, endIndex],
);
const offsetY = startIndex * itemHeight;
const offsetY = startIndex * itemHeight;
const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
return {
ref,
onScroll,
visibleItems,
totalHeight,
offsetY,
};
}
return {
ref,
onScroll,
visibleItems,
totalHeight,
offsetY,
};
}

View File

@ -29,7 +29,9 @@ createRoot(rootElement).render(
components: {
Layout: {
siderBg: '#fff',
fontSize: 13,
},
Button: {
defaultBorderColor: '#c7cdd5',
defaultHoverBg: '#f8fafc',
@ -46,6 +48,10 @@ createRoot(rootElement).render(
headerColor: '#667085',
headerBg: 'rgb(248, 250, 252)',
},
Form: {
labelFontSize: 12,
labelColor: '#667085',
},
},
}}>
<App />

View File

@ -1,5 +1,5 @@
import { Space, message } from 'antd';
import { ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
@ -10,21 +10,21 @@ import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
export function ConsolidatioReportPage() {
function ReportContent() {
const [card, setCard] = useState<VersionCard | null>(null);
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} />,
chess: <ChessContent />,
chess: <ChessContent />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? <></>;
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
@ -67,3 +67,9 @@ export function ConsolidatioReportPage() {
</div>
);
}
export function ConsolidatioReportPage() {
const { version_id = '' } = useParams<{ version_id: string }>();
// key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново
return <ReportContent key={version_id} />;
}

View File

@ -1,17 +1,24 @@
import { Button, Empty, Result, Spin, Tag } from 'antd';
import { CommentOutlined } from '@ant-design/icons';
import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { fetchLegalEntity } from '../services/api/directories/company_groups';
import { getDocument } from '../services/api/documents';
import { getFile } from '../services/api/files';
import { getFile, listLegalEntityFiles } from '../services/api/files';
import type { DocumentOut } from '../types/api';
import { documentTypeLabel, periodLabel } from '../utils/labels';
import CommentModal from '../widgets/DocumentReview/CommentModal';
import { OsvDocument } from '../widgets/DocumentReview/OsvDocument';
import { StatementReview } from '../widgets/DocumentReview/StatementReview';
import { documentPageTitle, documentSubtitle, statusBadge } from '../widgets/DocumentReview/reviewView';
import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView';
import { documentViewKind } from '../widgets/DocumentReview/viewKind';
import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows';
type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow };
export function DocumentPage() {
const { document_id } = useParams();
@ -20,6 +27,8 @@ export function DocumentPage() {
const [entityName, setEntityName] = useState('');
const [subtitle, setSubtitle] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [documentOptions, setDocumentOptions] = useState<DocumentOption[]>([]);
const [commentOpen, setCommentOpen] = useState(false);
const load = useCallback(async () => {
if (!document_id) return;
@ -31,7 +40,22 @@ export function DocumentPage() {
.then((le) => setEntityName(le.name))
.catch(() => setEntityName(''));
getFile(d.file_id)
.then((f) => setSubtitle(documentSubtitle(f)))
.then((f) => {
setSubtitle(documentSubtitle(f));
// переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ)
listLegalEntityFiles(d.legal_entity_id, f.consolidation_id)
.then((files) =>
setDocumentOptions(
buildDocumentRows(files).map((row) => ({
value: row.documentId ?? row.fileId,
label: row.name,
disabled: !row.linkable,
row,
})),
),
)
.catch(() => setDocumentOptions([]));
})
.catch(() => setSubtitle(null));
} catch (e: unknown) {
setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ');
@ -63,11 +87,38 @@ export function DocumentPage() {
`${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}`
}
extra={
<Tag variant='filled' color={badge.color} style={{ fontWeight: 600, fontSize: 14, padding: '4px 12px' }}>
{badge.label}
</Tag>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', width: 'min(520px, 48vw)' }}>
<Form style={{ flex: '1 1 auto', minWidth: 0 }} layout='vertical'>
<Form.Item style={{ marginBottom: 0 }} label='Документ'>
<Select<string, DocumentOption>
style={{ width: '100%' }}
value={doc.id}
options={documentOptions}
onChange={(value) => navigate(`/documents/${value}`)}
optionRender={(option) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
<Tooltip title={option.data.label}>
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div>{option.data.label}</div>
<div style={{ fontSize: 11, color: '#667085' }}>{option.data.row.subtitle}</div>
</div>
</Tooltip>
<TableTag {...optionStatusBadge(option.data.row.status)} />
</div>
)}
/>
</Form.Item>
</Form>
<TableTag text={badge.label} {...TAG_PRESETS[badge.color]} />
</div>
}
/>
<div style={{ alignSelf: 'flex-end' }}>
<Button icon={<CommentOutlined />} onClick={() => setCommentOpen(true)}>
Добавить комментарий
</Button>
</div>
<main
style={{
flex: '1 1 auto',
@ -82,6 +133,7 @@ export function DocumentPage() {
{!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main>
<CommentModal open={commentOpen} documentId={doc.id} onClose={() => setCommentOpen(false)} />
</div>
);
}

View File

@ -2,11 +2,12 @@ import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidationCreate } from '../../types/api';
import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { VersionCard } from '../../types/consolidations/version_members';
import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members';
export const getConsolidations = (groupId: string) => api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`);
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string) => api.post<LockOut>(`/consolidations/versions/${versionId}/lock`);

View File

@ -10,7 +10,7 @@ import type {
import { LE_PAGE } from './company_groups';
export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) {
const res = await api.get<Page<ICatalogueJur>>('/directories/legal-entities', {limit:LE_PAGE, offset:0, ...params});
const res = await api.get<Page<ICatalogueJur>>('/directories/legal-entities', { limit: LE_PAGE, offset: 0, ...params });
return res;
}

View File

@ -21,3 +21,6 @@ export async function getAllEntries(id: string): Promise<EntryOut[]> {
}
}
}
/** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */
export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text });

View File

@ -1,4 +1,4 @@
import { ApiError, apiFetch, apiFetchBlob } from '../../api/client';
import { ApiError, api, apiFetch, apiFetchBlob } from '../../api/client';
import type { FileOut, Page, UploadResult } from '../../types/api';
export function isUploadResult(body: unknown): body is UploadResult {
@ -56,3 +56,7 @@ export function fetchFileContent(fileId: string): Promise<{ blob: Blob; filename
export function getFile(id: string): Promise<FileOut> {
return apiFetch<FileOut>(`/files/${id}`);
}
export function removeFile(fileId: string) {
return api.del(`/files/${fileId}`);
}

View File

@ -1,5 +1,5 @@
import { api, apiFetchBlob } from '../../api/client';
import { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess';
import type { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess';
export const fetchChessConsolidations = (id: string) =>
api.get<IntercompanyMatrixResponse>(`/consolidations/versions/${id}/intercompany-matrix/all`);

View File

@ -1,23 +1,23 @@
export interface MatrixCompany {
id: string;
name: string;
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
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[];
companies: MatrixCompany[];
cells: MatrixCell[];
}
export type ChessManualRequest = {

View File

@ -35,6 +35,24 @@ type ChecklistItem = {
account?: string | null;
};
type ChecklistRow = {
cells: ChecklistItem[];
legal_entity_id: string;
legal_entity_name: string;
legal_entity_inn: string;
};
type ChecklistRule = {
document_type: DocumentType;
account: string | null;
is_mandatory: boolean;
};
export type ChecklistBackendShape = {
rows: ChecklistRow[];
rules: ChecklistRule[];
};
type VersionMember = {
id: string;
name: string;

View File

@ -54,7 +54,7 @@ export const CatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, jurId
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
}
} finally {
setInitialDataLoading(false);
}

View File

@ -243,7 +243,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur
<ColumnSettings defs={catalogueJurConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
</Space>
</Space>
{(relatedGroupFilter?.id && !outside_group) && (
{relatedGroupFilter?.id && !outside_group && (
<div
style={{
display: 'flex',

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Input, List, Spin, Empty, Typography, Tag } from 'antd';
import { FolderOpenOutlined, SearchOutlined } from '@ant-design/icons';
import { useAppDispatch, useAppSelector } from '../../store/store';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { setActiveCompanyGroup } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
import { Empty, Input, List, Spin, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useVirtualList } from '../../hooks/useVirtualList';
import { setActiveCompanyGroup } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
const { Text } = Typography;

View File

@ -0,0 +1,49 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Modal, Table, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { getChecklist } from '../../services/api/consolidations';
import type { ChecklistBackendShape, IVersionCard } from '../../types/consolidations/version_members';
import { buildChecklistColumns, buildChecklistRows, getMarksItems } from './checklist_utils';
type ChecklistProps = {
open: boolean;
onDownload: () => void;
consolidationData: IVersionCard;
onClose: () => void;
};
export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) {
const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null);
const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]);
const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]);
useEffect(() => {
if (!consolidationData.id || !open) return;
getChecklist(consolidationData.id)
.then(setChecklistData)
.catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист'));
}, [open, consolidationData.id]);
return (
<Modal
width='100%'
open={open}
cancelText='Отмена'
onCancel={onClose}
title='Чеклист'
destroyOnHidden
onOk={() => {
onDownload();
onClose();
}}
okText='Скачать чек-лист'
okButtonProps={{ icon: <DownloadOutlined /> }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{getMarksItems()}
<Table rowKey='key' columns={columns} dataSource={rows} pagination={false} />
</div>
</Modal>
);
}

View File

@ -10,6 +10,7 @@ import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/c
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members';
import { saveBlob } from '../../utils/download';
import ChecklistModal from './ChecklistModal';
import { membersConfig } from './config';
import { normalizeData } from './normalize';
import { checklistFilename, responsibleName } from './panelInfo';
@ -18,6 +19,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false);
const consolidation_id = useAppSelector((s) => s.consolidations.current_version_id);
const [checklistOpen, setChecklistOpen] = useState(false);
const [consolidationData, setConsolidationData] = useState<IVersionCard | null>(null);
@ -47,6 +49,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист');
}
};
const viewChecklist = () => {
if (!consolidationData) return;
setChecklistOpen(true);
};
const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig);
const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<MemberRow>();
// single filtered array — title count and table share it
@ -60,61 +66,78 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Space
style={{
width: '100%',
justifyContent: 'space-between',
padding: '12px 16px',
flex: 0,
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, margin: 0 }}>
<h2
style={{
display: 'block',
boxSizing: 'border-box',
fontSize: 17,
fontWeight: 600,
margin: 0,
}}>
Организации
</h2>
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)' }}>{rawRows.length} шт.</span>
{consolidationData && (
// «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)', marginLeft: 8 }}>
Ответственный: {responsibleName(consolidationData)}&nbsp;&nbsp; Утверждён в РФ: —&nbsp;&nbsp; Верифицирован в ГО: —
</span>
)}
</div>
<div>
{hasActiveFilters && (
<Button
type='default'
onClick={() => {
resetAll();
<>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Space
style={{
width: '100%',
justifyContent: 'space-between',
padding: '12px 16px',
flex: 0,
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, margin: 0 }}>
<h2
style={{
display: 'block',
boxSizing: 'border-box',
fontSize: 17,
fontWeight: 600,
margin: 0,
}}>
Сбросить фильтры
Организации
</h2>
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)' }}>{rawRows.length} шт.</span>
{consolidationData && (
// «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7
<span
style={{
fontSize: 13,
color: 'rgb(102, 112, 133)',
marginLeft: 8,
}}>
Ответственный: {responsibleName(consolidationData)}&nbsp;&nbsp; Утверждён в РФ: —&nbsp;&nbsp; Верифицирован в ГО: —
</span>
)}
</div>
<div>
{hasActiveFilters && (
<Button
type='default'
onClick={() => {
resetAll();
}}>
Сбросить фильтры
</Button>
)}
<ColumnSettings defs={membersConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
<Button icon={<DownloadOutlined />} disabled={!consolidationData} onClick={viewChecklist} style={{ marginLeft: 8 }}>
Скачать чек-лист
</Button>
)}
<ColumnSettings defs={membersConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
<Button icon={<DownloadOutlined />} disabled={!consolidationData} onClick={downloadChecklist} style={{ marginLeft: 8 }}>
Скачать чек-лист
</Button>
</div>
</Space>
<DataTable
config={membersConfig}
rows={filtered}
allRows={rawRows}
loading={loading}
visibleKeys={visibleKeys}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
sortState={sort}
onSortChange={setSort}
onRow={onRow}
/>
</div>
</div>
</Space>
<DataTable
config={membersConfig}
rows={filtered}
allRows={rawRows}
loading={loading}
visibleKeys={visibleKeys}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
sortState={sort}
onSortChange={setSort}
onRow={onRow}
/>
</div>
{consolidationData && (
<ChecklistModal
open={checklistOpen}
onClose={() => {
setChecklistOpen(false);
}}
onDownload={downloadChecklist}
consolidationData={consolidationData}
/>
)}
</>
);
}

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { ChecklistBackendShape } from '../../types/consolidations/version_members';
import { buildChecklistRows } from './checklist_utils';
const checklist: ChecklistBackendShape = {
rules: [],
rows: [
{
legal_entity_id: 'le1',
legal_entity_name: 'РУССКИЙ БИСКВИТ, АО',
legal_entity_inn: '3528052718',
cells: [
{ document_type: 'BALANCE', account: null, status: 'NEEDS_CHECK', missing_quarters: [], document_ids: ['d1'] },
{ document_type: 'INCOME', account: null, status: 'CONFIRMED', missing_quarters: [], document_ids: ['d2'] },
{ document_type: 'OSV', account: '60', status: 'ERROR', missing_quarters: [], document_ids: ['d3'] },
],
},
],
};
describe('buildChecklistRows', () => {
it('ОФР — ячейка INCOME, ББ — BALANCE, ОСВ — по счёту; ключ строки — ЮЛ', () => {
expect(buildChecklistRows(checklist)).toEqual([
{
key: 'le1',
name: 'РУССКИЙ БИСКВИТ, АО',
inn: '3528052718',
balance: 'NEEDS_CHECK',
ofr: 'CONFIRMED',
osv: { '60': 'ERROR' },
},
]);
});
it('нет ячеек ББ/ОФР — «Отсутствует»', () => {
const rows = buildChecklistRows({ rules: [], rows: [{ ...checklist.rows[0], cells: [] }] });
expect(rows[0]).toMatchObject({ balance: 'MISSING', ofr: 'MISSING', osv: {} });
});
});

View File

@ -0,0 +1,127 @@
import {
CheckCircleOutlined,
ClockCircleOutlined,
EllipsisOutlined,
ExclamationCircleOutlined,
EyeOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import { Avatar, type TableColumnsType } from 'antd';
import type { ReactNode } from 'react';
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members';
import { statusMapping } from './consts';
/** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */
export type ChecklistTableRow = {
key: string;
name: string;
inn: string;
balance: FileStatus;
ofr: FileStatus;
osv: Record<string, FileStatus>;
};
const presetMapping: Record<FileStatus, Preset> = {
MISSING: TAG_PRESETS.default,
CONFIRMED: TAG_PRESETS.green,
NEEDS_CHECK: TAG_PRESETS.orange,
RECOGNIZED: TAG_PRESETS.gold,
ERROR: TAG_PRESETS.red,
PROCESSING: TAG_PRESETS.geekblue,
};
const iconMapping: Record<FileStatus, ReactNode> = {
MISSING: <MinusCircleOutlined />,
NEEDS_CHECK: <EyeOutlined />,
ERROR: <ExclamationCircleOutlined />,
CONFIRMED: <CheckCircleOutlined />,
RECOGNIZED: <EllipsisOutlined />,
PROCESSING: <ClockCircleOutlined />,
};
export const mapStatus = (status: FileStatus) => {
const preset = presetMapping[status];
return (
<Avatar style={{ color: preset.color, backgroundColor: preset.bgColor, borderColor: preset.borderColor }} icon={iconMapping[status]} />
);
};
export function getMarksItems() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
backgroundColor: '#f8fafc',
padding: '5px 10px',
borderRadius: 10,
fontSize: 11,
}}>
<b>Условные обозначения</b>
{(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => (
<div key={status}>
{mapStatus(status)} <span style={{ color: '#667085' }}>{label}</span>
</div>
))}
</div>
);
}
/** Строки таблицы из ответа `GET …/checklist`: ОФР — ячейка `INCOME`; нет ячейки ББ/ОФР — «Отсутствует». */
export function buildChecklistRows(checklistData: ChecklistBackendShape): ChecklistTableRow[] {
return checklistData.rows.map((row) => {
const result: ChecklistTableRow = {
key: row.legal_entity_id,
name: row.legal_entity_name,
inn: row.legal_entity_inn,
balance: 'MISSING',
ofr: 'MISSING',
osv: {},
};
for (const cell of row.cells) {
if (cell.document_type === 'OSV' && cell.account) {
result.osv[cell.account] = cell.status;
} else if (cell.document_type === 'BALANCE') {
result.balance = cell.status;
} else if (cell.document_type === 'INCOME') {
result.ofr = cell.status;
}
}
return result;
});
}
export function buildChecklistColumns(checklistData: ChecklistBackendShape): TableColumnsType<ChecklistTableRow> {
const osvCodes: string[] = [];
for (const row of checklistData.rows) {
for (const cell of row.cells) {
if (cell.document_type === 'OSV' && cell.account && !osvCodes.includes(cell.account)) {
osvCodes.push(cell.account);
}
}
}
return [
{
key: 'name',
title: 'ОРГАНИЗАЦИЯ',
render: (_, r) => (
<h5>
{r.name}: {r.inn}
</h5>
),
},
{ key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v: FileStatus) => mapStatus(v) },
{ key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) },
{
key: 'osv',
title: 'ОСВ',
children: osvCodes.map((code) => ({
key: `osv_${code}`,
title: code,
render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'),
})),
},
];
}

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import {
exportChessTableAll,
@ -10,13 +10,10 @@ import {
fetchChessConsolidationsManual,
} from '../../services/consodilation/chess';
import type { IntercompanyMatrixResponse } from '../../types/chess/chess';
import { saveBlob } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification';
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
import { ConsolidationChessTableFilter } from '../ConsolidationChessTable/ConsolidationChessTableFilter';
import { saveBlob } from '../../utils/download';
export const ChessContent = () => {
const { version_id } = useParams<{ version_id: string }>();
@ -50,7 +47,7 @@ export const ChessContent = () => {
const loadData = async (vId: string, dIds: string[], cIds: string[]) => {
setLoading(true);
try {
let res;
let res: IntercompanyMatrixResponse;
if (dIds?.length && cIds?.length) {
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
} else {
@ -163,7 +160,6 @@ export const ChessContent = () => {
</Button>
</div>
}
/>
</div>
);

View File

@ -53,6 +53,7 @@ export function OsvTabs() {
const label = (
<span
key={tabItem.key}
style={{
color: isActive ? '#2a8c3a' : undefined,
fontWeight: isActive ? 600 : 400,

View File

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

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { Table, 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/chess/chess';
import './ConsolidatedChessTable.css';
@ -37,8 +38,8 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
const formatValue = (val: string | number) => {
if (val === undefined || val === null || val === '') return '';
let num = typeof val === 'string' ? parseFloat(val) : val;
if (isNaN(num)) return String(val);
let num = typeof val === 'string' ? Number.parseFloat(val) : val;
if (Number.isNaN(num)) return String(val);
if (units === 'thousand') {
num /= 1000;
@ -57,9 +58,9 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
const isNegative: boolean = val.startsWith('(') && val.endsWith(')');
const cleanVal = isNegative ? val.slice(1, -1) : val;
const num = parseFloat(cleanVal);
const num = Number.parseFloat(cleanVal);
if (isNaN(num)) {
if (Number.isNaN(num)) {
return 0;
}
@ -146,7 +147,7 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
return (
<Table<TableRow>
dataSource={data?.companies || []}
dataSource={data?.companies || []}
columns={columns}
rowKey='id'
pagination={false}
@ -159,7 +160,7 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
if (!totalRowData) return null;
return (
<Table.Summary fixed >
<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%' }}>
@ -180,4 +181,4 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
}}
/>
);
};
};

View File

@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { Space, Select, Button } from 'antd';
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 { MatrixCompany } from '../../types/chess/chess';
import { OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown';
import { api, ApiError } from '../../api/client';
import { useNotificationApi } from '../../utils/useNotification';
interface Account {

View File

@ -0,0 +1,52 @@
import { Form, Input, Modal, message } from 'antd';
import { useState } from 'react';
import { ApiError } from '../../api/client';
import { postComment } from '../../services/api/documents';
type CommentModalProps = { documentId: string; open: boolean; onClose: () => void };
export default function CommentModal({ documentId, open, onClose }: CommentModalProps) {
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const close = () => {
setComment('');
onClose();
};
const handleOk = async () => {
setSaving(true);
try {
await postComment(documentId, comment.trim());
message.success('Комментарий добавлен');
close();
} catch (e: unknown) {
// окно не закрываем: текст не теряется, можно повторить
message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий');
} finally {
setSaving(false);
}
};
return (
<Modal
title='Добавить комментарий'
okText='Добавить'
onOk={handleOk}
okButtonProps={{ disabled: comment.trim().length === 0 }}
confirmLoading={saving}
open={open}
cancelText='Отмена'
onCancel={close}
destroyOnHidden>
<Form layout='vertical'>
<Form.Item label={<span style={{ fontWeight: 600 }}>Комментарий</span>}>
<Input.TextArea
value={comment}
onChange={(e) => setComment(e.target.value)}
autoSize={{ minRows: 3 }}
maxLength={4000}
placeholder='Опишите результат проверки или внесенные правки'
/>
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -20,6 +20,7 @@ import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces';
import { applyLineMenu, showsCodeInput } from './lineMenu';
import { countReviewValues } from './reviewCounters';
import { reviewActions } from './reviewView';
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
@ -68,6 +69,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}, [load]);
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// счётчики над таблицей — только пока форма на проверке
const counters = useMemo(
() => (statement && statement.document.status === 'NEEDS_CHECK' ? countReviewValues(statement) : null),
[statement],
);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
@ -295,6 +301,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
</span>
)}
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
{counters && (
<p style={{ color: 'red' }}>
Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}
</p>
)}
<StatementTable
columns={grid.columns}
layout={layout}

View File

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { HeaderFieldOut, StatementOut, ValueOut } from '../../types/api';
import { countReviewValues } from './reviewCounters';
const v = (extra: Partial<ValueOut> = {}): ValueOut => ({
id: 'v',
column_id: 'c1',
column_index: 0,
amount: '1.00',
string_value: null,
text: null,
confidence: '100',
is_recognized: true,
is_modified: false,
original_amount: null,
needs_review: false,
is_confirmed: false,
...extra,
});
const field = (extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({
value: 'x',
original_value: 'x',
verified: null,
modified: null,
...extra,
});
const statement = (values: ValueOut[], header: Partial<StatementOut['header']> = {}) =>
({
header: {
inn: field(),
report_date: field(),
units: field(),
organization_name_modified: false,
needs_review_fields: [],
...header,
},
lines: [{ id: 'l1', values }],
}) as unknown as StatementOut;
describe('countReviewValues', () => {
it('чистая форма — нули', () => {
expect(countReviewValues(statement([v(), v()]))).toEqual({ changed: 0, needsReview: 0 });
});
it('на проверку — needs_review без подтверждения и без правки (как подсветка ячейки)', () => {
const values = [
v({ needs_review: true }),
v({ needs_review: true, is_confirmed: true }),
v({ needs_review: true, is_modified: true }),
// достроенная сервером ячейка: is_recognized = null, на проверку не считается
v({ id: null, is_recognized: null, amount: null }),
];
expect(countReviewValues(statement(values))).toEqual({ changed: 1, needsReview: 1 });
});
it('шапка: правки полей и наименования, поля из needs_review_fields без подтверждения', () => {
const s = statement([], {
inn: field({ modified: true }),
report_date: field({ verified: true }),
organization_name_modified: true,
needs_review_fields: ['report_date', 'units'],
});
expect(countReviewValues(s)).toEqual({ changed: 2, needsReview: 1 });
});
});

View File

@ -0,0 +1,31 @@
import type { HeaderFieldOut, StatementOut } from '../../types/api';
const HEADER_FIELDS = ['inn', 'report_date', 'units'] as const;
/**
* Счётчики над таблицей формы: «изменённых» — правки аналитика (ячейки, поля шапки, наименование организации);
* «требующих проверки» — то же, что подсвечено на экране: ячейка с `needs_review` и поле из `needs_review_fields`
* (решает ядро), ещё не подтверждённые и не исправленные. Достроенные сервером пустые ячейки не считаются.
*/
export function countReviewValues(statement: StatementOut): { changed: number; needsReview: number } {
let changed = 0;
let needsReview = 0;
for (const line of statement.lines) {
for (const c of line.values) {
if (c.is_modified) changed++;
else if (c.needs_review && !c.is_confirmed) needsReview++;
}
}
const header = statement.header;
for (const key of HEADER_FIELDS) {
if (header[key].modified) changed++;
}
if (header.organization_name_modified) changed++;
for (const name of header.needs_review_fields ?? []) {
const field: HeaderFieldOut | undefined = (HEADER_FIELDS as readonly string[]).includes(name)
? header[name as (typeof HEADER_FIELDS)[number]]
: undefined;
if (!field?.modified && !field?.verified) needsReview++;
}
return { changed, needsReview };
}

View File

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { columnTitle, documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView';
import {
columnTitle,
documentPageTitle,
documentSubtitle,
formTitle,
formatReportDate,
optionStatusBadge,
reviewActions,
statusBadge,
} from './reviewView';
describe('formatReportDate', () => {
it('ISO → дд.мм.гггг', () => {
@ -69,3 +78,12 @@ describe('columnTitle', () => {
expect(columnTitle(col(null, null))).toBe('Колонка 2');
});
});
describe('optionStatusBadge', () => {
it('текст значка по статусу строки документа', () => {
expect(optionStatusBadge('success').text).toBe('Загружен');
expect(optionStatusBadge('warning').text).toBe('Требуется проверка');
expect(optionStatusBadge('processing').text).toBe('Обработка');
expect(optionStatusBadge('error').text).toBe('Ошибка');
});
});

View File

@ -1,6 +1,8 @@
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api';
import { formatMoscowDate } from '../../utils/dates';
import { documentStatusLabel } from '../../utils/labels';
import type { RowStatus } from '../FilesDrawer/documentRows';
/** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */
export function formatReportDate(value: string | null | undefined): string | null {
@ -32,6 +34,17 @@ export function statusBadge(status: DocumentStatus): { label: string; color: 'gr
}
return { label, color: status === 'ERROR' ? 'red' : 'orange' };
}
const OPTION_BADGES: Record<RowStatus, { text: string } & Preset> = {
success: { text: 'Загружен', ...TAG_PRESETS.green },
warning: { text: 'Требуется проверка', ...TAG_PRESETS.orange },
processing: { text: 'Обработка', ...TAG_PRESETS.geekblue },
error: { text: 'Ошибка', ...TAG_PRESETS.red },
};
/** Значок статуса документа в переключателе документов на экране проверки. */
export function optionStatusBadge(status: RowStatus): { text: string } & Preset {
return OPTION_BADGES[status];
}
export function documentSubtitle(file: Pick<FileOut, 'original_name' | 'file_type' | 'uploaded_at'>): string {
return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · ');

View File

@ -0,0 +1,88 @@
import { Button, Modal, Tooltip, Typography, message, theme } from 'antd';
import { type ReactNode, useState } from 'react';
import { ApiError } from '../../api/client';
import { removeFile } from '../../services/api/files';
import type { DocumentRow } from './documentRows';
const { Text } = Typography;
const OptionCard = ({ title, detail, action }: { title: string; detail: string; action: ReactNode }) => {
const { token } = theme.useToken();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: 12,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
}}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text strong>{title}</Text>
<Text type='secondary'>{detail}</Text>
</div>
{action}
</div>
);
};
type DeleteReasonModalProps = {
targetRow: DocumentRow;
open: boolean;
onCancel: () => void;
/** файл удалён — обновить список */
onDelete: () => void;
onReplace: () => void;
/** замена = загрузка нового файла: только в версии в работе, как кнопка «Загрузить документы» */
canReplace: boolean;
};
/** Удаление или замена — файлом целиком (`DELETE /files/{file_id}`): уходят все документы файла. */
export default function DeleteReasonModal({ targetRow, open, onCancel, onDelete, onReplace, canReplace }: DeleteReasonModalProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
await removeFile(targetRow.fileId);
message.success('Файл удалён');
onDelete();
} catch (e: unknown) {
// 409 — например, в файле подтверждённый документ; сервер объясняет причину
message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл');
} finally {
setIsDeleting(false);
}
};
return (
<Modal title='Удаление документа' open={open} onCancel={onCancel} footer={null} destroyOnHidden>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ color: '#667085', margin: 0 }}>Выберите причину удаления документа «{targetRow.name}»</p>
<p style={{ color: 'red', fontSize: 11, margin: 0 }}>Будет удален/заменён сам файл и все его документы</p>
<OptionCard
title='Ошибочный документ'
detail='Документ будет удален без возможности восстановления.'
action={
<Button type='primary' danger loading={isDeleting} onClick={handleDelete}>
Удалить
</Button>
}
/>
<OptionCard
title='Замена документа'
detail='Загрузите актуальный файл и укажите причину замены.'
action={
<Tooltip title={canReplace ? undefined : 'Замена доступна только в версии в работе'}>
<Button type='primary' disabled={!canReplace} onClick={onReplace}>
Заменить
</Button>
</Tooltip>
}
/>
</div>
</Modal>
);
}

View File

@ -1,13 +1,19 @@
import { CloseOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Drawer, Switch, Tooltip, Typography } from 'antd';
import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchLegalEntity } from '../../services/api/directories/company_groups';
import { postComment } from '../../services/api/documents';
import { summarizeUpload, uploadFiles } from '../../services/api/files';
import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors';
import { useAppSelector } from '../../store/store';
import type { FileOut, LegalEntityBrief } from '../../types/api';
import DeleteReasonModal from './DeleteModal';
import { FilesList } from './FilesList';
import ReplaceDocModal from './ReplaceDocModal';
import { UploadDocumentsModal } from './UploadDocumentsModal';
import { consolidationLabel } from './consolidationLabel';
import type { DocumentRow } from './documentRows';
import { useLegalEntityFiles } from './useLegalEntityFiles';
const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе';
@ -22,7 +28,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
const desk = current !== null && !showAll;
const consolidationId = desk ? current.consolidation_id : null;
const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open);
const [targetRow, setTargetRow] = useState<DocumentRow | null>(null);
const [removalOpen, setRemovalOpen] = useState(false);
const canUpload = current !== null && isInWork(current.version_status);
const [replaceOpen, setReplaceOpen] = useState(false);
const [replacing, setReplacing] = useState(false);
const onDelete = () => {
void reload();
setRemovalOpen(false);
};
const handleReplaceClick = () => {
setRemovalOpen(false);
setReplaceOpen(true);
};
const labelFor = desk
? undefined
: (f: FileOut) => consolidationLabel(f.consolidation_id, consolidations, current?.consolidation_id ?? null);
@ -44,6 +64,36 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
};
}, [open, current_legal_entity_id]);
const handleRowDelete = (row: DocumentRow) => {
setTargetRow(row);
setRemovalOpen(true);
};
// Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора,
// если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу.
const handleReplaceSubmit = async (file: File, reason: string) => {
if (!current_legal_entity_id || !targetRow) return;
setReplacing(true);
try {
const result = summarizeUpload(await uploadFiles(current_legal_entity_id, [file], current?.consolidation_id ?? null));
if (result.accepted.length === 0) {
message.error(result.rejected[0]?.reason ?? 'Файл не принят');
return;
}
if (targetRow.documentId) {
await postComment(targetRow.documentId, `Замена документа: ${reason}`).catch(() =>
message.warning('Файл загружен, но причину замены сохранить не удалось'),
);
}
message.success('Файл загружен: прежний будет заменён после разбора, если совпадут тип, счёт и период');
setReplaceOpen(false);
void reload();
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл');
} finally {
setReplacing(false);
}
};
const uploadButton = (
<Button
type='primary'
@ -65,7 +115,13 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
extra={<Button icon={<CloseOutlined />} aria-label='Закрыть' onClick={onClose} />}
title={
<div>
<Typography.Text type='secondary' style={{ fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Typography.Text
type='secondary'
style={{
fontSize: 11,
letterSpacing: 0.5,
textTransform: 'uppercase',
}}>
Первичные документы
</Typography.Text>
{entity && (
@ -78,7 +134,13 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
)}
</div>
}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
}}>
{canUpload ? (
uploadButton
) : (
@ -93,7 +155,7 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
</span>
)}
</div>
<FilesList files={files} loading={loading} labelFor={labelFor} />
<FilesList files={files} loading={loading} labelFor={labelFor} onDeleteClick={handleRowDelete} />
{current_legal_entity_id && (
<UploadDocumentsModal
legalEntityId={current_legal_entity_id}
@ -104,6 +166,26 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
onUploaded={() => void reload()}
/>
)}
{targetRow && (
<>
<DeleteReasonModal
targetRow={targetRow}
open={removalOpen}
onCancel={() => setRemovalOpen(false)}
onDelete={onDelete}
onReplace={handleReplaceClick}
canReplace={canUpload}
/>
<ReplaceDocModal
open={replaceOpen}
docName={targetRow.name}
organizationName={entity?.name ?? null}
submitting={replacing}
onCancel={() => setReplaceOpen(false)}
onSubmit={handleReplaceSubmit}
/>
</>
)}
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { CheckOutlined, CloseOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons';
import { Spin, Table, Tooltip, Typography } from 'antd';
import { CheckOutlined, CloseOutlined, DeleteOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons';
import { Button, Spin, Table, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
@ -55,10 +55,25 @@ function StatusPill({ row }: { row: DocumentRow }) {
return row.errorReason ? <Tooltip title={row.errorReason}>{pill}</Tooltip> : pill;
}
const HEADER = { fontSize: 11, letterSpacing: 0.5, color: '#8c8c8c', textTransform: 'uppercase' as const };
const HEADER = {
fontSize: 11,
letterSpacing: 0.5,
color: '#8c8c8c',
textTransform: 'uppercase' as const,
};
const head = (text: string) => <span style={HEADER}>{text}</span>;
export function FilesList({ files, loading, labelFor }: { files: FileOut[]; loading: boolean; labelFor?: (f: FileOut) => string | null }) {
export function FilesList({
files,
loading,
labelFor,
onDeleteClick,
}: {
files: FileOut[];
loading: boolean;
labelFor?: (f: FileOut) => string | null;
onDeleteClick: (row: DocumentRow) => void;
}) {
const navigate = useNavigate();
const rows = buildDocumentRows(files, labelFor);
@ -67,7 +82,13 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
title: head('Документ'),
key: 'document',
render: (_, r) => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', minWidth: 0 }}>
<div
style={{
display: 'flex',
gap: 12,
alignItems: 'center',
minWidth: 0,
}}>
<FormatIcon type={r.fileType} />
<div style={{ minWidth: 0 }}>
<Tooltip title={r.name}>
@ -82,7 +103,12 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
</div>
),
},
{ title: head('Период'), key: 'period', width: 110, render: (_, r) => r.period },
{
title: head('Период'),
key: 'period',
width: 110,
render: (_, r) => r.period,
},
{
title: head('Проверка'),
key: 'check',
@ -106,6 +132,22 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
</div>
),
},
{
key: 'actions',
title: '',
dataIndex: 'action',
render: (_, r) => (
<Button
icon={<DeleteOutlined />}
aria-label='Удалить файл'
onClick={(e) => {
e.stopPropagation();
onDeleteClick(r);
}}
/>
),
},
];
if (loading && files.length === 0) {
@ -125,7 +167,10 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
dataSource={rows}
locale={{ emptyText: 'Документов нет' }}
onRow={(r) => ({
onClick: r.linkable && r.documentId ? () => navigate(`/documents/${r.documentId}`) : undefined,
onClick:
r.linkable && r.documentId
? () => navigate(`/documents/${r.documentId}`)
: () => message.warning('Просмотр данного документа недоступен'),
style: r.linkable ? { cursor: 'pointer' } : undefined,
})}
/>

View File

@ -0,0 +1,77 @@
import { FileAddOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Upload } from 'antd';
import { useEffect, useState } from 'react';
const { Dragger } = Upload;
type ReplaceDocModalProps = {
open: boolean;
docName: string;
organizationName: string | null;
submitting: boolean;
onCancel: () => void;
onSubmit: (file: File, reason: string) => void;
};
export default function ReplaceDocModal({ open, docName, organizationName, submitting, onCancel, onSubmit }: ReplaceDocModalProps) {
const [reason, setReason] = useState('');
const [file, setFile] = useState<File | null>(null);
// при каждом открытии — пустая форма
useEffect(() => {
if (open) {
setReason('');
setFile(null);
}
}, [open]);
return (
<Modal
title='Замена документа'
open={open}
onCancel={onCancel}
onOk={() => file && onSubmit(file, reason.trim())}
okText='Загрузить'
okButtonProps={{
icon: <UploadOutlined />,
disabled: file === null || reason.trim().length === 0,
}}
confirmLoading={submitting}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{organizationName && <span>{organizationName}</span>}
<div
style={{
backgroundColor: 'rgb(248, 250, 252)',
borderRadius: 6,
fontSize: 12,
color: '#667085',
padding: '8px 10px',
}}>
Заменяемый документ: {docName}
</div>
<Input placeholder='Укажите причину замены' value={reason} maxLength={4000} onChange={(e) => setReason(e.target.value)} />
<Dragger
multiple={false}
style={{ borderStyle: 'dashed' }}
fileList={file ? [{ uid: 'replace', name: file.name, status: 'done' }] : []}
maxCount={1}
accept='.xls,.xlsx,.xlsb,.pdf,.png,.jpg,.jpeg'
beforeUpload={(f) => {
setFile(f);
return false;
}}
onRemove={() => setFile(null)}>
<p className='ant-upload-drag-icon'>
<FileAddOutlined />
</p>
<p className='ant-upload-text' style={{ color: '#667085' }}>
Перетащите файл сюда
</p>
<Button>Выбрать документ</Button>
</Dragger>
</div>
</Modal>
);
}

View File

@ -145,6 +145,13 @@ describe('buildDocumentRows: предупреждения', () => {
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
});
it('fileId — id файла и у строки документа, и у строки файла без документов (удаление идёт по файлу)', () => {
const withDoc = buildDocumentRows([file({ id: 'f7', documents: [doc({ id: 'd7' })] })]);
expect(withDoc[0]).toMatchObject({ key: 'd7', documentId: 'd7', fileId: 'f7' });
const failed = buildDocumentRows([file({ id: 'f8', status: 'ERROR' })]);
expect(failed[0]).toMatchObject({ key: 'f8', documentId: null, fileId: 'f8' });
});
it('job null — пустые списки; старый формат строк не ломает', () => {
expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] });
const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]);

View File

@ -10,6 +10,8 @@ export interface DocumentRow {
key: string;
/** id документа; null — строка файла без документов */
documentId: string | null;
/** id файла: удаление и замена — файлом целиком */
fileId: string;
name: string;
fileType: FileType;
subtitle: string;
@ -64,6 +66,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
{
key: f.id,
documentId: null,
fileId: f.id,
name,
fileType: f.file_type,
subtitle: join([f.file_type, date, extra]),
@ -87,6 +90,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
return {
key: d.id,
documentId: d.id,
fileId: f.id,
name,
fileType: f.file_type,
subtitle: join([typeText, f.file_type, date, extra]),