Compare commits
6 Commits
2ef4c6ae86
...
c8a89d4c63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a89d4c63 | ||
|
|
2fa672a2fb | ||
|
|
d71c46e846 | ||
|
|
ccca6dfa13 | ||
|
|
54de39727b | ||
|
|
46d553b074 |
@ -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.
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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»
|
||||
@ -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** вкладка закрывается без диалога
|
||||
|
||||
@ -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. После слияния
|
||||
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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` и
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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';
|
||||
|
||||
18
web/src/hooks/useBeforeUnload.ts
Normal file
18
web/src/hooks/useBeforeUnload.ts
Normal 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]);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -21,7 +21,7 @@ export type ConsRussianStatus =
|
||||
| 'Корректировка данных'
|
||||
| 'Консолидация ГО'
|
||||
| 'Консолидация РФ'
|
||||
| 'Отменён'
|
||||
| 'Отменена'
|
||||
| 'Корректировка'
|
||||
| 'В архиве'
|
||||
| 'Отклонён';
|
||||
|
||||
@ -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;
|
||||
|
||||
135
web/src/utils/consolidations.test.ts
Normal file
135
web/src/utils/consolidations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
web/src/utils/consolidations.ts
Normal file
93
web/src/utils/consolidations.ts
Normal 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;
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(',', '.');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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: 'Статус',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
|
||||
.section {
|
||||
margin-bottom: 0px;
|
||||
/* padding: 12px; */
|
||||
/* отступ сверху и снизу, как в макете [34]; по горизонтали пункты выровнены по кнопке «Новая консолидация» */
|
||||
padding: 12px 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user