diff --git a/docs/superpowers/plans/2026-11-06-goodwill-review-front.md b/docs/superpowers/plans/2026-11-06-goodwill-review-front.md new file mode 100644 index 0000000..af52785 --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-goodwill-review-front.md @@ -0,0 +1,130 @@ +# Гудвилл: доводка фронта по ревью (arm-core #220) — план + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** довести PR arm-core #220 (ветка `feat/goodWill`, AURORA-1544) до мержа: убрать правки вне задачи, влить `main`, +перевести вкладку «Гудвилл» на фактический API бэка (#222 после доводки по плану `2026-11-06-goodwill-review-back.md`), +закрыть расхождения с DOC-259025 и макетом `31-03`. + +**Architecture:** чистые функции вкладки — `web/src/utils/goodwill.ts` (+ vitest); типы — `web/src/types/goodwill/goodwill.ts` +по схемам `api/src/schemas/goodwill.py`; запросы — `web/src/services/api/goodwill.ts`; экран — +`web/src/widgets/ConsolidationGoodwill/*`. Агрегаты (итоги «Всего» по ГО и по листу) фронт не считает — берёт `groups[].totals` +и `totals` из ответа. + +**Tech Stack:** React 18 + TS strict, antd 6, vitest, biome + ESLint/Prettier. + +**Spec:** контракт `../docs/dev/api-contract-goodwill.md` (обновлён в docs #204), DOC-259025 «Гудвилл» §4–§6, макет +`../docs/mockup/screens/31-03-goodwill.png`, `31-03-01-goodwill-edit.png`. + +Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/review-220/arm-core` (ветка `feat/goodWill`). Бэк для ручной +проверки — ветка `feat/goodwill-back` (worktree `.wt/review-222/arm-core`), пока #222 не в `main`. + +## Global Constraints + +- Деньги — строки; арифметика только через `toCents`/`fromCents` (`utils/money.ts`), никаких `Number` над суммами. +- `null` — «—», `0.00` — ноль по формату единиц; нерассчитанный гудвилл не попадает ни в один фильтр знака. +- Доля: ввод с запятой или точкой, 0–100, не больше двух знаков; ошибку показать у поля и заблокировать «Сохранить», значение не обрезать и не подменять. +- Все запросы — через `api/client.ts`; ошибки показываются только для `ApiError`, ошибки валидации форм antd тостом не показываются. +- Код вне `widgets/ConsolidationGoodwill`, `types/goodwill`, `services/api/goodwill.ts`, `utils/goodwill*`, `hooks/useFullScreen.tsx`, + `components/ConsolidationFormatTableFilter` и вкладок отчёта (`ConsolidationReportPage.tsx`, `OsvTabs.tsx`) совпадает с `main` + (кроме строки импорта фильтра в `ConsolidatedChessContent.tsx`). +- Коммиты — по-русски, `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +- Снять галочку у строки, где доля ни разу не вводилась: пакет уходит с `ownership_share: null`, сервер отвечает 200. +- Сервер вернул 422 по одной строке пакета: поле этой строки подсвечено, режим редактирования не закрыт, введённое не потеряно. +- «Выделить все» в блоке, где есть «Нет вложений» и скрытые фильтром строки: отмечаются все действующие строки блока, «Нет вложений» — нет. +- Версия без своей блокировки (`read_only_reason = LOCK_REQUIRED`): «Редактировать» и переключатель недоступны, видна причина. +- Фильтр «Положительные» + Экспорт: в файл уходит тот же фильтр (`sign=positive`). + +--- + +### Task 1: Ветка — только гудвилл + +**Files:** +- Restore from `origin/main`: `web/src/layouts/CatalogueGroups.tsx`, `web/src/types/api.ts`, + `web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx`, `web/src/widgets/CatalogueJur/**`, + `web/src/widgets/RelatedGroup/**`, `web/src/widgets/CorrectionTable/CorrectionContent.tsx`, + `web/src/widgets/ConsolidationChess/**` (кроме удаления `ConsolidationChessFormatTableFilter` — см. Step 2) +- Modify: `web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx` (только импорт фильтра), + `web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx` (удалить `MOCK_DATA`) + +- [ ] **Step 1:** `git merge origin/main` (конфликты в `CatalogueGroups.tsx`, `ConsolidatedChessContent.tsx`, `ConsolidationChessTable.tsx` — + брать сторону `main`), затем `git checkout origin/main -- <файлы из списка>`. +- [ ] **Step 2:** общий фильтр единиц — оставить `components/ConsolidationFormatTableFilter`, удалить + `widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/`, в шахматке поменять только импорт и имя компонента. +- [ ] **Step 3:** `git diff --stat origin/main...HEAD` — изменены только файлы из Global Constraints; `useFullscreen` использует только вкладка гудвилла. +- [ ] **Step 4:** из `web/`: `npm run format`, затем `make -C .. web-check`, `npm test`, `npm run lint` — зелёные. +- [ ] **Step 5:** коммит «Гудвилл: ветка без правок вне задачи, влит main». + +### Task 2: Типы, запросы и чистые функции + +**Files:** +- Modify: `web/src/types/goodwill/goodwill.ts`, `web/src/services/api/goodwill.ts` +- Create: `web/src/utils/goodwill.ts`, `web/src/utils/goodwill.test.ts` + +**Interfaces:** +- Produces (типы, по `api/src/schemas/goodwill.py` ветки `feat/goodwill-back`): `GoodwillRow` — суммы `string | null`, + `has_investments: boolean`, `warnings: {code: string; message: string}[]`; `GoodwillTotals` + `charter_capital_amount`; + `GoodwillGroup {head_legal_entity: Ref; totals: GoodwillTotals}`; `GoodwillTableResponse.groups: GoodwillGroup[]`; + `GoodwillRowKeyIn {head_legal_entity_id; subsidiary_legal_entity_id; ownership_share: string | null; include_in_kfo: boolean; comment: string | null}`; + `GoodwillSign = 'all' | 'positive' | 'negative' | 'zero'`. +- Produces (запросы): `saveGoodwillRows(versionId, rows: GoodwillRowKeyIn[]): Promise` (`PUT …/goodwill`, тело `{rows}`); + `saveGoodwillSettings(versionId, {goodwill_enabled})`; `exportGoodwillTable(versionId, sign: GoodwillSign)` (`?sign=`). `saveGoodwillRow` удалить. +- Produces (`utils/goodwill.ts`): + `rowKey(row: Pick): string` (`${head}:${sub}`); + `parseShare(input: string): {ok: true; value: string | null} | {ok: false; error: string}` (пусто → `null`; запятая → точка); + `previewAmounts(row: GoodwillRow, share: string | null, include: boolean): {participation: string | null; goodwill: string | null}`; + `matchesSign(goodwill: string | null, sign: GoodwillSign): boolean`; + `changedRows(rows: GoodwillRow[], edits: Record): GoodwillRowKeyIn[]`, где + `GoodwillEdit = {include_in_kfo?: boolean; ownership_share?: string | null; comment?: string | null}`. + +- [ ] **Step 1: тесты** `goodwill.test.ts`: + `parseShare`: `"80,12"` → `{ok, "80.12"}`, `"100"` ok, `""` → `{ok, null}`, `"80,123"`, `"100,01"`, `"-1"`, `"abc"` → `{ok:false}`; + `previewAmounts` (DOC-259025 §11.1): ФВ `"180000.00"`, УК `"100000.00"`, доля `"80"`, отмечена → `{participation:"80000.00", goodwill:"100000.00"}`; + ФВ `"50000.00"` → goodwill `"-30000.00"`; УК `"371.17"`, доля `"99.99"` → participation `"371.13"` (HALF_UP: 371.13288…); + не отмечена или доля `null` или УК `null` → `{null, null}`; + `matchesSign`: `null` → false для всех, кроме `all`; `"0.00"` — только `zero`/`all`; + `changedRows`: правка, совпадающая с исходной строкой, не попадает; снятая галочка у строки без доли даёт `ownership_share: null`. +- [ ] **Step 2:** `npm test -- goodwill` — FAIL; реализовать (`previewAmounts` — в копейках через `toCents`/`fromCents`, доля в сотых долях + процента `BigInt`, `P = round(УК_коп × доля_сотых / 10000)` HALF_UP); PASS. +- [ ] **Step 3:** `npx tsc --noEmit` — ошибки только в компонентах вкладки (чинятся в Task 3); коммит «Гудвилл: типы и запросы по API бэка, чистые функции вкладки». + +### Task 3: Вкладка по API, DOC-259025 и макету + +**Files:** +- Modify: `web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx`, + `.../ConsolidationGoodwillTable/{ConsolidationGoodwillTable.tsx,config.tsx,ConsolidationGoodwillTable.css}`, + `.../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx` + +**Interfaces:** +- Consumes: Task 2. + +- [ ] **Step 1: отображение.** Суммы — `null` → «—», иначе формат единиц (ноль — `0,00`); строки «Нет вложений» (`!has_investments`) — + серым, подпись «Нет вложений» в колонке сумм, чекбокс и поля недоступны; `warnings` — иконка `WarningOutlined` с `Tooltip` из `message` + у названия ДО; «Всего» блока — из `groups[].totals`, «Итого по листу» — из `totals` (без `sumAmounts`/`Number`). +- [ ] **Step 2: режим «Редактировать».** Правки — `Record`, стартует пустым; P и G в режиме — `previewAmounts`; + доля — `parseShare` с ошибкой под полем; групповой чекбокс отмечает/снимает все действующие строки блока, включая скрытые фильтром, + `indeterminate` при частичном выборе; «Сохранить» недоступно, если у отмеченной действующей строки нет доли, доля с ошибкой или пустой комментарий; + сохранение — один `saveGoodwillRows(changedRows(...))`, ответ заменяет таблицу и закрывает режим; `ApiError` с `body.detail.subsidiary_legal_entity_id` + подсвечивает поле этой строки (`COMMENT_REQUIRED` → комментарий, `INVALID_SHARE` → доля), режим не закрывается; «Отменить изменения» — сброс правок. +- [ ] **Step 3: переключатель.** `Switch` «Учитывать гудвилл в КФО» в панели инструментов, значение `goodwill_enabled`, доступен при `editable`; + смена → `saveGoodwillSettings`, ответ заменяет таблицу; при выключенном — подпись «Гудвилл не учитывается в КФО». + Если `!editable` — у «Редактировать» `Tooltip` с причиной (`LOCK_REQUIRED` — «Возьмите версию в работу», `LOCKED_BY_OTHER` — «Версия в работе у другого пользователя», `VERSION_NOT_EDITABLE` — «Версия недоступна для изменения»). +- [ ] **Step 4: фильтр и экспорт.** Варианты «Все значения», «Положительные», «Отрицательные», «Нулевые» через `matchesSign`; экспорт передаёт текущий `sign`. +- [ ] **Step 5:** `npm run format`, `make -C .. web-check`, `npm test`, `npm run lint` — зелёные; коммит «Гудвилл: вкладка по API и DOC-259025». + +### Task 4: UI-спека и проверка на стенде + +**Files:** +- Create: `openspec/changes/goodwill-ui/{proposal.md,design.md,tasks.md,specs/goodwill-ui/spec.md}` + +- [ ] **Step 1:** OpenSpec change `goodwill-ui` — требования Steps 1–4 Task 3 со сценариями WHEN/THEN, ссылки на контракт и DOC-259025; + `design.md` — раздел `## Decisions made without discussion` (R-1: правки вне задачи откатены; R-2: итоги с бэка; R-3: предпросмотр P/G только по строкам, + итоги — после сохранения); `openspec validate goodwill-ui --strict`. +- [ ] **Step 2: стенд.** Поднять бэк из `.wt/review-222/arm-core` (ветка `feat/goodwill-back`, порт 8000, БД стенда), фронт отсюда + (`REACT_APP_ROOT_PATH=/ npm run start`), версия «РБ 2024 Q3» или любая с ОСВ 58.01; в Playwright пройти: открыть вкладку, сверить + с `31-03-goodwill.png`, «Редактировать» — сверить с `31-03-01-goodwill-edit.png`, ввести долю `80,5` и комментарий, сохранить, снять галочку + у строки без доли и сохранить, переключить «Учитывать гудвилл», фильтр «Нулевые», экспорт. Скриншоты — в scratchpad, найденное — чинить в Task 3. +- [ ] **Step 3:** коммит «Гудвилл: UI-спека goodwill-ui». diff --git a/openspec/changes/goodwill-ui/.openspec.yaml b/openspec/changes/goodwill-ui/.openspec.yaml new file mode 100644 index 0000000..8a94d76 --- /dev/null +++ b/openspec/changes/goodwill-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/goodwill-ui/design.md b/openspec/changes/goodwill-ui/design.md new file mode 100644 index 0000000..2b3544d --- /dev/null +++ b/openspec/changes/goodwill-ui/design.md @@ -0,0 +1,36 @@ +## Context + +Экран — по контракту `../docs/dev/api-contract-goodwill.md`, DOC-259025 §4–§6 и макету `[31-03]`, `[31-03-01]`. Суммы — +строки, арифметика только в копейках (`utils/money.ts`), чистые функции вкладки — `utils/goodwill.ts` с vitest. + +## Decisions + +- Итоги «Всего» и «Итого по листу» берутся из ответа (`groups[].totals`, `totals`), фронт их не считает. +- В режиме редактирования P и G строки — предпросмотр (`previewAmounts`, та же формула и округление, что на бэке). +- Сохранение — один `PUT …/goodwill` с изменёнными строками (`changedRows`); ошибка строки (422 с ключом пары) + подсвечивает поле этой строки, режим не закрывается. +- Переключатель `goodwill_enabled` меняется вне режима редактирования, отдельным запросом. + +## Decisions made without discussion + +Ревью arm-core #220 (06.11.2026, владелец: «давай всё сделаем сами»). + +- **R-1 (2026-11-06). Правки вне задачи в PR откатены к `main`.** Обработка ошибок в 8 файлах справочников, `Alert` в + `CatalogueJurTable`, удалённые комментарии в `types/api.ts`, шахматке и корректировках. Обоснование: не относятся к + AURORA-1544, давали ложный тост на незаполненной форме (`validateFields` бросает не `Error`) и конфликтовали с #219/#221. + Оставлены общий фильтр единиц и `useFullscreen` (используется только вкладкой гудвилла). Цена ошибки: если правки нужны — + отдельной задачей. +- **R-2 (2026-11-06). Итоги — только с бэка** (правило владельца «агрегаты отдаёт бэк»). Цена ошибки: нет. +- **R-3 (2026-11-06). Предпросмотр — только у строк;** «Всего» и «Итого по листу» в режиме правки показывают сохранённое + и обновляются после «Сохранить». Обоснование: агрегаты не считаем на фронте (R-2). Цена ошибки: аналитик видит итог + после сохранения, а не во время ввода (DOC-259025 §5.1 п. 3 говорит «предварительные … итоги»). +- **R-4 (2026-11-06). `buildTree`/`rowClassName` без unit-теста.** Компоненты в проекте не тестируются (`web/CLAUDE.md`), + логика тривиальна; чистая логика вкладки (`utils/goodwill.ts`, в т. ч. `effectiveRow`) покрыта vitest. Цена ошибки: + регрессия дерева видна только глазами. +- **R-5 (2026-11-06). Проверка на стенде — частично моками.** На локальном стенде нет пар 58.01 между участниками периметра: + реальный бэк ветки проверен на пустой таблице, переключателе и экспорте, насыщенные состояния и 422 — подменой ответов + в Playwright. Цена ошибки: совпадение предпросмотра P/G с сервером на реальных данных подтверждено только тестами; после + мержа #222 пройти один сценарий на реальной паре. +- **R-6 (2026-11-06). Снятая галочка с неверной долей уходит с сохранённой долей** (финальное ревью, I-1): поле доли у + неотмеченной строки скрыто, неверный ввод там не исправить. Подсказка у «Сохранить» называет строки с ошибкой и + предупреждает, если они скрыты фильтром. Цена ошибки: неверный ввод в скрытом поле молча отбрасывается. diff --git a/openspec/changes/goodwill-ui/proposal.md b/openspec/changes/goodwill-ui/proposal.md new file mode 100644 index 0000000..e784ddb --- /dev/null +++ b/openspec/changes/goodwill-ui/proposal.md @@ -0,0 +1,29 @@ +## Why + +AURORA-1544: вкладка «Гудвилл» страницы отчёта версии. Первая версия экрана (PR arm-core #220, Голов) строилась на +фейковых данных по черновику контракта 30.10, а бэкенд (#222, AURORA-1543) сделан по аналитике DOC-259025 «Гудвилл» +(Лысенко, 03.11.2026): пары по фактическим вложениям, строки «Нет вложений», nullable суммы, предупреждения, итоги по ГО, +пакетное сохранение. Ревью 06.11.2026: экран переведён на фактический API и DOC-259025. + +## What Changes + +Репозиторий — arm-core, только `web/`. Контракт — `../docs/dev/api-contract-goodwill.md` (docs #204). + +- Вкладка «Гудвилл» (`…/versions/{id}/goodwill`) доступна. +- Таблица по макету `[31-03]`: блоки ГО, строки ДО, «Всего» блока и «Итого по листу» из ответа (`groups`, `totals`). +- Режим «Редактировать» (`[31-03-01]`): галочки, доля, комментарий, предпросмотр P/G, пакетное сохранение. +- Переключатель «Учитывать гудвилл в КФО» (`goodwill_enabled`, OQ-27), фильтр знака, экспорт с фильтром. + +## Capabilities + +### New Capabilities +- `goodwill-ui`: вкладка «Гудвилл». + +### Modified Capabilities +- `intercompany-matrix-ui`: вкладка «Гудвилл» больше не заглушка. + +## Impact + +`web/src/widgets/ConsolidationGoodwill/*`, `web/src/utils/goodwill.ts` (+ тесты), `web/src/types/goodwill/goodwill.ts`, +`web/src/services/api/goodwill.ts`, `web/src/hooks/useFullScreen.tsx`, общий фильтр единиц +`web/src/components/ConsolidationFormatTableFilter` (шахматка переведена на него), вкладки отчёта. diff --git a/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md b/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md new file mode 100644 index 0000000..0424a29 --- /dev/null +++ b/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Таблица гудвилла +Вкладка `goodwill` страницы отчёта версии SHALL загружать таблицу одним запросом `GET /consolidations/versions/{id}/goodwill` +(контракт `../docs/dev/api-contract-goodwill.md`) и показывать блоки по ГО в порядке ответа: строка ГО, строки ДО, строка +«Всего» блока из `groups[].totals`, внизу «Итого по листу» из `totals`. Колонки — по макету `[31-03]`: «Учет в отчете», +«Наименование дочерней организации», «Доля владения», «Фондовые вложения головной организации», «Доля участия головной +организации в уставном капитале», «Уставный капитал», «Гудвилл (+/-)», «Комментарий». Сумма `null` SHALL показываться +прочерком, ноль — нулём в выбранных единицах; итоги фронт MUST NOT считать сам. + +#### Scenario: Нулевой и нерассчитанный гудвилл +- **WHEN** у одной строки `goodwill_amount = "0.00"`, у другой `null` +- **THEN** первая показывает «0,00», вторая — «—» + +#### Scenario: Строка «Нет вложений» +- **WHEN** у строки `has_investments = false` +- **THEN** строка серая, с меткой «Нет вложений», чекбокс недоступен, суммы — прочерки, сохранённые доля и комментарий видны + +#### Scenario: Предупреждения +- **WHEN** у строки есть `warnings` (например, `CAPITAL_SOURCE_MISSING`) +- **THEN** рядом с названием ДО иконка предупреждения, по наведению — тексты `message` + +### Requirement: Редактирование таблицы гудвилла +Кнопка «Редактировать» SHALL быть доступна при `editable = true`; иначе — недоступна с подсказкой причины +(`read_only_reason`). В режиме редактирования (баннер «Доступны для редактирования столбцы «Доля владения» и +«Комментарий».») у действующих строк доступна галочка; у отмеченной действующей строки — поля доли и комментария, P и G +строки SHALL пересчитываться предварительно по введённой доле. Доля — 0–100, не больше двух знаков, запятая или точка; +ошибочная доля MUST NOT обрезаться или подменяться, а показывается ошибкой у поля. «Сохранить» SHALL быть недоступно, +пока у отмеченной действующей строки нет корректной доли или комментария. Групповой чекбокс блока SHALL отмечать и снимать +все действующие строки блока, включая скрытые фильтром, и показывать промежуточное состояние при частичном выборе. +«Отменить изменения» SHALL возвращать последнее сохранённое состояние. + +#### Scenario: Сохранение набора +- **WHEN** аналитик отметил две строки, ввёл доли «80,5» и «100» и комментарии и нажал «Сохранить» +- **THEN** уходит один `PUT …/goodwill` только с изменёнными строками; ответ заменяет таблицу, режим редактирования закрывается + +#### Scenario: Снятие галочки у строки без доли +- **WHEN** аналитик снимает галочку у строки, где доля не вводилась, и сохраняет +- **THEN** строка уходит с `ownership_share: null` и сохраняется без ошибки + +#### Scenario: Ошибка строки от сервера +- **WHEN** сервер отвечает 422 `COMMENT_REQUIRED` с ключом пары +- **THEN** поле комментария этой строки подсвечено ошибкой, режим редактирования не закрыт, введённое не потеряно + +#### Scenario: Лишние знаки доли +- **WHEN** аналитик вводит долю «80,123» +- **THEN** у поля ошибка «Число от 0 до 100, не больше двух знаков после запятой», значение в поле не меняется, «Сохранить» недоступно + +### Requirement: Переключатель, фильтр и экспорт гудвилла +Панель вкладки SHALL содержать переключатель «Учитывать гудвилл в КФО» (`PUT …/goodwill-settings`, доступен при +`editable` вне режима редактирования) с подписью суммы `goodwill_in_kfo_amount` или «Гудвилл не учитывается в КФО»; +фильтр «Гудвилл»: «Все значения», «Положительные», «Отрицательные», «Нулевые» (нерассчитанный гудвилл — только во «Все +значения»); единицы и знаки после запятой; «Настройка таблицы»; «Экспорт» — `GET …/goodwill/export` с текущим фильтром +(`sign`); «На весь экран». + +#### Scenario: Экспорт с фильтром +- **WHEN** выбран фильтр «Положительные» и нажат «Экспорт» +- **THEN** запрос уходит с `sign=positive` + +#### Scenario: Выключение гудвилла +- **WHEN** аналитик выключает переключатель +- **THEN** таблица перечитывается из ответа, под переключателем подпись «Гудвилл не учитывается в КФО», ручной ввод строк не меняется diff --git a/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..88185e4 --- /dev/null +++ b/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка», `correction` — «Таблица корректировки» (спека `corrections-table-ui`), `goodwill` — «Гудвилл» (спека `goodwill-ui`). +Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние) заменой текущей записи истории браузера, без новой записи: кнопка «Назад» в шапке и +кнопка «Назад» браузера ведут туда, откуда открыт отчёт, а не по переключениям вкладок (AURORA-1851). Активная вкладка +подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL +заменяться на `osv` без новой записи в истории браузера. Вкладки «КФО», «КФО Общий», +«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё +состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Переход на таблицу корректировки +- **WHEN** открыта шахматка версии по адресу `…/versions/{id}/chess` и нажата вкладка «Таблица корректировки» +- **THEN** адрес становится `…/versions/{id}/correction`, шапка версии остаётся, под вкладками показана таблица корректировок + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +#### Scenario: Неизвестная вкладка +- **WHEN** открыт адрес `…/versions/{id}/abc` +- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc` + +#### Scenario: Назад после переключения вкладок +- **WHEN** аналитик открыл отчёт версии кнопкой «К отчётности» в списке консолидаций, переключился «Шахматка» → «ОСВ» → + «Шахматка» и нажал «Назад» в шапке +- **THEN** одним нажатием открыт список консолидаций ГК + +#### Scenario: Переход на гудвилл +- **WHEN** открыта таблица корректировки версии и нажата вкладка «Гудвилл» +- **THEN** адрес становится `…/versions/{id}/goodwill`, шапка версии остаётся, под вкладками показана таблица гудвилла diff --git a/openspec/changes/goodwill-ui/tasks.md b/openspec/changes/goodwill-ui/tasks.md new file mode 100644 index 0000000..51f1cef --- /dev/null +++ b/openspec/changes/goodwill-ui/tasks.md @@ -0,0 +1,6 @@ +## 1. Доводка по ревью (план docs/superpowers/plans/2026-11-06-goodwill-review-front.md) + +- [x] 1.1 Ветка без правок вне задачи, влит main (R-1). +- [x] 1.2 Типы и запросы по API бэка, чистые функции `utils/goodwill.ts` с тестами. +- [x] 1.3 Вкладка по API, DOC-259025 и макету. +- [x] 1.4 UI-спека `goodwill-ui`, проверка на стенде. diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx similarity index 90% rename from web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx rename to web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx index 5c2b5d9..79ee8f5 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx +++ b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx @@ -1,14 +1,14 @@ import { Select, Space } from 'antd'; import type React from 'react'; -interface ConsolidationChessFormatTableFilterI { +interface ConsolidationFormatTableFilterI { decimals: number; onDecimalsChange: (val: number) => void; units: string; onUnitsChange: (val: string) => void; } -export const ConsolidationChessFormatTableFilter: React.FC = ({ +export const ConsolidationFormatTableFilter: React.FC = ({ decimals, onDecimalsChange, units, diff --git a/web/src/hooks/useFullScreen.tsx b/web/src/hooks/useFullScreen.tsx new file mode 100644 index 0000000..dad1ec5 --- /dev/null +++ b/web/src/hooks/useFullScreen.tsx @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNotificationApi } from '../utils/useNotification'; + +export function useFullscreen() { + const ref = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const notification = useNotificationApi(); + + useEffect(() => { + const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggle = useCallback(async () => { + if (!ref.current) return; + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await ref.current.requestFullscreen(); + } + } catch (e) { + notification.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' }); + } + }, [notification]); + + return { ref, isFullscreen, toggle }; +} diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 55c1d2e..e37586c 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -16,6 +16,7 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent'; +import { GoodwillContent } from '../widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent'; import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; @@ -34,6 +35,7 @@ function ReportContent() { chess: , correction: , + goodwill: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; @@ -64,7 +66,7 @@ function ReportContent() { }, [version_id]); useEffect(() => { - const isValidTab = !tab || tab === 'osv' || tab === 'chess' || tab === 'correction'; + const isValidTab = !tab || Object.keys(TAB_COMPONENTS).includes(tab); if (!isValidTab) { const pathParts = pathname.split('/'); diff --git a/web/src/services/api/goodwill.ts b/web/src/services/api/goodwill.ts new file mode 100644 index 0000000..dd1e7fe --- /dev/null +++ b/web/src/services/api/goodwill.ts @@ -0,0 +1,19 @@ +import { api, apiFetchBlob } from '../../api/client'; +import type { GoodwillRowKeyIn, GoodwillSettingsPayload, GoodwillSign, GoodwillTableResponse } from '../../types/goodwill/goodwill'; + +export function fetchGoodwillTable(versionId: string): Promise { + return api.get(`/consolidations/versions/${versionId}/goodwill`); +} + +/** Пакет строк одной транзакцией: ошибка любой строки — ничего не сохранено, в `ApiError.body.detail` её ключ. */ +export function saveGoodwillRows(versionId: string, rows: GoodwillRowKeyIn[]): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill`, { rows }); +} + +export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill-settings`, payload); +} + +export function exportGoodwillTable(versionId: string, sign: GoodwillSign): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export?sign=${sign}`); +} diff --git a/web/src/types/goodwill/goodwill.ts b/web/src/types/goodwill/goodwill.ts new file mode 100644 index 0000000..96439be --- /dev/null +++ b/web/src/types/goodwill/goodwill.ts @@ -0,0 +1,77 @@ +/** DTO таблицы гудвилла — по `api/src/schemas/goodwill.py` и `../docs/dev/api-contract-goodwill.md`. Деньги и доля — строки. */ +export interface Ref { + id: string; + name: string; +} + +export interface GoodwillWarning { + code: 'CAPITAL_SOURCE_MISSING' | 'CAPITAL_SOURCE_AMBIGUOUS' | 'OWNERSHIP_TOTAL_EXCEEDED' | string; + message: string; +} + +export interface GoodwillRow { + head_legal_entity: Ref; + subsidiary_legal_entity: Ref; + ownership_share: string | null; + /** null — «Нет вложений» */ + financial_investment_amount: string | null; + /** null — нет однозначного 1310 или «Нет вложений» */ + charter_capital_amount: string | null; + additional_capital_amount: string | null; + /** только у действующей отмеченной строки с долей и УК */ + participation_amount: string | null; + goodwill_amount: string | null; + include_in_kfo: boolean; + comment: string | null; + has_investments: boolean; + warnings: GoodwillWarning[]; + updated_by: Ref | null; + updated_at: string | null; +} + +export interface GoodwillTotals { + financial_investment_amount: string; + charter_capital_amount: string; + participation_amount: string; + goodwill_amount: string; + goodwill_in_kfo_amount: string; +} + +export interface GoodwillGroup { + head_legal_entity: Ref; + totals: GoodwillTotals; +} + +export type GoodwillReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER'; + +export interface GoodwillTableResponse { + version_id: string; + goodwill_enabled: boolean; + editable: boolean; + read_only_reason: GoodwillReadOnlyReason | null; + rows: GoodwillRow[]; + groups: GoodwillGroup[]; + totals: GoodwillTotals; +} + +/** Строка пакета `PUT …/goodwill`. */ +export interface GoodwillRowKeyIn { + head_legal_entity_id: string; + subsidiary_legal_entity_id: string; + ownership_share: string | null; + include_in_kfo: boolean; + comment: string | null; +} + +export interface GoodwillSettingsPayload { + goodwill_enabled: boolean; +} + +export type GoodwillSign = 'all' | 'positive' | 'negative' | 'zero'; + +/** Несохранённая правка строки в режиме «Редактировать»; отсутствующее поле — как в сохранённой строке. */ +export interface GoodwillEdit { + include_in_kfo?: boolean; + ownership_share?: string | null; + comment?: string | null; +} diff --git a/web/src/utils/goodwill.test.ts b/web/src/utils/goodwill.test.ts new file mode 100644 index 0000000..86bd8bd --- /dev/null +++ b/web/src/utils/goodwill.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import type { GoodwillRow } from '../types/goodwill/goodwill'; +import { changedRows, effectiveRow, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill'; + +function row(patch: Partial = {}): GoodwillRow { + return { + head_legal_entity: { id: 'a', name: 'Альфа' }, + subsidiary_legal_entity: { id: 'b', name: 'Бета' }, + ownership_share: null, + financial_investment_amount: '180000.00', + charter_capital_amount: '100000.00', + additional_capital_amount: null, + participation_amount: null, + goodwill_amount: null, + include_in_kfo: false, + comment: null, + has_investments: true, + warnings: [], + updated_by: null, + updated_at: null, + ...patch, + }; +} + +describe('parseShare', () => { + it('принимает запятую и точку, не больше двух знаков', () => { + expect(parseShare('80,12')).toEqual({ ok: true, value: '80.12' }); + expect(parseShare('100')).toEqual({ ok: true, value: '100' }); + expect(parseShare(' 7.5 ')).toEqual({ ok: true, value: '7.5' }); + }); + it('пустое — null', () => { + expect(parseShare('')).toEqual({ ok: true, value: null }); + }); + it.each(['80,123', '100,01', '-1', 'abc', '1e2', '101'])('отклоняет %s без обрезки', (input) => { + expect(parseShare(input).ok).toBe(false); + }); +}); + +describe('previewAmounts (DOC-259025 §11.1)', () => { + it('Бета: P = 80 000, G = +100 000', () => { + expect(previewAmounts(row(), '80', true)).toEqual({ participation: '80000.00', goodwill: '100000.00' }); + }); + it('Гамма: G отрицательный', () => { + expect(previewAmounts(row({ financial_investment_amount: '50000.00' }), '80', true)).toEqual({ + participation: '80000.00', + goodwill: '-30000.00', + }); + }); + it('округление половина вверх до копеек', () => { + expect(previewAmounts(row({ charter_capital_amount: '371.17' }), '99.99', true).participation).toBe('371.13'); + expect(previewAmounts(row({ charter_capital_amount: '0.05' }), '50', true).participation).toBe('0.03'); + }); + it('не отмечена, нет доли или УК — не рассчитано', () => { + const empty = { participation: null, goodwill: null }; + expect(previewAmounts(row(), '80', false)).toEqual(empty); + expect(previewAmounts(row(), null, true)).toEqual(empty); + expect(previewAmounts(row({ charter_capital_amount: null }), '80', true)).toEqual(empty); + expect(previewAmounts(row({ has_investments: false, financial_investment_amount: null }), '80', true)).toEqual(empty); + }); +}); + +describe('matchesSign', () => { + it('нерассчитанный гудвилл попадает только во «все»', () => { + expect(matchesSign(null, 'all')).toBe(true); + expect(matchesSign(null, 'positive')).toBe(false); + expect(matchesSign(null, 'negative')).toBe(false); + expect(matchesSign(null, 'zero')).toBe(false); + }); + it('ноль — только «нулевые»', () => { + expect(matchesSign('0.00', 'zero')).toBe(true); + expect(matchesSign('0.00', 'positive')).toBe(false); + expect(matchesSign('-0.01', 'negative')).toBe(true); + expect(matchesSign('0.01', 'positive')).toBe(true); + }); +}); + +describe('changedRows', () => { + const saved = row({ ownership_share: '80.00', include_in_kfo: true, comment: 'Устав' }); + it('правка, совпадающая с сохранённым, не уходит', () => { + const edits = { [rowKey(saved)]: { include_in_kfo: true, ownership_share: '80', comment: 'Устав' } }; + expect(changedRows([saved], edits)).toEqual([]); + }); + it('изменённая строка уходит целиком', () => { + const edits = { [rowKey(saved)]: { comment: 'Устав 2024' } }; + expect(changedRows([saved], edits)).toEqual([ + { head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: '80.00', include_in_kfo: true, comment: 'Устав 2024' }, + ]); + }); + it('снятая галочка у строки без доли — ownership_share: null', () => { + const fresh = row({ include_in_kfo: true }); + expect(changedRows([fresh], { [rowKey(fresh)]: { include_in_kfo: false } })).toEqual([ + { head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: null, include_in_kfo: false, comment: null }, + ]); + }); + it('доля с запятой уходит с точкой', () => { + const fresh = row(); + const edits = { [rowKey(fresh)]: { include_in_kfo: true, ownership_share: '80,5', comment: 'Да' } }; + expect(changedRows([fresh], edits)[0].ownership_share).toBe('80.5'); + }); +}); + +describe('effectiveRow', () => { + it('снятая галочка с неверной долей уходит с сохранённой долей (ошибку у скрытого поля не исправить)', () => { + const saved = row({ ownership_share: '80.00' }); + expect(effectiveRow(saved, { include_in_kfo: false, ownership_share: '80,123' }).ownership_share).toBe('80.00'); + const fresh = row(); + expect(effectiveRow(fresh, { include_in_kfo: false, ownership_share: 'abc' }).ownership_share).toBeNull(); + }); + it('у отмеченной строки неверная доля остаётся как введена — ошибку видно у поля', () => { + expect(effectiveRow(row(), { include_in_kfo: true, ownership_share: '80,123' }).ownership_share).toBe('80,123'); + }); +}); diff --git a/web/src/utils/goodwill.ts b/web/src/utils/goodwill.ts new file mode 100644 index 0000000..6b32a5f --- /dev/null +++ b/web/src/utils/goodwill.ts @@ -0,0 +1,95 @@ +/** Чистые функции вкладки «Гудвилл» (DOC-259025 §4–§6): ввод доли, предпросмотр P/G, фильтр знака, изменённые строки. */ +import type { GoodwillEdit, GoodwillRow, GoodwillRowKeyIn, GoodwillSign } from '../types/goodwill/goodwill'; +import { fromCents, toCents } from './money'; + +const SHARE = /^\d{1,3}(?:\.\d{1,2})?$/; + +export function rowKey(row: Pick): string { + return `${row.head_legal_entity.id}:${row.subsidiary_legal_entity.id}`; +} + +/** Доля 0–100, не больше двух знаков, запятая или точка; пусто — null. Лишние знаки — ошибка, не обрезка. */ +export function parseShare(input: string): { ok: true; value: string | null } | { ok: false; error: string } { + const value = input.trim().replace(',', '.'); + if (value === '') return { ok: true, value: null }; + if (!SHARE.test(value) || shareHundredths(value) > 10000n) { + return { ok: false, error: 'Число от 0 до 100, не больше двух знаков после запятой' }; + } + return { ok: true, value }; +} + +/** Доля строкой («80.5») → сотые доли процента (8050n). */ +function shareHundredths(value: string): bigint { + const [int, frac = ''] = value.split('.'); + return BigInt(int) * 100n + BigInt(frac.padEnd(2, '0')); +} + +/** Предварительные P = round(УК × d / 100, 2) и G = ФВ − P в копейках, половина вверх; как на бэке. */ +export function previewAmounts( + row: GoodwillRow, + share: string | null, + include: boolean, +): { participation: string | null; goodwill: string | null } { + const fv = row.financial_investment_amount; + const capital = row.charter_capital_amount; + if (!row.has_investments || !include || share === null || fv === null || capital === null) { + return { participation: null, goodwill: null }; + } + const parsed = parseShare(share); + if (!parsed.ok || parsed.value === null) return { participation: null, goodwill: null }; + const product = toCents(capital) * shareHundredths(parsed.value); + const divisor = 10000n; + const negative = product < 0n; + const abs = negative ? -product : product; + const rounded = (abs + divisor / 2n) / divisor; + const p = negative ? -rounded : rounded; + return { participation: fromCents(p), goodwill: fromCents(toCents(fv) - p) }; +} + +/** Фильтр «Гудвилл»: нерассчитанный (null) — только во «Все значения». */ +export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolean { + if (sign === 'all') return true; + if (goodwill === null) return false; + const cents = toCents(goodwill); + if (sign === 'positive') return cents > 0n; + if (sign === 'negative') return cents < 0n; + return cents === 0n; +} + +/** Значения строки с учётом правки: что уйдёт в пакет. */ +export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn { + const include = edit?.include_in_kfo ?? row.include_in_kfo; + const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share; + const parsed = share === null ? null : parseShare(share); + const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment; + let ownership = parsed?.ok ? parsed.value : share; + // у неотмеченной строки поле доли скрыто: неверный ввод не исправить, поэтому уходит сохранённая доля + if (!include && parsed && !parsed.ok) ownership = row.ownership_share; + return { + head_legal_entity_id: row.head_legal_entity.id, + subsidiary_legal_entity_id: row.subsidiary_legal_entity.id, + ownership_share: ownership, + include_in_kfo: include, + comment: comment?.trim() ? comment : null, + }; +} + +function sameShare(a: string | null, b: string | null): boolean { + if (a === null || b === null) return a === b; + if (!SHARE.test(a) || !SHARE.test(b)) return a === b; + return shareHundredths(a) === shareHundredths(b); +} + +/** Строки пакета — только те, где правка отличается от сохранённого. */ +export function changedRows(rows: GoodwillRow[], edits: Record): GoodwillRowKeyIn[] { + return rows.flatMap((row) => { + const edit = edits[rowKey(row)]; + if (!edit) return []; + const next = effectiveRow(row, edit); + const same = + next.include_in_kfo === row.include_in_kfo && + sameShare(next.ownership_share, row.ownership_share) && + (next.comment?.trim() ?? null) === (row.comment?.trim() || null); + return same ? [] : [next]; + }); +} diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 219bf56..98aefba 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -22,8 +22,6 @@ const TAB_CONFIG = [ { key: 'goodwill', label: 'Гудвилл', - disabled: true, - tooltip: 'Будет в следующих срезах', }, { key: 'kfo', diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index cc41295..1b3cbd2 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -3,6 +3,7 @@ import { Button, ConfigProvider } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../../api/client'; +import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { exportChessConsolidationsResult, exportIntercompanyMatrix, @@ -20,7 +21,6 @@ import { chessMatrixMode } from '../../../utils/chessMatrixMode'; import { chessMatrixRequest, isWholePerimeter } from '../../../utils/chessMatrixRequest'; import { saveBlob, xlsxFilename } from '../../../utils/download'; import { useNotificationApi } from '../../../utils/useNotification'; -import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter'; import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; @@ -236,7 +236,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { backgroundColor: '#e5e7eb', }} /> - { gap: 16, }}>
- = { COMMENT_REQUIRED: 'comment', INVALID_SHARE: 'ownership_share' }; + +type RowErrorDetail = { code?: string; message?: string; head_legal_entity_id?: string; subsidiary_legal_entity_id?: string }; + +/** Ключ пары из ошибки пакетного сохранения; ошибка не по строке — null. */ +function rowErrorDetail(error: unknown): RowErrorDetail | null { + if (!(error instanceof ApiError)) return null; + const detail = (error.body as { detail?: unknown } | undefined)?.detail; + if (typeof detail !== 'object' || detail === null || Array.isArray(detail)) return null; + const d = detail as RowErrorDetail; + return d.head_legal_entity_id && d.subsidiary_legal_entity_id ? d : null; +} + +export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { + const { version_id } = useParams<{ version_id: string }>(); + const notification = useNotificationApi(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [settingsSaving, setSettingsSaving] = useState(false); + const [decimals, setDecimals] = useState(2); + const [units, setUnits] = useState('thousand'); + const [isEditMode, setIsEditMode] = useState(false); + const [edits, setEdits] = useState>({}); + const [serverIssues, setServerIssues] = useState>({}); + const [sign, setSign] = useState('all'); + const [exportLoading, setExportLoading] = useState(false); + const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; + const { ref: tableRef, isFullscreen, toggle: toggleFullscreen } = useFullscreen(); + + const loadData = useCallback( + async (vId: string) => { + setLoading(true); + try { + setData(await fetchGoodwillTable(vId)); + } catch (e) { + if (e instanceof ApiError) notification.error({ description: e.message }); + } finally { + setLoading(false); + } + }, + [notification], + ); + + useEffect(() => { + if (version_id) loadData(version_id); + }, [version_id, loadData]); + + const editRow = useCallback((key: string, patch: GoodwillEdit, clear?: keyof RowIssues) => { + setEdits((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); + if (clear) { + setServerIssues((prev) => (prev[key]?.[clear] ? { ...prev, [key]: { ...prev[key], [clear]: undefined } } : prev)); + } + }, []); + + const onToggleGroup = useCallback( + (headId: string, checked: boolean) => { + // все действующие строки блока, включая скрытые фильтром; «Нет вложений» не трогаем (DOC-259025 §4) + const keys = (data?.rows ?? []).filter((r) => r.head_legal_entity.id === headId && r.has_investments).map(rowKey); + setEdits((prev) => { + const next = { ...prev }; + for (const key of keys) next[key] = { ...next[key], include_in_kfo: checked }; + return next; + }); + }, + [data], + ); + + /** Ошибки ввода: отмеченной действующей строке нужны корректная доля и комментарий (DOC-259025 §5.2). */ + const issues = useMemo(() => { + const result: Record = {}; + if (!data || !isEditMode) return result; + for (const row of data.rows) { + const key = rowKey(row); + const next = effectiveRow(row, edits[key]); + const own: RowIssues = { ...serverIssues[key] }; + if (row.has_investments && next.include_in_kfo) { + const parsed = next.ownership_share === null ? null : parseShare(next.ownership_share); + if (parsed === null) own.ownership_share = own.ownership_share ?? 'Заполните долю'; + else if (!parsed.ok) own.ownership_share = parsed.error; + if (next.comment === null) own.comment = own.comment ?? 'Заполните комментарий'; + } + if (own.ownership_share || own.comment) result[key] = own; + } + return result; + }, [data, edits, isEditMode, serverIssues]); + + const pending = useMemo(() => (data ? changedRows(data.rows, edits) : []), [data, edits]); + const hasIssues = Object.keys(issues).length > 0; + + const columns = useMemo( + () => + getGoodwillColumns({ + units, + decimals, + isEditMode, + edits, + issues, + rows: data?.rows ?? [], + onToggleRow: (key, checked) => editRow(key, { include_in_kfo: checked }), + onToggleGroup, + onShare: (key, value) => editRow(key, { ownership_share: value }, 'ownership_share'), + onComment: (key, value) => editRow(key, { comment: value }, 'comment'), + }), + [units, decimals, isEditMode, edits, issues, data, editRow, onToggleGroup], + ); + const columnsConfig = useMemo(() => ({ rowKey: 'key' as const, columns: columns as ColumnDef[] }), [columns]); + const { visibleKeys, setVisibleKeys } = useVisibleColumns(columnsConfig); + const visibleColumns = useMemo( + () => columns.filter((col) => (visibleKeys as string[]).includes(String(col.key))), + [columns, visibleKeys], + ); + + const filteredRows = useMemo(() => (data?.rows ?? []).filter((row) => matchesSign(row.goodwill_amount, sign)), [data, sign]); + + /** Подсказка у «Сохранить»: какие строки мешают, и не скрыты ли они фильтром или настройкой колонок. */ + const issuesHint = useMemo(() => { + if (!data || !hasIssues) return undefined; + const keys = Object.keys(issues); + const names = data.rows.filter((r) => keys.includes(rowKey(r))).map((r) => r.subsidiary_legal_entity.name); + const visible = new Set(filteredRows.map(rowKey)); + const hidden = keys.some((k) => !visible.has(k)) ? ' Часть строк скрыта фильтром — сбросьте фильтр.' : ''; + return `Заполните долю и комментарий: ${names.join(', ')}.${hidden}`; + }, [data, hasIssues, issues, filteredRows]); + + const closeEditing = () => { + setEdits({}); + setServerIssues({}); + setIsEditMode(false); + }; + + const handleSave = async () => { + if (!version_id || !data) return; + if (pending.length === 0) { + closeEditing(); + return; + } + setSaving(true); + try { + setData(await saveGoodwillRows(version_id, pending)); + closeEditing(); + notification.success({ description: 'Изменения сохранены' }); + } catch (e) { + const detail = rowErrorDetail(e); + if (detail) { + const key = `${detail.head_legal_entity_id}:${detail.subsidiary_legal_entity_id}`; + const field = ERROR_FIELD[detail.code ?? ''] ?? 'ownership_share'; + setServerIssues((prev) => ({ ...prev, [key]: { ...prev[key], [field]: detail.message } })); + } + if (e instanceof ApiError) notification.error({ description: e.message }); + } finally { + setSaving(false); + } + }; + + const handleSettings = async (enabled: boolean) => { + if (!version_id) return; + setSettingsSaving(true); + try { + setData(await saveGoodwillSettings(version_id, { goodwill_enabled: enabled })); + } catch (e) { + if (e instanceof ApiError) notification.error({ description: e.message }); + } finally { + setSettingsSaving(false); + } + }; + + const download = async () => { + if (!version_id) return; + setExportLoading(true); + try { + const { blob, filename } = await exportGoodwillTable(version_id, sign); + saveBlob(blob, filename ?? xlsxFilename('Гудвилл', versionInfo)); + } catch (e) { + if (e instanceof ApiError) notification.error({ description: e.message }); + } finally { + setExportLoading(false); + } + }; + + const readOnly = data && !data.editable ? readOnlyHint(data.read_only_reason) : null; + + return ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+ {isEditMode && ( + Доступны для редактирования столбцы «Доля владения» и «Комментарий».} + type='warning' + action={ + + + + + + + } + /> + )} + +
+ +
+ + + Гудвилл + onShare(node.key, e.target.value)} + placeholder='%' + aria-label='Доля владения' + /> + {error && ( + + {error} + + )} +
+ ); + } + return value === null ? '—' : `${chessTableCellFormatValue(value, 'rub', 2)} %`; + }, + }, + moneyColumn('financial_investment_amount', 'Фондовые вложения головной организации'), + moneyColumn('participation_amount', 'Доля участия головной организации в уставном капитале'), + moneyColumn('charter_capital_amount', 'Уставный капитал'), + moneyColumn('goodwill_amount', 'Гудвилл (+/-)'), + { + title: 'Комментарий', + key: 'comment', + width: 240, + render: (_, node) => { + if (node.kind !== 'row') return null; + const { row } = node; + const edit = edits[node.key]; + const value = edit && 'comment' in edit ? (edit.comment ?? '') : (row.comment ?? ''); + if (isEditMode && row.has_investments && included(row)) { + const error = issues[node.key]?.comment; + return ( +
+ onComment(node.key, e.target.value)} + placeholder='Комментарий' + aria-label='Комментарий' + /> + {error && ( + + {error} + + )} +
+ ); + } + return value || '—'; + }, + }, + ]; +};