Compare commits

...

6 Commits

Author SHA1 Message Date
Andrey Epifancev
c8a89d4c63 docs(openspec): consolidations-ui — расхождения закрыты, дельта consolidated-osv-ui «Единицы» (AURORA-1589, PR #166)
Решение владельца 23.10.2026: PR доводим сами, 71ef633 остаётся. design.md: Known issues
1–14 закрыты, открыты 15 (lucide-react в Nexus), 16 (предупреждение вне модалок), 17
(красное поле поиска в [42]); решения без обсуждения (л)–(х), пересмотр (е) и (к).
Спека: несохранённые правки в модалках, ошибки [35], [31-00] при неактивных вкладках.
tasks.md, web/ARCHITECTURE.md, web/CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
Andrey Epifancev
2fa672a2fb fix(web): «Новая консолидация» — «+ Создать» от двух ЮЛ, строка ЮЛ — флажок antd (AURORA-1589, PR #166)
Сервер отвечает 422 PERIMETER_TOO_SMALL при одном ЮЛ — кнопка неактивна раньше, у поля
«Выберите не меньше двух ЮЛ». Строка ЮЛ — сам Checkbox (label на всю строку) вместо
div role="checkbox" (biome useSemanticElements). Предупреждение браузера при начатой форме.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
Andrey Epifancev
d71c46e846 fix(web): список консолидаций и «Взять в работу» по спеке (AURORA-1589, PR #166)
«Только в работе» выключен по умолчанию, параметр — boolean; ошибка загрузки списка
показывается; пункты «⋯» disabled с подсказкой «Будет позже», клик не выбирает строку;
«Взять в работу» неактивна по коду CANCELLED. Модалка [35]: опечатки, плейсхолдер,
≤ 1000 символов, «К3», ошибки (403 и прочие) — текст сервера, ФИО без второй точки;
после взятия в работу перечитываются список и панель «Организации» («Ответственный»).
Боковое меню: thunk списка с объектом, отступ секций. Порядок импортов (biome).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
Andrey Epifancev
ccca6dfa13 fix(web): «Изменить статус» — путь /consolidations/…/status, модалка по спеке, [31-00] (AURORA-1589, PR #166)
404 был из-за опечатки в пути (/consolidation/ без s). Модалка берёт статус из карточки
страницы, а после 200 страница берёт карточку из ответа; в «Новом этапе» ничего не
выбрано; ошибки — текст сервера, модалка остаётся открытой. Кнопка в шапке активна при
своей блокировке и наличии ручных переходов, подсказка «Возьмите консолидацию в работу».
Пояснение под заголовком — текст статуса, а не название. Модалка «Остались неуказанные
ИНН» при уходе с «ОСВ» (вкладки шагов пока неактивны). Предупреждение браузера при
начатом вводе.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
Andrey Epifancev
54de39727b fix(web): «Единицы» сводной ОСВ — масштаб на строках без float, ноль — «—» (AURORA-1589, PR #166)
formatScaledAmount в utils/money.ts: запятая сдвигается на 3/6/9 разрядов в строке,
округление половины от нуля на BigInt; нулевая сумма — «—» (zeroDash, как до 71ef633).
Подписи «млн руб.», «млрд руб.» без точки. Исправляет коммит 71ef633.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
Andrey Epifancev
46d553b074 fix(web): правила экранов консолидаций — в utils/consolidations.ts с тестами (AURORA-1589, PR #166)
Подписи и пояснения статусов версии, ручные переходы MANUAL_TRANSITIONS (зеркало
api/src/services/consolidation_status.py, синхронная константа вместо мока), своя
блокировка, активность «Изменить статус», причина ≤ 1000 после обрезки, «Версия»
(1 — пусто, 2+ — номер), разбор ?in_work_only (по умолчанию выключен), «Взять в работу»
по коду статуса CANCELLED. Подпись CANCELLED — «Отменена»; у строки списка — код статуса
(status_code). Хук useBeforeUnload. Тесты vitest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:15:55 +04:00
32 changed files with 865 additions and 428 deletions

View File

@ -9,8 +9,11 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
меняется. В ветку добавлена вторая библиотека иконок `lucide-react` (иконки макета: меню «⋯», «Взять в работу», «К
отчётности», боковое меню).
Спеку, документацию и тесты ведёт ревьюер коммитами в ветку автора (правило владельца для чужих PR); продуктовый код
правит автор. Спека описывает **целевое** поведение; расхождения кода ветки (коммит 71ef633) — раздел «Known issues».
Спеку, документацию и тесты ведёт ревьюер коммитами в ветку автора (правило владельца для чужих PR). Спека описывает
**целевое** поведение; расхождения кода ветки (коммит 71ef633) с ней — раздел «Known issues». Решение владельца
23.10.2026 после ревью раунда 1: продуктовый код ревьюер исправляет сам в этой же ветке (ручной код автора
не переписывается сверх нужного), коммит 71ef633 («Единицы» сводной ОСВ) остаётся в PR и тоже исправляется, его
поведение описано дельтой `consolidated-osv-ui`. Известные расхождения 1–14 закрыты (раздел «Known issues»).
Источники: макет [34], [35], [42], [30], [33-01], [33], [31-00] (`docs/mockup/INDEX.md`), описание AURORA-1589, ТЗ
«Статусная модель» (`docs/source/tz/…08_4. Статусная модель.md`), гайд фронта по API §5.7.
@ -46,40 +49,43 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
## Known issues
Расхождения кода ветки `AURORA-1589` (71ef633) с целевой спекой — по пунктам ревью раунда 1; исправляет автор PR.
Расхождения кода ветки `AURORA-1589` (71ef633) с целевой спекой по ревью раунда 1. По решению владельца (23.10.2026)
1–14 исправлены ревьюером в этой же ветке; открытыми остаются 15 и часть 16.
1. **404 при смене статуса.** `services/api/consolidations.ts` `updateStatus` шлёт `POST /consolidation/versions/{id}/status`
(нет «s»); нужен `/consolidations/versions/{id}/status`.
2. **Переходы — заглушка `mockStatusMappingFetch()` внутри модалки** (`ChangeStatusModal.tsx`). Нужна одна константа (D-1)
рядом с подписями статусов. Для статусов без переходов сейчас `nextStatusOptions = undefined`: форма скрыта, кнопка
«Изменить статус» в шапке при этом активна.
3. **«Новый этап» предвыбран** (`initialValues.to_status = nextStatusOptions[0]`, на стенде — «Отменён»); нужно пусто (D-5).
4. **Кнопка «Изменить статус»**: подсказка «Сначала возьмите версию в работу» вместо «Возьмите консолидацию в работу»;
отсутствие ручных переходов не учитывается (при своей блокировке `VERIFIED` кнопка активна, модалка пустая).
5. **Колонка «Версия» инвертирована** (`ConsoleTable/config.tsx`): показывает «1» и скрывает 2+; нужно наоборот.
6. **«Только в работе» включён по умолчанию** (`ConsoleTable.tsx`: `searchParams.get('in_work_only') || true`, флажок
отмечен, пока параметр не `'false'`); нужно выключен (D-6).
7. **Пункты «⋯» не `disabled`**: у них только класс `ant-dropdown-menu-item-disabled`, клик обрабатывается
(«История изменений» ведёт в пустой «Журнал действий», остальные — `message.info('Будет позже')`); в `ITEMS` лишний
пустой элемент `{}`.
8. **Подпись `CANCELLED` — «Отменён»** (нужно «Отменена», D-9); «Взять в работу» блокируется сравнением
`row.status === 'Отменён'` — после переименования условие перестанет срабатывать (D-10).
9. **Перехват [35]**: плейсхолдер поля причины «Type message»; проверка длины `< 1000` вместо `≤ 1000`; квартал в тексте —
`Q3` вместо «К3»; опечатки «обнаржуена», «нформацию».
10. **Пояснение под заголовком**: для `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` повторяется название статуса;
тексты `VERIFIED` и `CONSOLIDATION_RF` в `consStatusDescriptionMapping` расходятся с макетом [30], [33-01].
11. **Модалка [31-00] «Остались неуказанные ИНН»** не реализована. Другие вкладки отчёта в ветке пока неактивны
(`OsvTabs`), поэтому сценарий проверяется после появления вкладки «Шахматка» (PR #158) или в нём же.
12. **«+ Создать» активна при одном отмеченном ЮЛ** (`NewConsoleModal.tsx`, `!legEntIds?.length`); нужно не меньше двух
(сервер отвечает 422 `PERIMETER_TOO_SMALL`).
13. **Модалка «Изменить статус», замечено при написании спеки** (в комментарий ревью раунда 1 не вошло): ошибка
`getVersionCard` не обрабатывается — модалка остаётся в загрузке; `comment.trim()` вызывается на значении
`Form.useWatch`, которое до монтирования формы `undefined`.
14. **Тестов нет.** Чистые правила (подпись версии, переходы, активность кнопок, параметр `in_work_only`) зашиты в
компоненты; тесты vitest — после их выноса в чистые функции (`tasks.md`).
15. **`lucide-react@0.263.1` в зеркале npm банка не проверен.** Пакет в `dependencies`, снимок контура его оставляет
(`docs/dev/contour-transfer-guide.md` §3.2); если версии нет в Nexus, сборка в контуре упадёт.
16. **Предупреждение браузера о несохранённых правках** (пункт AURORA-1589) не реализовано; в спеку не входит (решение (е)).
1. ~~404 при смене статуса~~ — закрыто: `updateStatus` шлёт `POST /consolidations/versions/{id}/status` (была опечатка
`/consolidation/…`; эндпоинт в main есть, на стенде отвечает 409 `TRANSITION_NOT_ALLOWED` на недопустимый переход).
2. ~~Переходы — заглушка `mockStatusMappingFetch()`~~ — закрыто: синхронная константа `MANUAL_TRANSITIONS` в
`web/src/utils/consolidations.ts` (зеркало бэка, D-1) и `manualTransitions(status)` — пустой список без переходов.
3. ~~«Новый этап» предвыбран~~ — закрыто: по умолчанию пусто, плейсхолдер «Выберите этап» (D-5).
4. ~~Кнопка «Изменить статус»~~ — закрыто: активность — `canChangeStatus` (своя блокировка и есть ручные переходы),
подсказка «Возьмите консолидацию в работу» без своей блокировки (D-3).
5. ~~Колонка «Версия» инвертирована~~ — закрыто: `versionLabel` (1 → пусто, 2+ → номер).
6. ~~«Только в работе» включён по умолчанию~~ — закрыто: `parseInWorkOnly` (включён только `?in_work_only=true`),
параметр запроса — boolean через `api.get(path, params)` (D-6).
7. ~~Пункты «⋯» не `disabled`~~ — закрыто: `disabled: true`, подсказка «Будет позже», переход в журнал и
`message.info` убраны, пустой элемент `{}` убран, клик по меню не выбирает строку таблицы.
8. ~~«Отменён»~~ — закрыто: «Отменена»; «Взять в работу» блокирует `canTakeInWork(status_code)` — у строки списка есть код
статуса (`ConsRow.status_code`), подпись только для показа и фильтра (D-9, D-10).
9. ~~Перехват [35]~~ — закрыто: плейсхолдер «Укажите причину замены», счётчик 0/1000, `isValidReason` (≤ 1000 после
обрезки), «К3» через `quarterShortLabel`, опечатки исправлены, ФИО с инициалами без второй точки (`endSentence`).
10. ~~Пояснение под заголовком~~ — закрыто: `CONS_STATUS_DESCRIPTION` / `consStatusDescription` (тексты спеки; нет текста —
пусто, название статуса не повторяется).
11. ~~Модалка [31-00]~~ — закрыто: `UnmappedInnModal` и проверка в `OsvTabs` (`needsUnmappedWarning`). Вкладки шагов
пока неактивны, поэтому на стенде модалка проверена на временной копии с активной «Шахматкой» (решение (м)).
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 / платформе.
16. **Предупреждение браузера о несохранённых правках** — частично: сделано для трёх модалок экранов консолидаций
(требование «Предупреждение о несохранённых правках в модалках консолидаций», решение (л)). Открыто: нужно ли оно в
остальных формах приложения (справочники, проверка документов) и при переходе по меню вне модалок (у `BrowserRouter`
нет `useBlocker`, нужен переход на data router) — вопрос владельцу.
17. **Поле поиска в блоке «ЮЛ консолидации» [42] подсвечивается красным**, когда у поля ошибка «Выберите не меньше двух
ЮЛ» (antd передаёт статус `Form.Item` вложенному `Input`); так было и с прежним правилом `required`. Косметика, открыто.
## Decisions made without discussion
@ -104,7 +110,7 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
расширение триггера — MODIFIED требования.
- **2026-10-23 (е). Предупреждение браузера о несохранённых правках не включено в спеку**: пункт задачи касается всех форм
и модалок приложения, а не экранов консолидаций; остаётся задачей автора в `tasks.md`. Цена ошибки: требование появится
отдельной спекой (или дельтой) позже.
отдельной спекой (или дельтой) позже. **Пересмотрено 23.10.2026:** для модалок экранов консолидаций — решение (л).
- **2026-10-23 (ж). Подпись `DOCUMENTS_PROCESSING` оставлена «Обработка документов»** (как в `main`); в ТЗ — «Загрузка,
распознавание и корректировка документов», в словаре UI — «Загрузка и распознавание документов». Вопрос аналитику.
Цена ошибки: замена одной подписи.
@ -115,3 +121,38 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
сливается в этот `main`.
- **2026-10-23 (к). «Единицы» и «Знаков после запятой» сводной ОСВ (коммит 71ef633, AURORA-1539) в спеку не включены** —
автору предложено вынести их в отдельный PR. Цена ошибки: если коммит останется в PR, его поведение будет без спеки.
**Пересмотрено 23.10.2026 решением владельца:** коммит остаётся в PR, поведение описано дельтой `consolidated-osv-ui`
(«Единицы и знаки после запятой»).
- **2026-10-23 (л). Предупреждение о несохранённых правках — `beforeunload` в трёх модалках экранов консолидаций**
(перехват с причиной, «Изменить статус», «Новая консолидация»; хук `web/src/hooks/useBeforeUnload.ts`), требование —
в спеке `consolidations-ui`. Пересматривает (е) в части этих экранов. Обоснование: в задаче — «в открытой форме или
модалке»; модалки этого PR — понятное место, маска модалки и так закрывает меню; формы других экранов и переход по
меню вне модалок требуют решения владельца (Known issues 16). Цена ошибки: если предупреждение нужно и в других формах —
подключить тот же хук; если не нужно вовсе — убрать три вызова.
- **2026-10-23 (м). Модалка [31-00] — в `OsvTabs` с параметрами `onNavigate` / `enabledTabs`**; пока шагов нет, вкладки
неактивны и модалка не вызывается, на стенде она проверена на временной копии с активной «Шахматкой». Обоснование: PR
шахматки (#158) только передаёт переход, модалку не пишет. Цена ошибки: подключение вкладки — две строки.
- **2026-10-23 (н). Ошибки в модалках — текст сервера** (`ApiError.message`, по-русски) вместо карты фронтовых текстов;
исключение — 409 `VERSION_NOT_EDITABLE` в [35] (текст спеки). Обоснование: незнакомые коды и 403 раньше проходили молча
или «Что-то пошло не так». Цена ошибки: замена строк.
- **2026-10-23 (о). Пояснения статусов без текстов** (`ADJUSTMENT`, `ARCHIVED`, `REJECTED`) — пусто под заголовком;
`CONSOLIDATION_GO`, `VERIFICATION_RF`, `VERIFICATION_GO` — тексты автора PR, с ТЗ не сверены. Цена ошибки: строки в
`CONS_STATUS_DESCRIPTION`.
- **2026-10-23 (п). Единицы: «млн руб.», «млрд руб.» без точки** (сокращения «млн», «млрд» пишутся без точки; у автора —
«млн. руб.»); по умолчанию «руб.» и 2 знака, хотя в макете [30] выбрано «тыс. руб.»: контрольные цифры спеки
`consolidated-osv-ui` и гайда тестировщика — в рублях. Цена ошибки: одна строка умолчания.
- **2026-10-23 (р). Масштаб сумм — на строке и BigInt, округление половины от нуля** (`formatScaledAmount`, как в
Excel); ноль — «—» по исходной сумме, ненулевая сумма, округлённая до нуля, — «0,00» без минуса. Обоснование: правило
«суммы — строками» (`utils/money.ts`), у автора был `Number()` и потерян `zeroDash`. Цена ошибки: одна функция и тест.
- **2026-10-23 (с). После создания консолидации боковое меню перечитывает список с `in_work_only: false`**: переход
открывает список без `?in_work_only` (флажок выключен). Цена ошибки: низкая.
- **2026-10-23 (т). Строка ЮЛ модалки [42] — сам флажок antd (`<label>` на всю строку)** вместо `div role="checkbox"`
(замечание biome `useSemanticElements`); клик по наименованию или ИНН переключает, клавиатура — как у флажка. Цена
ошибки: низкая.
- **2026-10-23 (у). «Ответственный» после «Взять в работу» — перечитывание панели «Организации»** через `reloadKey`
страницы (`ConsoleTableWidget onVersionLocked`); список перечитывается только после успеха или 409
`VERSION_NOT_EDITABLE`, не после каждой ошибки. Цена ошибки: низкая.
- **2026-10-23 (ф). Чистые правила — `web/src/utils/consolidations.ts`**, подписи и пояснения статусов перенесены туда
из `widgets/ConsoleTable/consts.ts` (там остались цвета бейджей). Цена ошибки: низкая.
- **2026-10-23 (х). Отступ секций бокового меню — `padding: 12px 0`** (у автора закомментирован `12px`): по вертикали —
как в макете [34], по горизонтали пункты остаются на одной линии с кнопкой «Новая консолидация». Цена ошибки: CSS.

View File

@ -4,8 +4,9 @@
макета: список консолидаций ГК [34] с «Взять в работу» и перехватом [35], модалку «Новая консолидация» [42] и кнопку
«Изменить статус» в карточке версии [30] с модалкой [33-01]. Бэкенд этих действий уже в main (`consolidations-api`:
список с `in_work_only`, блокировка и перехват, ручная смена статуса). У экранов нет UI-спеки. Спеку, документацию и тесты
ведёт ревьюер коммитами в ветку автора (правило владельца): спека задаёт целевое поведение, расхождения кода ветки с ней —
`design.md`, «Known issues»; продуктовый код правит автор.
ведёт ревьюер коммитами в ветку автора (правило владельца). После ревью раунда 1 владелец решил (23.10.2026) довести PR
самим в этой же ветке: расхождения кода с целевой спекой (бывшие «Known issues» `design.md`) исправлены ревьюером, коммит
71ef633 («Единицы» сводной ОСВ) остаётся в PR и тоже исправлен.
## What Changes
@ -21,21 +22,25 @@
- «Изменить статус» [33-01]: допустимые переходы — зеркало `MANUAL_TRANSITIONS` бэка, по умолчанию ничего не выбрано,
кнопка доступна только владельцу блокировки и при наличии ручных переходов.
- Модалка [31-00] «Остались неуказанные ИНН» при уходе со «Сводной ОСВ» на другую вкладку отчёта.
- Новая спека `consolidations-ui` (ADDED Requirements).
- Предупреждение браузера о несохранённых правках в модалках этих экранов («Взять в работу» с причиной, «Изменить
статус», «Новая консолидация»).
- Сводная ОСВ [30]: «Единицы» (руб. / тыс. руб. / млн руб. / млрд руб.) и «Знаков после запятой» (0–3) — масштаб на
строках без float, ноль — «—».
- Новая спека `consolidations-ui` (ADDED Requirements), дельта `consolidated-osv-ui` (ADDED).
- Чистые правила экранов — `web/src/utils/consolidations.ts`, масштаб сумм — `formatScaledAmount` в `web/src/utils/money.ts`,
тесты vitest.
- Документация фронта: `web/CLAUDE.md` (вторая библиотека иконок `lucide-react`), `web/ARCHITECTURE.md` (новые виджеты).
## Non-goals
- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR.
- «Единицы» и «Знаков после запятой» в сводной ОСВ (коммит 71ef633, AURORA-1539) — автору предложено вынести в отдельный
PR; в эту спеку не входят.
- Полный маршрут согласования (переходы Среза 7), «Добавить версию», «Отключить подтверждение КФО», «История изменений»,
«Редактировать состав», «Импорт», «Дата верификации», «Утверждён в РФ» / «Верифицирован в ГО» — зонтичная задача
AURORA-1416.
- ГСК консолидации в API списка и карточки (колонка «ГСК») — отдельная задача ядра; до неё колонка пустая.
- Список допустимых переходов из API (`allowed_transitions`) — с маршрутом согласования; до него фронт держит зеркало.
- Предупреждение браузера о несохранённых правках (пункт задачи AURORA-1589) — касается всех форм приложения, в эту спеку
не входит (`design.md`, решение без обсуждения (е)).
- Предупреждение браузера о несохранённых правках в формах вне экранов консолидаций (справочники, проверка документов) и
при переходе по меню приложения — открытый вопрос владельцу (`design.md`, «Открытые вопросы»).
- «Настройка таблицы», «На весь экран», фильтры по суммам в сводной ОСВ — задача «Сводная ОСВ: UI по макету».
- Изменения бэкенда и DBML.
## Capabilities
@ -46,12 +51,16 @@
### Modified Capabilities
Нет: `consolidation-setup` (состав модалки [42], создание, «К отчётности») и `consolidated-osv-ui` (сводная ОСВ) не
правятся — добавленное поведение описано в новой спеке (`design.md`, решение без обсуждения (а)).
- `consolidated-osv-ui`: ADDED требование «Единицы и знаки после запятой» (коммит 71ef633 остаётся в PR, решение
владельца 23.10.2026). `consolidation-setup` (состав модалки [42], создание, «К отчётности») не правится — вид модалки
и шапка карточки описаны в новой спеке (`design.md`, решение без обсуждения (а)).
## Impact
- `web/src/widgets/ConsoleTable/` (`ConsoleTable.tsx`, `LockModal.tsx`, `config.tsx`, `consts.ts`),
- `web/src/utils/consolidations.ts` (+ тест), `web/src/utils/money.ts` (`formatScaledAmount`, + тест),
`web/src/hooks/useBeforeUnload.ts`, `web/src/widgets/ConsolidatedOsv/` (`OsvTabs.tsx` — модалка [31-00],
`useNumberDisplay.tsx`, `OsvTable.tsx`),
- `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`,

View File

@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: Единицы и знаки после запятой
Над таблицей сводной ОСВ [30] SHALL быть два выпадающих списка: «Единицы» — «руб.», «тыс. руб.», «млн руб.», «млрд руб.»
(по умолчанию «руб.»: суммы API — в рублях) и «Знаков после запятой» — 0, 1, 2, 3 (по умолчанию 2). Выбор SHALL менять
только показ сумм шести денежных колонок (сальдо на начало, обороты, сальдо на конец, Дт/Кт); запрос, фильтры и экспорт в
xlsx не меняются. Пересчёт SHALL выполняться над строкой суммы из API без арифметики с плавающей точкой: запятая
сдвигается на 3/6/9 разрядов, округление до выбранного числа знаков — половина от нуля, разряды разделяются пробелом,
дробная часть — через запятую. Нулевая сумма SHALL показываться «—» при любых единицах; ненулевая сумма, округлившаяся до
нуля, — нулём с выбранным числом знаков, без знака «минус».
#### Scenario: Тысячи рублей
- **WHEN** выбраны «тыс. руб.» и 3 знака, сумма строки — `24000.00`
- **THEN** в ячейке «24,000»
#### Scenario: Округление без потери разрядов
- **WHEN** выбраны «тыс. руб.» и 2 знака, сумма — `4454151.93`
- **THEN** в ячейке «4 454,15»
#### Scenario: Ноль
- **WHEN** сумма ячейки — `0.00`, выбраны любые единицы и число знаков
- **THEN** в ячейке «—»
#### Scenario: Малая сумма в крупных единицах
- **WHEN** выбраны «млн руб.» и 2 знака, сумма — `-0.40`
- **THEN** в ячейке «0,00»

View File

@ -58,7 +58,9 @@
После 200/201 модалка SHALL закрыться с сообщением «Консолидация взята в работу.» и перечитать список. Ответ 409
`LOCKED_BY_OTHER` на запрос без причины (версию успели взять) SHALL переводить модалку в режим перехвата с предупреждением
«<ФИО> только что взял(-а) консолидацию в работу.» и пустым полем причины. Ответ 409 `VERSION_NOT_EDITABLE` SHALL
закрывать модалку с сообщением «Нельзя взять в работу отменённую версию.». У версии со статусом `CANCELLED` кнопка
закрывать модалку с сообщением «Нельзя взять в работу отменённую версию.». Прочие ошибки (403 без права
`CONSOLIDATION_EDIT`, иные 409/422) SHALL показываться текстом сервера (`ApiError.message`), модалка остаётся открытой. ФИО
в тексте модалки не получает вторую точку («Соколов А. В. Укажите причину замены.»). У версии со статусом `CANCELLED` кнопка
«Взять в работу» SHALL быть неактивной (условие — по коду статуса, а не по подписи); у версии, которую держит текущий
пользователь, вместо неё SHALL показываться неактивная кнопка «В работе».
@ -80,6 +82,15 @@
- **WHEN** версия в статусе «Отменена»
- **THEN** «Взять в работу» в её строке неактивна
#### Scenario: Нет права
- **WHEN** пользователь без `CONSOLIDATION_EDIT` подтверждает перехват, сервер отвечает 403
- **THEN** показан текст ответа сервера («недостаточно прав: требуется CONSOLIDATION_EDIT»), модалка открыта
#### Scenario: Панель «Организации» после взятия в работу
- **WHEN** в списке выбрана версия, аналитик берёт её в работу
- **THEN** список и карточка выбранной версии перечитаны, «Ответственный» в панели «Организации» — текущий владелец
блокировки
### Requirement: Меню «⋯» строки консолидации
Меню «⋯» строки SHALL содержать пункты «Добавить версию», «Отключить подтверждение КФО», «История изменений» с иконками
[43]. Пока функции нет, пункты SHALL быть неактивными (`disabled`) с подсказкой «Будет позже»; нажатие на неактивный пункт
@ -214,7 +225,9 @@ SHALL быть неактивной с подсказкой «Возьмите
При уходе со вкладки «ОСВ» на другую вкладку отчёта версии, если в сводке `unmapped_total > 0`, фронт SHALL показывать
модалку [31-00] «Остались неуказанные ИНН»: «В ОСВ остались неуказанные ИНН», «Не заполнено ИНН: N. Точно хотите перейти
в другой раздел?», кнопки «Остаться» / «Перейти». «Остаться» SHALL закрывать модалку без перехода, «Перейти» — выполнять
переход. При `unmapped_total = 0` переход SHALL выполняться без модалки.
переход. При `unmapped_total = 0` переход SHALL выполняться без модалки. Пока шагов после ОСВ нет, их вкладки
неактивны («Будет в следующих срезах») и модалка не вызывается; вкладка становится активной, когда для неё задан переход
(первой — «Шахматка»).
#### Scenario: Есть несопоставленные строки
- **WHEN** в сводке `unmapped_total = 2` и аналитик выбирает другую вкладку отчёта
@ -224,3 +237,18 @@ SHALL быть неактивной с подсказкой «Возьмите
#### Scenario: Все строки сопоставлены
- **WHEN** `unmapped_total = 0` и аналитик выбирает другую вкладку отчёта
- **THEN** вкладка открывается без модалки
### Requirement: Предупреждение о несохранённых правках в модалках консолидаций
Пока открыта модалка с начатым вводом, браузер SHALL спрашивать подтверждение при закрытии или перезагрузке вкладки
(стандартный диалог `beforeunload`, текст задаёт браузер): «Взять консолидацию в работу?» — введена причина перехвата
(непустая после обрезки пробелов); «Изменить статус» — выбран новый этап или введён комментарий; «Новая консолидация» —
выбрано хотя бы одно из полей ГК, ГСК, год, квартал. После закрытия модалки (отправка или «Отмена») предупреждения нет.
Переход по меню приложения при открытой модалке закрыт её маской.
#### Scenario: Причина перехвата введена
- **WHEN** в модалке перехвата введена причина «Отпуск» и аналитик перезагружает вкладку
- **THEN** браузер показывает диалог подтверждения ухода
#### Scenario: Ничего не введено
- **WHEN** модалка «Изменить статус» открыта, этап не выбран, комментарий пуст, аналитик закрывает вкладку
- **THEN** вкладка закрывается без диалога

View File

@ -6,27 +6,36 @@
- [x] 1.3 `web/CLAUDE.md` — `lucide-react` в «Стек»; `web/ARCHITECTURE.md` — новые виджеты экранов консолидаций.
- [x] 1.4 Гайд фронта по API §5.7 — заметка о зеркале ручных переходов (PR в RSHB/arm-ka-docs, сливать вместе с #166).
## 2. Автор PR
## 2. Исправления по ревью раунда 1 (ревьюер, решение владельца 23.10.2026)
- [ ] 2.1 Исправить «Known issues» 1–13 из `design.md`.
- [ ] 2.2 Вынести коммит 71ef633 («Единицы» сводной ОСВ, AURORA-1539) в отдельный PR.
- [ ] 2.3 Вынести чистые правила из компонентов в функции без React: подпись колонки «Версия», константа ручных переходов
(D-1), активность кнопки «Изменить статус» (D-3), активность «Изменить статус» в модалке, параметр `in_work_only`,
подписи и пояснения статусов.
- [ ] 2.4 Предупреждение браузера о несохранённых правках (пункт AURORA-1589; вне спеки, решение (е) в `design.md`).
- [x] 2.1 Исправить «Known issues» 1–14 из `design.md` (путь смены статуса, `MANUAL_TRANSITIONS` константой, статус для
модалки из карточки страницы, ничего не выбрано, кнопка «Изменить статус» по спеке, «Версия», «Только в работе»,
«⋯» `disabled`, ошибки текстом сервера, [35], «Отменена», пояснения статусов, «+ Создать» от двух ЮЛ, [31-00],
отступ бокового меню, ошибка загрузки списка, «Ответственный» после «Взять в работу», Sidebar с объектом в thunk).
- [x] 2.2 Коммит 71ef633 оставить в PR (решение владельца): масштаб сумм на строках без float (`formatScaledAmount`),
«—» для нулей, дельта `consolidated-osv-ui`.
- [x] 2.3 Вынести чистые правила из компонентов: `web/src/utils/consolidations.ts` (подписи и пояснения статусов,
`MANUAL_TRANSITIONS`, `canChangeStatus`, `canSubmitStatusChange`, `isValidReason`, `versionLabel`,
`parseInWorkOnly`, `canTakeInWork`, `needsUnmappedWarning`, `endSentence`).
- [x] 2.4 Предупреждение браузера о несохранённых правках — в модалках экранов консолидаций (решение (л)); остальные
формы — вопрос владельцу (Known issues 16).
- [ ] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) или заменить иконки на
`@ant-design/icons`.
- [ ] 2.6 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
- [ ] 2.7 Сверка экранов с макетом [34], [35], [42], [30], [33-01], [31-00] на стенде; сценарий AURORA-1589: взять версию
в работу → «Изменить статус» → «Отменена» с комментарием → новый статус в списке и запись в истории; то же для
возврата в «Инициация».
`@ant-design/icons` — вопрос автору PR.
- [x] 2.6 Проверки в копии ветки с `lucide-react`: `tsc --noEmit`, `biome check`, `prettier --check`, `eslint` (0 ошибок,
предупреждений меньше, чем на main), `vitest run`, `npm run build` — зелёные.
- [x] 2.7 Сверка на стенде: список [34] (флажок, «Версия», «⋯»), [35] (свободная версия, перехват и гонка — с подменой
ответа, 403 без права), «Изменить статус» (варианты, пустой выбор, 409 `TRANSITION_NOT_ALLOWED` подменой тела,
карточка из ответа — подменой ответа; статусы стенда не менялись), [42] (одно / два ЮЛ, без создания), сводная ОСВ
(единицы, «—»), [31-00] — на временной копии. Сценарий AURORA-1589 со сменой статуса на реальных данных
(«Отменена» необратима) — за тестировщиком на отдельной версии.
## 3. Тесты (ревьюер, после 2.3)
## 3. Тесты (ревьюер)
- [ ] 3.1 vitest на вынесенные функции: «Версия» (1 → пусто, 2 → «2»); переходы — совпадение с `MANUAL_TRANSITIONS`
- [x] 3.1 vitest на вынесенные функции: «Версия» (1 → пусто, 2 → «2»); переходы — совпадение с `MANUAL_TRANSITIONS`
и пустой список для `VERIFIED`, `CANCELLED`, статусов Среза 7; кнопка «Изменить статус» (своя / чужая / нет
блокировки × есть / нет переходов); модалка — нет выбора, комментарий из пробелов; `in_work_only` по умолчанию `false`;
подписи «Корректировка данных», «Отменена»; пояснение не равно подписи для статусов из спеки.
подписи «Корректировка данных», «Отменена»; пояснение не равно подписи; причина ровно 1000 символов;
`formatScaledAmount` (сдвиг, округление, ноль, большие суммы без потери разрядов).
## 4. После слияния

View File

@ -12,7 +12,8 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне
| `src/auth/` | профиль `/me`, dev-вход, права для меню | `api/` |
| `src/services/api/` | вызовы ресурсов arm-core | `api/`, `types/` |
| `src/store/` | состояние экранов (Redux Toolkit) | `services/api/` |
| `src/utils/` | чистые функции: деньги, подписи, скачивание | `types/` |
| `src/utils/` | чистые функции: деньги, подписи, скачивание, правила консолидаций | `types/` |
| `src/hooks/` | общие React-хуки (виртуальный список, предупреждение `beforeunload`) | React |
| `src/types/` | DTO API (повторяют `../api/src/schemas/`) | — |
| `src/components/` | примитивы UI (таблица, теги, заголовок) | antd |
| `src/widgets/` | блоки экранов (`DocumentReview` — проверка документа со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer`; консолидации — см. ниже) | `components/`, `store/`, `services/api/`, `utils/` |
@ -27,7 +28,11 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне
|---|---|---|
| `ConsoleTable/ConsoleTable.tsx` | [34] | список консолидаций ГК: дерево версий, «Только в работе» (`in_work_only`), действия строки, меню «⋯» |
| `ConsoleTable/LockModal.tsx` | [35] | «Взять в работу» (`POST …/lock`) и перехват с причиной (`takeover_reason`) |
| `ConsoleTable/consts.ts` | — | подписи, цвета и пояснения статусов версии — одно место для списка, карточки и модалки |
| `../utils/consolidations.ts` | — | правила без React: подписи и пояснения статусов версии, ручные переходы (`MANUAL_TRANSITIONS` — зеркало бэка), своя блокировка, «Версия», `in_work_only`, причина ≤ 1000 |
| `ConsoleTable/consts.ts` | — | цвета бейджей статусов версии |
| `ConsolidatedOsv/OsvTabs.tsx` | [30], [31-00] | вкладки шагов отчёта и «Остались неуказанные ИНН» при уходе с «ОСВ» |
| `ConsolidatedOsv/useNumberDisplay.tsx` | [30] | «Единицы» и «Знаков после запятой» сводной ОСВ (`formatScaledAmount` из `utils/money.ts`) |
| `../hooks/useBeforeUnload.ts` | — | предупреждение браузера о несохранённых правках в открытой модалке |
| `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист |
| `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой |
| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidatedOsvPage.tsx` |

View File

@ -44,7 +44,8 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп
(скан PDF, экспорт xlsx) с той же обработкой токена/401/ошибок.
- `src/services/api/` — функции по ресурсам arm-core (consolidations, files, documents, osv, directories/…).
- `src/utils/` — чистые функции без React/store/API: деньги (`money.ts`, суммы — строки), подписи статусов/типов/периодов
(`labels.ts`), скачивание и имя файла (`download.ts`).
(`labels.ts`), скачивание и имя файла (`download.ts`), правила экранов консолидаций (`consolidations.ts`: статусы,
ручные переходы, блокировка).
- `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` и

View File

@ -1,6 +1,6 @@
import { Settings2 } from 'lucide-react';
import { Button, Checkbox, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { Settings2 } from 'lucide-react';
import { type ColumnDef, isGroup } from './tableTypes';
interface Props<T> {

View File

@ -1,7 +1,7 @@
import { Filter } from 'lucide-react';
import { Table } from 'antd';
import type { TableProps } from 'antd';
import type { ColumnGroupType, ColumnType, ColumnsType } from 'antd/es/table';
import { Filter } from 'lucide-react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { CustomFilterDropdown } from './CustomFilterDropdown';
import { Highlight } from './Highlight';

View File

@ -0,0 +1,18 @@
import { useEffect } from 'react';
/**
* Пока `dirty`, браузер спрашивает подтверждение при закрытии или перезагрузке вкладки (стандартный диалог браузера,
* текст задаёт браузер). Переход по меню приложения при открытой модалке закрыт её маской.
*/
export function useBeforeUnload(dirty: boolean) {
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// старые Chrome/Яндекс Браузер показывают диалог только при непустом returnValue
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
}

View File

@ -1,8 +1,9 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, InputNumber, Select, Space, Tooltip, message } from 'antd';
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';
@ -11,18 +12,18 @@ 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 { consStatusDescriptionMapping, statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
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 { useCurrentUser } from '../auth/MeContext';
import { PRECISIONS, UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
@ -63,9 +64,12 @@ function OsvContent({ versionId }: { versionId: string }) {
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const description = card ? (consStatusDescriptionMapping[card.status] ?? russianStatus) : '';
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 (
@ -91,28 +95,22 @@ function OsvContent({ versionId }: { versionId: string }) {
text={russianStatus}
{...preset}
/>
{card.lock === null || (card.lock && card.lock.locked_by !== currentUser.id) ? (
<Tooltip title='Сначала возьмите версию в работу'>
<Button size='middle' style={{ fontSize: 12 }} type='default' disabled>
Изменить статус
</Button>
</Tooltip>
) : (
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
<Button
size='middle'
onClick={() => {
setModalOpen(true);
}}
style={{ fontSize: 12 }}
type='default'>
type='default'
disabled={!statusChangeAllowed}
onClick={() => setModalOpen(true)}>
Изменить статус
</Button>
)}
</Tooltip>
</Space>
)
}
/>
<OsvTabs />
<OsvTabs unmappedTotal={unmapped} />
<main
style={{
flex: '1 1 auto',
@ -134,7 +132,7 @@ function OsvContent({ versionId }: { versionId: string }) {
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select
variant='underlined'
options={[0, 1, 2, 3].map((i) => ({ label: i, value: i }))}
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision}
onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }}
@ -196,14 +194,7 @@ function OsvContent({ versionId }: { versionId: string }) {
onMapped={osv.refresh}
/>
)}
<ChangeStatusModal
open={statusModalOpen}
versionId={versionId}
onSuccess={setCard}
setClose={() => {
setModalOpen(false);
}}
/>
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
</div>
);
}

View File

@ -5,16 +5,14 @@ import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { fetchOneCompanyGroup } from '../services/api/directories/company_groups';
import { setSelectedVersion } from '../store/slices/consolidations/consolidationsSlice';
import { getConsolidationsListThunk } from '../store/slices/consolidations/thunks';
import { useAppDispatch } from '../store/store';
import { ConsoleLegalEntitiesTable } from '../widgets/ConsoleLegalEntities/ConsoleLegalEntities';
import { ConsoleTableWidget } from '../widgets/ConsoleTable/ConsoleTable';
import { DocumentsDrawer } from '../widgets/FilesDrawer/FilesDrawer';
// unwrap() бросает rejectValue (строку), fetch группы — ApiError
// список консолидаций грузит ConsoleTableWidget (с фильтром «Только в работе»), здесь — только группа
function errorText(e: unknown): string {
if (e instanceof ApiError) return e.message;
if (typeof e === 'string') return e;
return 'Не удалось загрузить группу компаний';
}
@ -43,7 +41,7 @@ export function ConsolidationPage() {
}
}
getAllInfo(group_id);
}, [dispatch, group_id]);
}, [group_id]);
// выбор версии следует за ?version= и не перезагружает список
useEffect(() => {
@ -85,7 +83,8 @@ export function ConsolidationPage() {
flexDirection: 'column',
minHeight: 0,
}}>
<ConsoleTableWidget />
{/* после «Взять в работу» перечитываем и панель «Организации»: в ней «Ответственный» */}
<ConsoleTableWidget onVersionLocked={() => setReloadKey((k) => k + 1)} />
</div>
<div
style={{

View File

@ -4,20 +4,17 @@ import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members';
/** Список консолидаций ГК; `in_work_only` — серверный фильтр «Только в работе» (по умолчанию false). */
export const getConsolidations = (groupId: string, in_work_only: boolean) =>
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations?in_work_only=${in_work_only}`);
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`, { in_work_only });
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, reason?: string | null) => {
const body = {};
if (reason) {
body['takeover_reason'] = reason;
}
return api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, body);
};
export const lockVersion = (versionId: string, reason?: string | null) =>
api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, reason ? { takeover_reason: reason } : {});
/** Ручная смена статуса (гайд §5.7): нужна своя блокировка; ответ — карточка версии с новым статусом. */
export const updateStatus = (versionId: string, body: VersionStatusUpdate) =>
api.post<VersionCard>(`/consolidation/versions/${versionId}/status`, body);
api.post<VersionCard>(`/consolidations/versions/${versionId}/status`, body);

View File

@ -1,6 +1,6 @@
// DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки.
import { ConsStatus } from './consolidations/common_types';
import type { ConsStatus } from './consolidations/common_types';
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER';

View File

@ -21,7 +21,7 @@ export type ConsRussianStatus =
| 'Корректировка данных'
| 'Консолидация ГО'
| 'Консолидация РФ'
| 'Отменён'
| 'Отменена'
| 'Корректировка'
| 'В архиве'
| 'Отклонён';

View File

@ -33,7 +33,10 @@ export interface ConsRow {
reporting_date: Date | null;
version_no: number;
/** Подпись статуса — для колонки и её фильтра. */
status: ConsRussianStatus;
/** Код статуса — для правил (подпись меняется, код — нет). */
status_code: ConsStatus;
scenario: Scenario;
approach: Approach;
lock: LockOut | null;

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import type { ConsStatus, LockOut } from '../types/consolidations/common_types';
import {
CONS_STATUS_LABEL,
MANUAL_TRANSITIONS,
canChangeStatus,
canSubmitStatusChange,
canTakeInWork,
consStatusDescription,
endSentence,
isLockedBy,
isValidReason,
manualTransitions,
needsUnmappedWarning,
parseInWorkOnly,
versionLabel,
} from './consolidations';
const lock = (userId: string): LockOut => ({
locked_by: userId,
locked_by_name: 'Соколов А. В.',
locked_at: '2026-10-23T10:00:00Z',
taken_over_from: null,
takeover_reason: null,
});
const ALL: ConsStatus[] = Object.keys(CONS_STATUS_LABEL) as ConsStatus[];
describe('versionLabel', () => {
it('первая версия — пусто, последующие — номер', () => {
expect(versionLabel(1)).toBe('');
expect(versionLabel(2)).toBe('2');
expect(versionLabel(10)).toBe('10');
});
});
describe('parseInWorkOnly', () => {
it('по умолчанию выключен, включает только явное true', () => {
expect(parseInWorkOnly(null)).toBe(false);
expect(parseInWorkOnly('')).toBe(false);
expect(parseInWorkOnly('false')).toBe(false);
expect(parseInWorkOnly('true')).toBe(true);
});
});
describe('подписи и пояснения статусов', () => {
it('подписи из спеки', () => {
expect(CONS_STATUS_LABEL.DATA_CORRECTION).toBe('Корректировка данных');
expect(CONS_STATUS_LABEL.CANCELLED).toBe('Отменена');
});
it('пояснение не повторяет подпись', () => {
for (const s of ALL) {
expect(consStatusDescription(s)).not.toBe(CONS_STATUS_LABEL[s]);
}
});
it('пояснения статусов первой очереди и макета заданы', () => {
for (const s of ['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFIED'] as const) {
expect(consStatusDescription(s)).not.toBe('');
}
expect(consStatusDescription('CONSOLIDATION_RF')).toBe('Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка');
expect(consStatusDescription('VERIFIED')).toBe('Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ');
});
});
describe('ручные переходы (зеркало MANUAL_TRANSITIONS бэка)', () => {
it('совпадают с api/src/services/consolidation_status.py', () => {
expect(MANUAL_TRANSITIONS).toEqual({
INITIATED: ['CANCELLED'],
DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'],
DATA_CORRECTION: ['CANCELLED', 'INITIATED'],
});
});
it('из остальных статусов переходов нет', () => {
for (const s of ['VERIFIED', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFICATION_RF', 'CONSOLIDATION_GO', 'VERIFICATION_GO'] as const) {
expect(manualTransitions(s)).toEqual([]);
}
});
});
describe('блокировка и «Изменить статус»', () => {
it('своя блокировка', () => {
expect(isLockedBy(lock('me'), 'me')).toBe(true);
expect(isLockedBy(lock('other'), 'me')).toBe(false);
expect(isLockedBy(null, 'me')).toBe(false);
expect(isLockedBy(undefined, 'me')).toBe(false);
});
it('кнопка: своя / чужая / нет блокировки × есть / нет переходов', () => {
expect(canChangeStatus('DATA_CORRECTION', lock('me'), 'me')).toBe(true);
expect(canChangeStatus('DATA_CORRECTION', lock('other'), 'me')).toBe(false);
expect(canChangeStatus('DATA_CORRECTION', null, 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', lock('me'), 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', lock('other'), 'me')).toBe(false);
expect(canChangeStatus('VERIFIED', null, 'me')).toBe(false);
});
it('«Взять в работу» недоступна только у отменённой версии', () => {
expect(canTakeInWork('CANCELLED')).toBe(false);
expect(canTakeInWork('INITIATED')).toBe(true);
expect(canTakeInWork('VERIFIED')).toBe(true);
});
});
describe('причина и комментарий', () => {
it('пустая и из пробелов — неверна', () => {
expect(isValidReason('')).toBe(false);
expect(isValidReason(' ')).toBe(false);
expect(isValidReason(undefined)).toBe(false);
expect(isValidReason(null)).toBe(false);
});
it('ровно 1000 символов можно, 1001 — нет', () => {
expect(isValidReason('a'.repeat(1000))).toBe(true);
expect(isValidReason('a'.repeat(1001))).toBe(false);
expect(isValidReason(` ${'a'.repeat(1000)} `)).toBe(true);
});
it('модалка: нужен выбранный этап и непустой комментарий', () => {
expect(canSubmitStatusChange(undefined, 'Дубль')).toBe(false);
expect(canSubmitStatusChange('CANCELLED', undefined)).toBe(false);
expect(canSubmitStatusChange('CANCELLED', ' ')).toBe(false);
expect(canSubmitStatusChange('CANCELLED', 'Дубль')).toBe(true);
});
});
describe('endSentence', () => {
it('не ставит вторую точку после инициалов', () => {
expect(endSentence('Соколов А. В.')).toBe('Соколов А. В.');
expect(endSentence('Dev: Аналитик ГО')).toBe('Dev: Аналитик ГО.');
});
});
describe('модалка [31-00]', () => {
it('только при несопоставленных строках', () => {
expect(needsUnmappedWarning(2)).toBe(true);
expect(needsUnmappedWarning(0)).toBe(false);
expect(needsUnmappedWarning(undefined)).toBe(false);
});
});

View File

@ -0,0 +1,93 @@
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */
export const CONS_STATUS_LABEL: Record<ConsStatus, ConsRussianStatus> = {
INITIATED: 'Инициация',
DOCUMENTS_PROCESSING: 'Обработка документов',
DATA_CORRECTION: 'Корректировка данных',
CONSOLIDATION_RF: 'Консолидация РФ',
VERIFICATION_RF: 'Верификация РФ',
CONSOLIDATION_GO: 'Консолидация ГО',
VERIFICATION_GO: 'Верификация ГО',
VERIFIED: 'Верифицирован',
CANCELLED: 'Отменена',
ADJUSTMENT: 'Корректировка',
ARCHIVED: 'В архиве',
REJECTED: 'Отклонён',
};
/**
* Пояснение статуса под заголовком карточки версии. `CONSOLIDATION_RF`, `VERIFIED` — тексты макета [30], [33-01];
* `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`, `CANCELLED` — временные из ТЗ «Статусная модель», пока нет
* текстов аналитика (design.md change consolidations-ui, решение (б)).
*/
export const CONS_STATUS_DESCRIPTION: Partial<Record<ConsStatus, string>> = {
INITIATED: 'Система зафиксировала состав ГСК на отчетную дату и инициатора (КА РФ или КА ГО)',
DOCUMENTS_PROCESSING:
'КА загружает документы; система отправляет pdf файлы в ИС Сканирование, производит парсинг ОСВ, бухгалтерского баланса и ОФР',
DATA_CORRECTION:
'Выявлены некорректные данные в ИС Сканирование, проводится ручная корректировка. Пользователь валидирует распарсенные данные и вносит правки',
CONSOLIDATION_RF: 'Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка',
CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО',
VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ',
VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО',
VERIFIED: 'Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ',
CANCELLED: 'Заявка на формирование/подтверждение КФО аннулирована по инициативе пользователя',
};
/** Пояснение статуса; нет текста — пусто (название статуса под заголовком не повторяем). */
export const consStatusDescription = (status: ConsStatus): string => CONS_STATUS_DESCRIPTION[status] ?? '';
/**
* Ручные переходы статуса версии — зеркало `MANUAL_TRANSITIONS` из `api/src/services/consolidation_status.py`
* (API допустимые переходы не отдаёт). Расхождение с бэком сервер ловит 409 `TRANSITION_NOT_ALLOWED`. С маршрутом
* согласования (Срез 7) заменяется ответом API.
*/
export const MANUAL_TRANSITIONS: Partial<Record<ConsStatus, readonly ConsStatus[]>> = {
INITIATED: ['CANCELLED'],
DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'],
DATA_CORRECTION: ['CANCELLED', 'INITIATED'],
};
/** Допустимые ручные переходы из статуса; нет — пустой список. */
export const manualTransitions = (status: ConsStatus): readonly ConsStatus[] => MANUAL_TRANSITIONS[status] ?? [];
/** Версию держит текущий пользователь. */
export const isLockedBy = (lock: LockOut | null | undefined, userId: string): boolean => !!lock && lock.locked_by === userId;
/** Кнопка «Изменить статус» в шапке карточки: своя блокировка и есть ручные переходы. */
export const canChangeStatus = (status: ConsStatus, lock: LockOut | null | undefined, userId: string): boolean =>
isLockedBy(lock, userId) && manualTransitions(status).length > 0;
/** Лимит причины перехвата и комментария к смене статуса (`MAX_TAKEOVER_REASON`, `MAX_STATUS_COMMENT` бэка). */
export const MAX_REASON_LENGTH = 1000;
/** Причина перехвата / комментарий: непусто после обрезки пробелов и не длиннее лимита. */
export const isValidReason = (text: string | null | undefined): boolean => {
const trimmed = (text ?? '').trim();
return trimmed.length > 0 && trimmed.length <= MAX_REASON_LENGTH;
};
/** «Изменить статус» в модалке: выбран новый этап и комментарий непуст. */
export const canSubmitStatusChange = (toStatus: ConsStatus | null | undefined, comment: string | null | undefined): boolean =>
!!toStatus && isValidReason(comment);
/** Колонка «Версия»: у первой версии пусто, у последующих — номер. */
export const versionLabel = (versionNo: number): string => (versionNo === 1 ? '' : String(versionNo));
/** Флажок «Только в работе» из `?in_work_only=`: включён только явным `true` (по умолчанию выключен, как в API). */
export const parseInWorkOnly = (param: string | null): boolean => param === 'true';
/** «Взять в работу» недоступна у отменённой версии — по коду статуса, а не по подписи. */
export const canTakeInWork = (status: ConsStatus): boolean => status !== 'CANCELLED';
/** Точка в конце фразы после ФИО: «Соколов А. В.» уже заканчивается точкой — вторую не ставим. */
export const endSentence = (text: string): string => (text.endsWith('.') ? text : `${text}.`);
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
export const MIN_PERIMETER = 2;
/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */
export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0;

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatAmount, parseAmountInput, sumAmounts } from './money';
import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money';
const NBSP = ' ';
@ -75,3 +75,43 @@ describe('sumAmounts', () => {
expect(sumAmounts(['1.234'])).toBeNull();
});
});
describe('formatScaledAmount', () => {
const rub = (precision: number) => ({ scale: 0, precision, zeroDash: true });
const thousands = (precision: number) => ({ scale: 3, precision, zeroDash: true });
it('рубли с двумя знаками — как formatAmount', () => {
expect(formatScaledAmount('4454151.93', rub(2))).toBe(`4${NBSP}454${NBSP}151,93`);
expect(formatScaledAmount('-8213.3', rub(2))).toBe(`-8${NBSP}213,30`);
});
it('сдвиг запятой без float', () => {
expect(formatScaledAmount('4454151.93', thousands(2))).toBe(`4${NBSP}454,15`);
expect(formatScaledAmount('128058143.65', { scale: 6, precision: 3 })).toBe('128,058');
expect(formatScaledAmount('2555438965.10', { scale: 9, precision: 2 })).toBe('2,56');
// float: 9007199254740993.01 / 1000 теряет разряды, строка — нет
expect(formatScaledAmount('9007199254740993.01', thousands(2))).toBe(`9${NBSP}007${NBSP}199${NBSP}254${NBSP}740,99`);
});
it('округление — половина от нуля', () => {
expect(formatScaledAmount('1234.5', thousands(0))).toBe('1');
expect(formatScaledAmount('1500', thousands(0))).toBe('2');
expect(formatScaledAmount('-1500', thousands(0))).toBe('-2');
expect(formatScaledAmount('0.05', rub(1))).toBe('0,1');
expect(formatScaledAmount('2.345', rub(2))).toBe('2,35');
});
it('больше знаков, чем в сумме, — дополняется нулями', () => {
expect(formatScaledAmount('12.5', rub(3))).toBe('12,500');
expect(formatScaledAmount('100', rub(0))).toBe('100');
});
it('ноль — прочерк по исходной сумме; округлённый до нуля — без минуса', () => {
expect(formatScaledAmount('0.00', thousands(2))).toBe('—');
expect(formatScaledAmount('0', rub(2))).toBe('—');
expect(formatScaledAmount('0.00', { scale: 0, precision: 2 })).toBe('0,00');
expect(formatScaledAmount('0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00');
expect(formatScaledAmount('-0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00');
});
it('пустое — прочерк, нераспознанное — как есть', () => {
expect(formatScaledAmount(null, rub(2))).toBe('—');
expect(formatScaledAmount('', rub(2))).toBe('—');
expect(formatScaledAmount('abc', rub(2))).toBe('abc');
expect(formatScaledAmount('1e5', rub(2))).toBe('1e5');
});
});

View File

@ -38,6 +38,54 @@ export function formatAmount(value: string | null | undefined, format: AmountFor
return `${isZero ? '' : sign}${grouped},${cents}`;
}
export type ScaledFormat = {
/** Степень десяти единиц показа: 0 — руб., 3 — тыс. руб., 6 — млн руб., 9 — млрд руб. */
scale: number;
/** Знаков после запятой (0…3). */
precision: number;
/** Ноль показывать прочерком (по исходной сумме: 0,40 руб. в млн — «0,00», а не «—»). */
zeroDash?: boolean;
};
/**
* Сумма из API (строка Decimal) в единицах 10^scale с `precision` знаками → «1 234,57». Считается на строке и BigInt:
* запятая сдвигается на `scale` разрядов, округление — половина от нуля; float не используется. Пустое — «—»,
* нераспознанное — как есть.
*/
export function formatScaledAmount(value: string | null | undefined, format: ScaledFormat): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const match = PLAIN.exec(value);
if (!match) {
return value;
}
const [, sign, intPart, frac = ''] = match;
const isZero = /^0+$/.test(intPart) && /^0*$/.test(frac);
if (isZero && format.zeroDash) {
return '—';
}
const precision = Math.max(0, Math.trunc(format.precision));
// число = digits / 10^places
const digits = BigInt(intPart + frac);
const places = frac.length + Math.max(0, Math.trunc(format.scale));
let scaled: bigint;
if (places > precision) {
const divisor = 10n ** BigInt(places - precision);
scaled = digits / divisor;
if ((digits % divisor) * 2n >= divisor) scaled += 1n;
} else {
scaled = digits * 10n ** BigInt(precision - places);
}
const unit = 10n ** BigInt(precision);
const grouped = (scaled / unit).toString().replace(/\B(?=(\d{3})+(?!\d))/g, NBSP);
const minus = sign && scaled !== 0n ? '-' : '';
if (precision === 0) {
return `${minus}${grouped}`;
}
return `${minus}${grouped},${(scaled % unit).toString().padStart(precision, '0')}`;
}
/** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */
export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } {
const normalized = input.replace(/[\s ]/g, '').replace(',', '.');

View File

@ -1,85 +1,63 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Space, Tooltip, message } from 'antd';
import { Button, Checkbox, Dropdown, type MenuProps, Space, Tooltip, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRight, Briefcase, GitBranchPlus, History, MoreHorizontal, ToggleLeft } from 'lucide-react';
import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useCurrentUser } from '../../auth/MeContext';
import { ColumnSettings, DataTable, type TableConfig, isGroup, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { ConsRow } from '../../types/consolidations/consolidations';
import { canTakeInWork, isLockedBy, parseInWorkOnly } from '../../utils/consolidations';
import LockModal from './LockModal';
import { consConfigBase } from './config';
import { flattenCons } from './flatten';
import { groupAdjacent } from './groupAdjacent';
import { useGrouping } from './useGrouping';
import { ArrowRight, Briefcase, GitBranchPlus, History, MoreHorizontal, ToggleLeft } from 'lucide-react';
import { useCurrentUser } from '../../auth/MeContext';
import LockModal from './LockModal';
import { LockOut } from '../../types/consolidations/common_types';
import { Me } from '../../api/types';
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
type ActionKey = 'copy' | 'disable_confirm' | 'history';
const ITEMS = [
{
key: 'copy',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>Добавить версию</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <GitBranchPlus size={14} />,
},
{
key: 'disable_confirm',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>Отключить подтверждение КФО</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <ToggleLeft size={14} />,
},
{
key: 'history',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>История изменений</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <History size={14} />,
},
{},
] as const;
const SOON = 'Будет позже';
const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; onAction: (key: ActionKey, _row: ConsRow) => void }) {
// пункт неактивен (disabled — клик не обрабатывается), подсказка — на подписи
const soonItem = (key: string, label: string, icon: ReactNode) => ({
key,
disabled: true,
icon,
label: (
<Tooltip title={SOON}>
<span style={{ display: 'block', width: '100%' }}>{label}</span>
</Tooltip>
),
});
// функций ещё нет: «История изменений» — после журнала с фильтрами [43-02], остальное — зонтичная задача экранов консолидаций
const ITEMS: MenuProps['items'] = [
soonItem('copy', 'Добавить версию', <GitBranchPlus size={14} />),
soonItem('disable_confirm', 'Отключить подтверждение КФО', <ToggleLeft size={14} />),
soonItem('history', 'История изменений', <History size={14} />),
];
const RowActions = memo(function RowActions() {
return (
<Dropdown
trigger={['click']}
menu={{
items: ITEMS as any,
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
onAction(key as ActionKey, row);
},
}}>
<Button icon={<MoreHorizontal size={14} />} onClick={(e) => e.stopPropagation()} />
</Dropdown>
// меню рендерится в портал, но React-событие всплывает по дереву до onRow строки — гасим здесь
<span onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<Dropdown trigger={['click']} menu={{ items: ITEMS }}>
<Button icon={<MoreHorizontal size={14} />} aria-label='Действия' />
</Dropdown>
</span>
);
});
function isLockedByCurrentUser(currentUser: Me, lockData: LockOut | null) {
if (lockData === null) return false;
if (lockData.locked_by !== currentUser.id) return false;
return true;
}
export function ConsoleTableWidget() {
/** `onVersionLocked` — версию взяли в работу: страница перечитывает панель «Организации» («Ответственный»). */
export function ConsoleTableWidget({ onVersionLocked }: { onVersionLocked?: () => void }) {
const navigate = useNavigate();
const { group_id } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const consolidations = useAppSelector((s) => s.consolidations.consolidations);
const loading = useAppSelector((s) => s.consolidations.loading);
const inWorkOnly = searchParams.get('in_work_only') || true;
// «Только в работе» по умолчанию выключен (макет [34], умолчание API — false)
const inWorkOnly = parseInWorkOnly(searchParams.get('in_work_only'));
const currentUser = useCurrentUser();
const dispatch = useAppDispatch();
@ -96,11 +74,12 @@ export function ConsoleTableWidget() {
const { visibleRows, expanded, toggle } = useGrouping(grouped);
const loadConsolidations = useCallback(async () => {
if (!group_id) return;
let in_work_only = true;
if (inWorkOnly === 'false') {
in_work_only = false;
try {
await dispatch(getConsolidationsListThunk({ company_group_id: group_id, in_work_only: inWorkOnly })).unwrap();
} catch (e: unknown) {
// unwrap() бросает rejectValue — текст ошибки сервера
message.error(typeof e === 'string' ? e : 'Не удалось загрузить консолидации');
}
await dispatch(getConsolidationsListThunk({ company_group_id: group_id, in_work_only })).unwrap();
}, [dispatch, group_id, inWorkOnly]);
useEffect(() => {
loadConsolidations();
@ -152,16 +131,6 @@ export function ConsoleTableWidget() {
const { visibleKeys, setVisibleKeys } = useVisibleColumns(config);
const handleDropdownAction = useCallback(
(key: ActionKey, _row: ConsRow) => {
if (key === 'history') {
navigate('/logging');
} else {
message.info('Будет позже');
}
},
[navigate],
);
const extraColumns: ColumnsType<ConsRow> = useMemo(
() => [
{
@ -170,7 +139,7 @@ export function ConsoleTableWidget() {
width: 120,
render: (_: unknown, row: ConsRow) => (
<div style={{ display: 'flex', gap: 4 }}>
{isLockedByCurrentUser(currentUser, row.lock) ? (
{isLockedBy(row.lock, currentUser.id) ? (
<Button disabled icon={<Briefcase size={16} />} type='default'>
В работе
</Button>
@ -179,7 +148,7 @@ export function ConsoleTableWidget() {
iconPlacement='start'
icon={<Briefcase size={16} />}
type='default'
disabled={row.status === 'Отменён'}
disabled={!canTakeInWork(row.status_code)}
onClick={(e) => {
e.stopPropagation();
setLockVersionId(row.id);
@ -198,13 +167,17 @@ export function ConsoleTableWidget() {
}}>
К отчётности
</Button>
<RowActions row={row} onAction={handleDropdownAction} />
<RowActions />
</div>
),
},
],
[navigate, group_id, handleDropdownAction, currentUser],
[navigate, group_id, currentUser],
);
const afterLock = useCallback(() => {
loadConsolidations();
onVersionLocked?.();
}, [loadConsolidations, onVersionLocked]);
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({
// выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад»
@ -252,7 +225,7 @@ export function ConsoleTableWidget() {
</Button>
)}
<Checkbox
checked={searchParams.get('in_work_only') === 'false' ? false : true}
checked={inWorkOnly}
onChange={(e) => {
setSearchParams(
(searchParams) => {
@ -284,7 +257,7 @@ export function ConsoleTableWidget() {
open={lockModalOpen}
versionId={lockVersionId}
onOpenChange={setLockModalOpen}
handleReload={loadConsolidations}
handleReload={afterLock}
currentUserId={currentUser.id}
/>
</div>

View File

@ -1,8 +1,11 @@
import { Alert, Button, Input, Modal, Skeleton, message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LockOut } from '../../types/consolidations/common_types';
import { Alert, Button, Input, message, Modal, Skeleton } from 'antd';
import { ApiError } from '../../api/client';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { getVersionCard, lockVersion } from '../../services/api/consolidations';
import type { LockOut } from '../../types/consolidations/common_types';
import { MAX_REASON_LENGTH, endSentence, isValidReason } from '../../utils/consolidations';
import { quarterShortLabel } from '../../utils/labels';
type LockModalProps = {
versionId: string | null;
@ -11,12 +14,8 @@ type LockModalProps = {
currentUserId: string;
handleReload: () => void;
};
function isValidReason(reason: string) {
const trimmed = reason.trim();
if (trimmed.length > 0 && trimmed.length < 1000) return true;
return false;
}
/** «Взять консолидацию в работу?» [35]: свободная версия — подтверждение, чужая — перехват с причиной. */
export default function LockModal({ versionId, open, onOpenChange, currentUserId, handleReload }: LockModalProps) {
const [data, setData] = useState<LockOut | null>(null);
const [consolidationName, setConsolidationName] = useState<string | null>(null);
@ -27,11 +26,14 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
const [conflict, setConflict] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Stale-response guard for fast open/switch/close sequences.
// Защита от устаревшего ответа при быстром открытии/смене версии/закрытии.
const reqId = useRef(0);
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
// введённая причина перехвата — несохранённая правка: браузер предупредит при закрытии вкладки
useBeforeUnload(open && reason.trim() !== '');
const load = useCallback(async () => {
if (!versionId) return;
const my = ++reqId.current;
@ -39,16 +41,14 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
setLoadError(false);
try {
const currentCard = await getVersionCard(versionId);
const lockData = currentCard.lock;
const { company_group, year, quarter } = currentCard;
const consName = `${year} ${quarter} · ${company_group.name}`;
if (my !== reqId.current) return; // a newer request won
setData(lockData);
setConsolidationName(consName);
if (my !== reqId.current) return; // успел прийти ответ на более новый запрос
setData(currentCard.lock);
setConsolidationName(`${year} ${quarterShortLabel(quarter)} · ${company_group.name}`);
} catch (e) {
if (my !== reqId.current) return;
if (e instanceof ApiError && e.status === 404) {
message.warning('Версия консолидации не обнаржуена');
message.warning('Версия консолидации не обнаружена');
close();
return;
}
@ -58,46 +58,47 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
}
}, [versionId, close]);
// Reset + fetch on every open, and on target change while open.
// Сброс и загрузка при каждом открытии и при смене версии в открытой модалке (load меняется вместе с versionId).
useEffect(() => {
if (!open) return;
const requests = reqId; // счётчик запросов, а не DOM-узел: в cleanup нужен его актуальный .current
setReason('');
setConflict(false);
setData(null);
setLoadError(false);
load();
return () => {
reqId.current++; // invalidate in-flight response on close/switch
requests.current++; // ответ в полёте больше не нужен
};
}, [open, versionId, load]);
}, [open, load]);
const hasToReason = !loading && data !== null && data.locked_by !== currentUserId;
const handleOk = async () => {
if (!versionId || loading || submitting) return;
if (hasToReason && !reason.trim()) return;
if (hasToReason && !isValidReason(reason)) return;
setSubmitting(true);
try {
const result = await lockVersion(versionId, hasToReason ? reason.trim() : null);
await lockVersion(versionId, hasToReason ? reason.trim() : null);
close();
message.success('Консолидация взята в работу.');
handleReload();
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
if (e.code === 'VERSION_NOT_EDITABLE') {
message.error('Нельзя взять в работу отменённую версию.');
close();
} else if (e.code === 'LOCKED_BY_OTHER' && !conflict) {
await load();
setConflict(true);
setReason('');
}
if (e instanceof ApiError && e.code === 'VERSION_NOT_EDITABLE') {
message.error('Нельзя взять в работу отменённую версию.');
close();
handleReload();
} else if (e instanceof ApiError && e.code === 'LOCKED_BY_OTHER' && !conflict) {
// версию взяли, пока модалка была открыта, — режим перехвата
await load();
setConflict(true);
setReason('');
} else {
message.error('Что-то пошло не так. Попробуйте снова.');
// 403 без права, прочие 409/422 — текст сервера
message.error(e instanceof ApiError ? e.message : 'Не удалось взять консолидацию в работу');
}
} finally {
setSubmitting(false);
handleReload();
}
};
@ -110,6 +111,7 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
closable={!submitting}
okText='Взять в работу'
okButtonProps={{ disabled: loading || loadError || (hasToReason && !isValidReason(reason)) }}
confirmLoading={submitting}
cancelText='Отмена'
onOk={handleOk}
cancelButtonProps={{ disabled: submitting }}>
@ -119,10 +121,10 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
<Alert
type='error'
showIcon
title='Не получилось загрузить нформацию о текущей блокировке'
title='Не удалось загрузить информацию о текущей блокировке'
action={
<Button size='small' onClick={load}>
Попробовать перезагрузить
Повторить
</Button>
}
/>
@ -141,12 +143,13 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
{hasToReason && data ? (
<>
<p style={{ marginTop: 0 }}>Консолидация взята в работу {data.locked_by_name}. Укажите причину замены.</p>
<p style={{ marginTop: 0 }}>Консолидация взята в работу {endSentence(data.locked_by_name)} Укажите причину замены.</p>
<Input.TextArea
autoFocus
placeholder='Type message'
placeholder='Укажите причину замены'
value={reason}
maxLength={1000}
maxLength={MAX_REASON_LENGTH}
showCount
rows={3}
onChange={(e) => setReason(e.target.value)}
/>

View File

@ -2,6 +2,7 @@ import type { TableConfig } from '../../components/GenericTable';
import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsRussianStatus, QuarterEnum } from '../../types/consolidations/common_types';
import type { ConsRow } from '../../types/consolidations/consolidations';
import { versionLabel } from '../../utils/consolidations';
import { formatMoscowDate } from '../../utils/dates';
import { quarterShortLabel } from '../../utils/labels';
import { statusColorMapping } from './consts';
@ -15,7 +16,7 @@ export const consConfigBase: TableConfig<ConsRow> = {
{ key: 'year', title: 'Год', features: { sort: true, multiselect: true } },
{ key: 'quarter', title: 'Квартал', features: { multiselect: true }, render: (v) => quarterShortLabel(v as QuarterEnum) },
{ key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => (String(v).trim() !== '1' ? '' : v) },
{ key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => versionLabel(Number(v)) },
{
key: 'status',
title: 'Статус',

View File

@ -1,21 +1,7 @@
import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ConsRussianStatus, ConsStatus } from '../../types/consolidations/common_types';
export const statusMapping: Record<ConsStatus, ConsRussianStatus> = {
VERIFIED: 'Верифицирован',
VERIFICATION_GO: 'Верификация ГО',
VERIFICATION_RF: 'Верификация РФ',
INITIATED: 'Инициация',
DOCUMENTS_PROCESSING: 'Обработка документов',
DATA_CORRECTION: 'Корректировка данных',
CONSOLIDATION_GO: 'Консолидация ГО',
CONSOLIDATION_RF: 'Консолидация РФ',
CANCELLED: 'Отменён',
ADJUSTMENT: 'Корректировка',
ARCHIVED: 'В архиве',
REJECTED: 'Отклонён',
};
import type { ConsRussianStatus } from '../../types/consolidations/common_types';
// Подписи и пояснения статусов — `utils/consolidations.ts` (CONS_STATUS_LABEL, CONS_STATUS_DESCRIPTION); здесь — цвета бейджей.
export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
'В архиве': TAG_PRESETS.default,
'Верификация ГО': TAG_PRESETS.geekblue,
@ -28,14 +14,5 @@ export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
'Корректировка данных': TAG_PRESETS.gold,
'Обработка документов': TAG_PRESETS.gold,
Отклонён: TAG_PRESETS.red,
Отменён: TAG_PRESETS.red,
};
export const consStatusDescriptionMapping: Partial<Record<ConsStatus, string>> = {
CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ',
CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО',
VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО',
VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ',
CANCELLED: 'Формирование КФО отменено',
VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ',
Отменена: TAG_PRESETS.red,
};

View File

@ -1,5 +1,5 @@
import type { ConsRow, IConsolidation } from '../../types/consolidations/consolidations';
import { statusMapping } from './consts';
import { CONS_STATUS_LABEL } from '../../utils/consolidations';
export function flattenCons(cons: IConsolidation[]): ConsRow[] {
const out: ConsRow[] = [];
@ -18,7 +18,8 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] {
quarter: con.quarter,
reporting_date: rep_date,
version_no: v.version_no,
status: statusMapping[v.status],
status: CONS_STATUS_LABEL[v.status],
status_code: v.status,
scenario: v.scenario,
approach: v.approach,
lock: v.lock,

View File

@ -1,26 +1,106 @@
import { Segmented, Tooltip } from 'antd';
import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useState } from 'react';
import { needsUnmappedWarning } from '../../utils/consolidations';
const SOON = 'Будет в следующих срезах';
const OSV = 'ОСВ';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */
export function OsvTabs() {
type Props = {
/** `unmapped_total` сводки: больше нуля — при уходе с «ОСВ» показывается модалка [31-00]. */
unmappedTotal: number;
/** Переход на вкладку шага. Пока шагов нет — вкладки неактивны, переход не задан (шахматка — PR #158). */
onNavigate?: (tab: string) => void;
/** Вкладки, по которым уже можно перейти (остальные — «Будет в следующих срезах»). */
enabledTabs?: readonly string[];
};
/** Ряд вкладок шагов консолидации (макет [30]): «ОСВ» — текущая; уход с неё при несопоставленных строках — через [31-00]. */
export function OsvTabs({ unmappedTotal, onNavigate, enabledTabs = [] }: Props) {
const [pending, setPending] = useState<string | null>(null);
const select = (tab: string) => {
if (tab === OSV || !onNavigate) return;
if (needsUnmappedWarning(unmappedTotal)) {
setPending(tab);
} else {
onNavigate(tab);
}
};
return (
<Segmented
value='ОСВ'
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
options={[
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
...TABS.map((t) => ({
value: t,
disabled: true,
label: (
<Tooltip title={SOON}>
<span>{t}</span>
</Tooltip>
),
})),
]}
/>
<>
<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>
),
};
}),
]}
/>
<UnmappedInnModal
open={pending !== null}
unmappedTotal={unmappedTotal}
onStay={() => setPending(null)}
onGo={() => {
const tab = pending;
setPending(null);
if (tab && onNavigate) onNavigate(tab);
}}
/>
</>
);
}
/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */
export function UnmappedInnModal({
open,
unmappedTotal,
onStay,
onGo,
}: {
open: boolean;
unmappedTotal: number;
onStay: () => void;
onGo: () => void;
}) {
return (
<Modal
open={open}
title='Остались неуказанные ИНН'
centered
onCancel={onStay}
footer={[
<Button key='stay' onClick={onStay}>
Остаться
</Button>,
<Button key='go' type='primary' onClick={onGo}>
Перейти
</Button>,
]}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* треугольник с «!», как в макете [31-00] */}
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>В ОСВ остались неуказанные ИНН</div>
<div style={{ color: '#667085' }}>Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?</div>
</div>
</div>
</Modal>
);
}

View File

@ -1,42 +1,29 @@
import { useState, useMemo, useCallback } from 'react';
import { useCallback, useState } from 'react';
import { formatScaledAmount } from '../../utils/money';
export type Unit = 'руб.' | 'тыс. руб.' | 'млн. руб.' | 'млрд. руб.';
export type Unit = 'руб.' | 'тыс. руб.' | 'млн руб.' | 'млрд руб.';
export const UNITS: { value: Unit; label: string }[] = [
{ value: 'руб.', label: 'руб.' },
{ value: 'тыс. руб.', label: 'тыс. руб.' },
{ value: 'млн. руб.', label: 'млн. руб.' },
{ value: 'млрд. руб.', label: 'млрд. руб.' },
];
const DIVISORS: Record<Unit, number> = {
'руб.': 1,
'тыс. руб.': 1e3,
'млн. руб.': 1e6,
'млрд. руб.': 1e9,
/** Единицы показа сумм сводной ОСВ и степень десяти (суммы API — в рублях). */
export const UNIT_SCALE: Record<Unit, number> = {
'руб.': 0,
'тыс. руб.': 3,
'млн руб.': 6,
'млрд руб.': 9,
};
export function createFormatter(precision: number): Intl.NumberFormat {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
}
export const UNITS: { value: Unit; label: string }[] = (Object.keys(UNIT_SCALE) as Unit[]).map((u) => ({ value: u, label: u }));
export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string {
if (raw == null || raw.trim() === '') return '—';
const n = Number(raw);
if (!Number.isFinite(n)) return raw;
return nf.format(n / DIVISORS[unit]);
}
export const PRECISIONS = [0, 1, 2, 3] as const;
/** «Единицы» и «Знаков после запятой» сводной ОСВ [30]: только показ, суммы считаются на строках (`utils/money.ts`). */
export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) {
const [unit, setUnit] = useState<Unit>(initial?.unit ?? 'руб.');
const [precision, setPrecision] = useState(initial?.precision ?? 2);
const nf = useMemo(() => createFormatter(precision), [precision]);
const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]);
const format = useCallback(
(raw: string | null) => formatScaledAmount(raw, { scale: UNIT_SCALE[unit], precision, zeroDash: true }),
[unit, precision],
);
return { unit, setUnit, precision, setPrecision, format };
}

View File

@ -1,52 +1,36 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { LegalEntityBrief } from '../../types/api';
import { Checkbox, Empty, Input, Spin } from 'antd';
import { memo, useCallback, useMemo, useState } from 'react';
import type { LegalEntityBrief } from '../../types/api';
type RowProps = {
entity: LegalEntityBrief;
checked: boolean;
onToggle: (id: string) => void;
};
// Вся строка — флажок antd (<label>): клик по наименованию или ИНН переключает, клавиатура — как у флажка.
// Подпись растянута на ширину строки: наименование с многоточием слева, ИНН — колонкой справа.
const CompanyOptionRow = memo(({ entity, checked, onToggle }: RowProps) => (
<div
role='checkbox'
aria-checked={checked}
tabIndex={0}
onClick={() => onToggle(entity.id)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
onToggle(entity.id);
}
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
minWidth: 0,
gap: 12,
padding: '10px 8px',
borderRadius: 4,
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
cursor: 'pointer',
boxSizing: 'border-box',
}}>
<span
style={{
<Checkbox
checked={checked}
onChange={() => onToggle(entity.id)}
styles={{
root: {
display: 'flex',
alignItems: 'center',
gap: 8,
flex: 1,
width: '100%',
minWidth: 0,
overflow: 'hidden',
}}>
<Checkbox checked={checked} tabIndex={-1} style={{ pointerEvents: 'none' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entity.name}</span>
padding: '10px 8px',
borderRadius: 4,
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
boxSizing: 'border-box',
},
label: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flex: 1, minWidth: 0 },
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }} title={entity.name}>
{entity.name}
</span>
<span style={{ color: '#8c8c8c', flexShrink: 0 }}>{entity.inn}</span>
</div>
</Checkbox>
));
type CompanyMultiSelectProps = {

View File

@ -1,14 +1,16 @@
import { PlusOutlined } from '@ant-design/icons';
import { Col, Form, Modal, Row, Select, message } from 'antd';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { fetchAllLegalEntities, fetchRelatedGroup, fetchRelatedGroups } from '../../services/api/directories/company_groups';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { ConsolidationCreate, LegalEntityBrief, Quarter, RelatedGroupOut } from '../../types/api';
import { membersFirst, preselectMembers } from './preselect';
import { PlusOutlined } from '@ant-design/icons';
import { MIN_PERIMETER } from '../../utils/consolidations';
import { CompanyMultiSelect } from './CompanyOptionRow';
import { membersFirst, preselectMembers } from './preselect';
type Props = {
open: boolean;
@ -50,7 +52,10 @@ export const NewConsolidationModal: React.FC<Props> = ({ open, onCancel, onOk })
}
}, [open, raw_rows.length, loading, dispatch]);
const isSubmitDisabled = !company_group_id || !related_group_id || !year || !semester || !legEntIds?.length;
// не меньше двух ЮЛ: иначе сервер ответит 422 PERIMETER_TOO_SMALL
const isSubmitDisabled = !company_group_id || !related_group_id || !year || !semester || legEntIds.length < MIN_PERIMETER;
// начатая форма — несохранённые правки: браузер предупредит при закрытии вкладки
useBeforeUnload(open && !!(company_group_id || related_group_id || year || semester));
const yearOptions = useMemo(() => {
const y = new Date().getFullYear();
@ -196,7 +201,15 @@ export const NewConsolidationModal: React.FC<Props> = ({ open, onCancel, onOk })
{/* ROW 3 - full width block, only after subgroup is selected */}
{related_group_id && (
<Col span={24}>
<Form.Item name='legal_entity_ids' label='ЮЛ консолидации' rules={[{ required: true, message: 'Выберите хотя бы одно ЮЛ' }]}>
<Form.Item
name='legal_entity_ids'
label='ЮЛ консолидации'
rules={[
{
validator: (_, v: string[] | undefined) =>
(v?.length ?? 0) >= MIN_PERIMETER ? Promise.resolve() : Promise.reject(new Error('Выберите не меньше двух ЮЛ')),
},
]}>
<CompanyMultiSelect loading={loadingEntities} companies={legEntities} />
</Form.Item>
</Col>

View File

@ -1,5 +1,6 @@
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Divider, Layout, Tooltip, message } from 'antd';
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { USERS_ADMIN } from '../../auth/permissions';
@ -10,7 +11,6 @@ import { useAppDispatch } from '../../store/store';
import { quarterLabel } from '../../utils/labels';
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
import styles from './styles.module.css';
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
type CollapsedIconProps = {
icon: ReactNode;
@ -205,7 +205,8 @@ export function Sidebar() {
onOk={async (values) => {
const card = await createConsolidation(values);
// перечитываем список: на странице группы он сам не обновится, и новая консолидация не станет текущей
await dispatch(getConsolidationsListThunk(values.company_group_id));
// in_work_only: false — переход ниже открывает список без ?in_work_only (флажок выключен)
await dispatch(getConsolidationsListThunk({ company_group_id: values.company_group_id, in_work_only: false }));
setModalOpen(false);
message.success(`Создана консолидация ${values.year}, ${quarterLabel(values.quarter)}`);
navigate(`/groups/${values.company_group_id}?version=${card.id}`);

View File

@ -7,7 +7,8 @@
.section {
margin-bottom: 0px;
/* padding: 12px; */
/* отступ сверху и снизу, как в макете [34]; по горизонтали пункты выровнены по кнопке «Новая консолидация» */
padding: 12px 0;
border-radius: 10px;
}

View File

@ -1,77 +1,55 @@
import { Form, Input, message, Modal, Select } from 'antd';
import { useEffect, useState } from 'react';
import { ConsStatus } from '../../types/consolidations/common_types';
import { VersionStatusUpdate } from '../../types/api';
import { getVersionCard, updateStatus } from '../../services/api/consolidations';
import { statusColorMapping, statusMapping } from '../ConsoleTable/consts';
import { TableTag } from '../../components/TableTag/TableTag';
import { TAG_PRESETS } from '../../components/TableTag/presets';
import { Form, Input, Modal, Select, message } from 'antd';
import { useState } from 'react';
import { ApiError } from '../../api/client';
import { VersionCard } from '../../types/consolidations/version_members';
import { TableTag } from '../../components/TableTag/TableTag';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { updateStatus } from '../../services/api/consolidations';
import type { VersionStatusUpdate } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types';
import type { VersionCard } from '../../types/consolidations/version_members';
import { CONS_STATUS_LABEL, MAX_REASON_LENGTH, canSubmitStatusChange, manualTransitions } from '../../utils/consolidations';
import { statusColorMapping } from '../ConsoleTable/consts';
type StatusModalProps = {
open: boolean;
versionId: string;
setClose: () => void;
/** Карточка версии со страницы: из неё текущий статус и допустимые переходы. */
card: VersionCard;
onClose: () => void;
/** 200: карточка из ответа `POST …/status` — экран берёт её вместо повторного GET. */
onSuccess: (card: VersionCard) => void;
};
const known409Codes = ['VERSION_NOT_EDITABLE', 'LOCK_REQUIRED', 'LOCKED_BY_OTHER', 'TRANSITION_NOT_ALLOWED'];
const errorCodeMap = {
VERSION_NOT_EDITABLE: 'Версию нельзя отредактировать',
LOCK_REQUIRED: 'Сначала возьмите версию в работу',
LOCKED_BY_OTHER: 'Версия находится в работе у другого пользователя',
TRANSITION_NOT_ALLOWED: 'Данный переход недоступен',
};
async function mockStatusMappingFetch() {
const mapping: Record<ConsStatus, ConsStatus[]> = {
INITIATED: ['CANCELLED'],
DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'],
DATA_CORRECTION: ['CANCELLED', 'INITIATED'],
};
return mapping;
}
export default function ChangeStatusModal({ open, versionId, setClose, onSuccess }: StatusModalProps) {
const [currentStatus, setCurrentStatus] = useState<ConsStatus | null>(null);
const [nextStatusOptions, setNextStatusOptions] = useState<ConsStatus[]>([]);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<VersionStatusUpdate>();
type FormValues = { to_status?: ConsStatus; comment?: string };
/** «Изменить статус» [33-01]: варианты — ручные переходы (`MANUAL_TRANSITIONS`), по умолчанию ничего не выбрано. */
export default function ChangeStatusModal({ open, card, onClose, onSuccess }: StatusModalProps) {
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
// до монтирования формы useWatch отдаёт undefined — правила это учитывают
const nextStatus = Form.useWatch('to_status', form);
const comment = Form.useWatch('comment', form);
useEffect(() => {
async function getAllData() {
if (!versionId) return;
setLoading(true);
const versionCard = await getVersionCard(versionId);
const status = versionCard.status;
const allNextOptions = await mockStatusMappingFetch();
setCurrentStatus(status);
setNextStatusOptions(allNextOptions[status]);
setLoading(false);
}
getAllData();
}, [versionId]);
const russianStatus = currentStatus ? statusMapping[currentStatus] : '';
const badgePreset = russianStatus ? statusColorMapping[russianStatus] : TAG_PRESETS.default;
const isSubmitDisabled = !nextStatus || !comment.trim();
const currentLabel = CONS_STATUS_LABEL[card.status];
const options = manualTransitions(card.status).map((s) => ({ value: s, label: CONS_STATUS_LABEL[s] }));
const canSubmit = canSubmitStatusChange(nextStatus, comment);
useBeforeUnload(open && (!!nextStatus || !!comment?.trim()));
const handleOk = async () => {
const values = await form.validateFields();
if (!nextStatus || !canSubmit || submitting) return;
const body: VersionStatusUpdate = { to_status: nextStatus, comment: (comment ?? '').trim() };
setSubmitting(true);
try {
const newCard = await updateStatus(versionId, { ...values, comment: values.comment.trim() });
const newCard = await updateStatus(card.id, body);
onSuccess(newCard);
setClose();
message.success(`Статус изменён: ${CONS_STATUS_LABEL[newCard.status]}`);
onClose();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code) {
if (known409Codes.includes(e.code)) {
message.error(errorCodeMap[e.code]);
}
} else {
message.error('Непредвиденная ошибка');
}
// 409 VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER / TRANSITION_NOT_ALLOWED, 422 COMMENT_REQUIRED —
// текст сервера; модалка открыта, статус на экране прежний
message.error(e instanceof ApiError ? e.message : 'Не удалось изменить статус');
} finally {
setSubmitting(false);
}
};
@ -81,28 +59,23 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess
title='Изменить статус'
okText='Изменить статус'
cancelText='Отмена'
okButtonProps={{ disabled: isSubmitDisabled || loading }}
loading={loading}
okButtonProps={{ disabled: !canSubmit }}
confirmLoading={submitting}
onOk={handleOk}
onCancel={setClose}
onCancel={() => !submitting && onClose()}
destroyOnHidden>
<div style={{ display: 'flex' }}>
Текущий этап: <TableTag text={russianStatus} {...badgePreset} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<span style={{ color: '#667085' }}>Текущий этап</span>
<TableTag text={currentLabel} {...statusColorMapping[currentLabel]} />
</div>
{nextStatusOptions && (
<Form form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}>
<Form.Item name='to_status' label='Новый этап'>
<Select
options={nextStatusOptions.map((i) => ({ label: i, value: i }))}
labelRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}
optionRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}></Select>
</Form.Item>
<Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'>
<Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea>
</Form.Item>
</Form>
)}
<Form form={form} layout='vertical' requiredMark={false} preserve={false}>
<Form.Item name='to_status' label='Новый этап'>
<Select placeholder='Выберите этап' options={options} />
</Form.Item>
<Form.Item name='comment' label='Комментарий'>
<Input.TextArea rows={3} maxLength={MAX_REASON_LENGTH} placeholder='Укажите комментарий к изменению статуса' />
</Form.Item>
</Form>
</Modal>
);
}