[feature][ui][roles] Каталог ролей: интеграция редизайна модалки из handoff/ (карточки наборов, сводные статусы, пакетные обновления) #371

Open
opened 2026-07-05 04:21:27 +03:00 by agent_vscode · 2 comments
Collaborator

Контекст

Модалка «Каталог ролей» (админ-настройки → роли агентов) переделана дизайнером по ТЗ. Хендофф не закоммичен в репозиторий — его содержимое приложено в комментариях к этой ишье:

  • handoff/RoleCatalogModal.tsx — готовый референс-компонент на Mantine v7 (мок-данные и мок-API помечены и удаляются);
  • handoff/README.md — описание модели данных, точек интеграции (TODO / [API]) и того, как закрыты требования ТЗ (Р1–Р12).

⚠️ Упомянутые в README html-прототипы (*.dc.html) недоступны — источник истины: RoleCatalogModal.tsx + README.md из комментариев ниже.

Суть редизайна: окно строится вокруг наборов-карточек — сводный статус набора виден без раскрытия, у набора одно главное действие (Install bundle / Update all / Installed), язык — компактный SegmentedControl, конфликты убраны из шапки (инлайн-плашка «Rename & install» после импорта), результат импорта показывается не закрывая окно.

Что сделать

Заменить текущую реализацию apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx на компонент из хендоффа, подключённый к реальному API. Контракт пропсов сохранить: { opened, onClose, roles: IAiRole[] } — родитель ai-agent-roles.tsx не трогаем (или минимально). uiLang / availableLangs из пропсов хендоффа вычисляются внутри (i18n + catalog.languages).

1. Данные и статусы (клиент, без изменений API)

  • Каталог: useAiRoleCatalogQuery(language, opened){ languages, bundles[{id, name, description}] }. Содержимое бандлов — useAiRoleCatalogBundleQuery. Новому UI статусы нужны в свёрнутых шапках, поэтому после загрузки списка грузить содержимое всех бандлов сразу (параллельно, useQueries); каталог маленький. Ленивую загрузку по раскрытию — убрать.
  • Статус роли считать на клиенте существующим хелпером catalogRoleInstallState (apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts, матч по source.slug + source.language): import / installed / update.
  • Для подсказки «установлено на другом языке» (Р5) добавить рядом чистый хелпер, который для роли в статусе import находит установку того же slug с другим source.languageinstalledLang.
  • Маппинг в типы хендоффа: updateversion=fromVersion, newVersion=toVersion; installed/importversion из каталога. emoji в каталоге опционален — предусмотреть отсутствие. Статус skipped — транзиентный клиентский (после импорта с конфликтом), с бэка не приходит.
  • Смена языка: перезагрузка контента, сброс выбора и плашек результата. Дефолт языка — базовый субтег i18n с реконсиляцией по catalog.languages (логика уже есть в старой модалке — перенести).

2. Импорт (нужна доработка сервера)

  • Клиент: useImportAiRolesFromCatalogMutationPOST /ai-chat/roles/import {bundleId, language, slugs, conflict:'skip'}.
  • ⚠️ Сервер сейчас возвращает только счётчики {created, skipped, renamed, errors} (apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts, importFromCatalog), а UI нужны списки: какие роли пропущены и почему (плашка «Installed N · M skipped» с именем конфликтной роли и кнопкой «Rename & install»). Расширить ответ per-role результатами, например:
    {
      created: { slug: string; name: string; renamedTo?: string }[];
      skipped: { slug: string; name: string; reason: 'name-conflict' | 'already-installed' }[];
      errors:  { slug: string; message: string }[];
    }
    
    Счётчики можно оставить рядом для совместимости либо обновить всех потребителей (сообщения в onSuccess мутации в ai-chat-query.ts и тесты import-from-catalog-message.test.tsx).
  • «Rename & install» для конфликтной роли: повторный импорт той же мутацией с conflict:'rename', slugs:[slug] — API уже умеет, новых эндпоинтов не нужно.

3. Обновления

  • По одной роли: существующий useUpdateAiRoleFromCatalogMutation(roleId).
  • «Update all (N)» в наборе и глобальный «Update all» (показывается при апдейтах в ≥2 наборах): клиентская серия последовательных вызовов той же мутации с прогрессом на кнопке («Updating 2/5…», заготовка в updateBundleAll). Пакетный эндпоинт — вне скоупа этой ишьи.

4. Качество кода

  • Все строки — через useTranslation(), добавить en/ru переводы (RU длиннее EN до ~40%, white-space:nowrap на локализуемые кнопки не ставить).
  • Убрать any в пропсах BundlePanel / RoleRow — полноценные интерфейсы.
  • Хардкод-hex в StatusDot заменить на CSS-переменные Mantine (var(--mantine-color-blue-6) и т.п.).
  • Удалить SEED, mockImport, delay, мок-флаг conflict.
  • Вычисление фазы набора (allNew | allInstalled | updates | mixed) вынести в чистую функцию рядом с catalogRoleInstallState — для юнит-тестов без монтирования компонента.
  • Комментарии в коде — на английском.

5. Тесты

  • Юнит: фаза набора (4 фазы + пустой набор), хелпер installedLang, маппинг каталога в модель компонента.
  • Обновить import-from-catalog-message.test.tsx / update-from-catalog-message.test.tsx, если меняются сообщения/формат ответа.
  • Сервер: тесты на новый формат ответа importFromCatalog (списки created/skipped с reason, rename-путь).

Критерии приёмки

  • Свёрнутый набор показывает сводку статусов (N new / all installed / N updates / mixed) без раскрытия.
  • Главное действие набора: «Install bundle (N)» / «Update all (N)» / статус «Installed» — задизейбленной «мёртвой» кнопки Import больше нет.
  • Чекбоксы выбора + «Select all / Deselect all», по умолчанию выбраны все доступные к установке.
  • После импорта — инлайн-плашка результата (успех / частично: «M skipped» с именем роли и рабочей «Rename & install»), окно не закрывается.
  • «Update all» набора и глобальный работают серией запросов с прогрессом.
  • Подсказка про установки на другом языке показывается, когда есть такие роли.
  • Состояния loading (скелетоны) / error (retry) / empty — как в хендоффе.
  • Светлая/тёмная темы, все строки локализованы (en/ru).
  • Старый код модалки удалён, мок-код хендоффа удалён, тесты зелёные.

Вне скоупа

  • Пакетный серверный эндпоинт обновления ([API #3] из README) — при желании отдельной ишьёй.
  • Модальный диалог конфликта в момент импорта ([API #4], вариант Р6-б с dry-run) — в компоненте реализован fallback на текущем API, его и оставляем.
  • Поиск по каталогу (Р12) — заложен только визуально, не реализуем.
# Контекст Модалка «Каталог ролей» (админ-настройки → роли агентов) переделана дизайнером по ТЗ. Хендофф **не закоммичен в репозиторий** — его содержимое приложено в комментариях к этой ишье: - `handoff/RoleCatalogModal.tsx` — готовый референс-компонент на Mantine v7 (мок-данные и мок-API помечены и удаляются); - `handoff/README.md` — описание модели данных, точек интеграции (`TODO` / `[API]`) и того, как закрыты требования ТЗ (Р1–Р12). ⚠️ Упомянутые в README html-прототипы (`*.dc.html`) недоступны — источник истины: `RoleCatalogModal.tsx` + `README.md` из комментариев ниже. Суть редизайна: окно строится вокруг **наборов-карточек** — сводный статус набора виден без раскрытия, у набора одно главное действие (Install bundle / Update all / Installed), язык — компактный `SegmentedControl`, конфликты убраны из шапки (инлайн-плашка «Rename & install» после импорта), результат импорта показывается не закрывая окно. # Что сделать Заменить текущую реализацию `apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx` на компонент из хендоффа, подключённый к реальному API. Контракт пропсов сохранить: `{ opened, onClose, roles: IAiRole[] }` — родитель `ai-agent-roles.tsx` не трогаем (или минимально). `uiLang` / `availableLangs` из пропсов хендоффа вычисляются внутри (i18n + `catalog.languages`). ## 1. Данные и статусы (клиент, без изменений API) - Каталог: `useAiRoleCatalogQuery(language, opened)` → `{ languages, bundles[{id, name, description}] }`. Содержимое бандлов — `useAiRoleCatalogBundleQuery`. Новому UI статусы нужны **в свёрнутых шапках**, поэтому после загрузки списка грузить содержимое **всех** бандлов сразу (параллельно, `useQueries`); каталог маленький. Ленивую загрузку по раскрытию — убрать. - Статус роли считать на клиенте существующим хелпером `catalogRoleInstallState` (`apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts`, матч по `source.slug + source.language`): `import` / `installed` / `update`. - Для подсказки «установлено на другом языке» (Р5) добавить рядом чистый хелпер, который для роли в статусе `import` находит установку того же `slug` с другим `source.language` → `installedLang`. - Маппинг в типы хендоффа: `update` → `version=fromVersion, newVersion=toVersion`; `installed`/`import` → `version` из каталога. `emoji` в каталоге опционален — предусмотреть отсутствие. Статус `skipped` — транзиентный клиентский (после импорта с конфликтом), с бэка не приходит. - Смена языка: перезагрузка контента, сброс выбора и плашек результата. Дефолт языка — базовый субтег i18n с реконсиляцией по `catalog.languages` (логика уже есть в старой модалке — перенести). ## 2. Импорт (нужна доработка сервера) - Клиент: `useImportAiRolesFromCatalogMutation` → `POST /ai-chat/roles/import` `{bundleId, language, slugs, conflict:'skip'}`. - ⚠️ **Сервер сейчас возвращает только счётчики** `{created, skipped, renamed, errors}` (`apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts`, `importFromCatalog`), а UI нужны **списки**: какие роли пропущены и почему (плашка «Installed N · M skipped» с именем конфликтной роли и кнопкой «Rename & install»). Расширить ответ per-role результатами, например: ```ts { created: { slug: string; name: string; renamedTo?: string }[]; skipped: { slug: string; name: string; reason: 'name-conflict' | 'already-installed' }[]; errors: { slug: string; message: string }[]; } ``` Счётчики можно оставить рядом для совместимости либо обновить всех потребителей (сообщения в `onSuccess` мутации в `ai-chat-query.ts` и тесты `import-from-catalog-message.test.tsx`). - «Rename & install» для конфликтной роли: повторный импорт **той же мутацией** с `conflict:'rename'`, `slugs:[slug]` — API уже умеет, новых эндпоинтов не нужно. ## 3. Обновления - По одной роли: существующий `useUpdateAiRoleFromCatalogMutation(roleId)`. - «Update all (N)» в наборе и глобальный «Update all» (показывается при апдейтах в ≥2 наборах): **клиентская серия** последовательных вызовов той же мутации с прогрессом на кнопке («Updating 2/5…», заготовка в `updateBundleAll`). Пакетный эндпоинт — вне скоупа этой ишьи. ## 4. Качество кода - Все строки — через `useTranslation()`, добавить en/ru переводы (RU длиннее EN до ~40%, `white-space:nowrap` на локализуемые кнопки не ставить). - Убрать `any` в пропсах `BundlePanel` / `RoleRow` — полноценные интерфейсы. - Хардкод-hex в `StatusDot` заменить на CSS-переменные Mantine (`var(--mantine-color-blue-6)` и т.п.). - Удалить `SEED`, `mockImport`, `delay`, мок-флаг `conflict`. - Вычисление фазы набора (`allNew | allInstalled | updates | mixed`) вынести в чистую функцию рядом с `catalogRoleInstallState` — для юнит-тестов без монтирования компонента. - Комментарии в коде — на английском. ## 5. Тесты - Юнит: фаза набора (4 фазы + пустой набор), хелпер `installedLang`, маппинг каталога в модель компонента. - Обновить `import-from-catalog-message.test.tsx` / `update-from-catalog-message.test.tsx`, если меняются сообщения/формат ответа. - Сервер: тесты на новый формат ответа `importFromCatalog` (списки created/skipped с reason, rename-путь). # Критерии приёмки - [ ] Свёрнутый набор показывает сводку статусов (N new / all installed / N updates / mixed) без раскрытия. - [ ] Главное действие набора: «Install bundle (N)» / «Update all (N)» / статус «Installed» — задизейбленной «мёртвой» кнопки Import больше нет. - [ ] Чекбоксы выбора + «Select all / Deselect all», по умолчанию выбраны все доступные к установке. - [ ] После импорта — инлайн-плашка результата (успех / частично: «M skipped» с именем роли и рабочей «Rename & install»), окно не закрывается. - [ ] «Update all» набора и глобальный работают серией запросов с прогрессом. - [ ] Подсказка про установки на другом языке показывается, когда есть такие роли. - [ ] Состояния loading (скелетоны) / error (retry) / empty — как в хендоффе. - [ ] Светлая/тёмная темы, все строки локализованы (en/ru). - [ ] Старый код модалки удалён, мок-код хендоффа удалён, тесты зелёные. # Вне скоупа - Пакетный серверный эндпоинт обновления (`[API #3]` из README) — при желании отдельной ишьёй. - Модальный диалог конфликта в момент импорта (`[API #4]`, вариант Р6-б с dry-run) — в компоненте реализован fallback на текущем API, его и оставляем. - Поиск по каталогу (Р12) — заложен только визуально, не реализуем.
Owner

Хендофф (1/2): handoff/README.md. Файлы хендоффа не закоммичены в репозиторий — содержимое приложено в этом и следующем комментариях.


Каталог ролей — редизайн. Хендофф для интеграции

Файлы:

  • RoleCatalogModal.tsx — React-компонент на Mantine v7 (готов к интеграции; мок-данные и мок-API помечены и удаляются).
  • Прототип: Каталог ролей - прототип.dc.html — интерактивная спека поведения (эталон состояний и переходов).
  • Макеты состояний: Каталог ролей - редизайн.dc.html.

Что меняется по сути

Раньше окно строилось вокруг настроек импорта + плоского списка строк. Теперь — вокруг наборов как карточек: у каждого набора сводный статус (виден без раскрытия) и одно главное действие. Настройки (язык, конфликты) уведены на второй план, результат импорта виден не закрывая окно.

Зависимости

@mantine/core >= 7, @mantine/hooks, @tabler/icons-react

Компонент использует только штатные Mantine-компоненты: Modal, Accordion, Badge, Button, Checkbox, SegmentedControl, Alert, Skeleton, ThemeIcon, Loader, Tooltip. Кастомных контролов нет. Цвета/радиусы — дефолтная тема Mantine, поэтому светлая/тёмная работают из коробки через MantineProvider (никаких хардкод-цветов, кроме точек-индикаторов статуса).

Модель данных

RoleStatus = 'import' | 'installed' | 'update' | 'skipped'
CatalogRole  { id, emoji, name, description, version, newVersion?, status, installedLang? }
CatalogBundle { id, name, description, roles: CatalogRole[] }

Фаза набора вычисляется из статусов ролей (allNew | allInstalled | updates | mixed) — она и определяет сводку и главную кнопку. Ничего дополнительно с бэка про «фазу» слать не нужно.

Точки интеграции с API

Ищи в коде метки TODO и [API].

  1. Загрузка каталогаGET catalog(lang)CatalogBundle[]. Статус каждой роли считается на бэке относительно воркспейса. Важно: роль, установленная на другом языке, при текущем lang должна прийти как import + installedLang (для подсказки Р5). Раскомментировать useEffect, повесить setView('loading' | 'error' | 'empty' | 'ready').
  2. Импорт набораPOST import(bundleId, lang, roleIds[], policy){ installed[], skipped[] }. Работает на текущем API (один набор за запрос). Передаём policy='skip'; конфликты возвращаются в skipped[], показываем инлайн-плашку.
  3. [API] Пакетное обновление — «Update all (N)» внутри набора и глобальное «Update all» между наборами. Сейчас обновление по одной роли. До доработки — клиентская серия вызовов updateRole() с общим прогрессом на кнопке (кнопка уже в состоянии loading). Помечено [API #3].
  4. [API, опционально] Диалог конфликта в момент импорта (Р6-б) — предпочтительный по ТЗ, но требует dry-run/паузы импорта. В компоненте реализован fallback на текущем API: импорт с policy='skip' → инлайн-плашка «Rename & install» → повторный импорт конфликтной роли с policy='rename'. Если/когда API научится возвращать конфликты до применения — заменить fallback на модальный диалог (макет 1g в файле редизайна). Помечено [API #4].

Как закрыты требования ТЗ

  • Р1 сводка в шапке набора — statusParts + Accordion.Control.
  • Р2 главное действие уровня набора; при «нечего ставить» — статус Installed, не задизейбленная кнопка — primary в BundlePanel.
  • Р3 чекбоксы + «select/deselect all», по умолчанию выбраны все import-роли — defaultSelection, strip над списком.
  • Р4 единая строка роли, различие только в правой зоне — RoleRow.
  • Р5 компактный SegmentedControl, дефолт = uiLang, подсказка при установках на другом языке — блок otherLangInstalls.
  • Р6 конфликты убраны из шапки; fallback-плашка (вариант б помечен как API-доработка).
  • Р7 результат импорта — ResultBanner (success / partial / updated), инлайн, окно не закрывается.
  • Р8 все системные состояния — LoadingState / ErrorState / EmptyState + loading на кнопках.
  • Р9 темы и доступность — дефолтная тема Mantine, видимый фокус штатный, бейджи variant="light", хит-зоны кнопок/чекбоксов ≥ стандарта Mantine.
  • Р10 глобальное «Update all» — Alert сверху при апдейтах в ≥2 наборах.
  • Р11 развёрнутость по умолчанию при малом каталоге — defaultValue={bundles.length <= 3 ? [bundles[0].id] : []}.
  • Р12 задел на рост — при большом каталоге по умолчанию всё свёрнуто; поиск заложен визуально в макете 1c (в компонент не добавлен — по решению команды).

Поведение подгрузки

Сейчас контент набора грузится при раскрытии — компонент это сохраняет (данные ролей приходят с getCatalog, аккордеон только показывает/прячет). Если решите грузить лениво по набору — добавьте onChange на Accordion и ленивую загрузку ролей; сводка в шапке при этом должна приходить сразу (иначе статус нельзя показать без раскрытия).

Локализация

Все строки в компоненте — английские литералы, вынести в i18n при интеграции. Закладывайте запас ширины: RU-лейблы длиннее EN до ~40% (кнопки набора уже гибкие, white-space:nowrap не ставить на локализуемых кнопках).

Известные упрощения мока

  • mockImport симулирует конфликт по флагу conflict на роли Proofreader — удалить вместе с SEED/mockImport.
  • Прогресс пакетных операций — единый спиннер на кнопке; при клиентской серии запросов можно показать «Updating 2/3…» (заготовка в updateBundleAll).
**Хендофф (1/2): `handoff/README.md`.** Файлы хендоффа не закоммичены в репозиторий — содержимое приложено в этом и следующем комментариях. --- # Каталог ролей — редизайн. Хендофф для интеграции Файлы: - `RoleCatalogModal.tsx` — React-компонент на Mantine v7 (готов к интеграции; мок-данные и мок-API помечены и удаляются). - Прототип: `Каталог ролей - прототип.dc.html` — интерактивная спека поведения (эталон состояний и переходов). - Макеты состояний: `Каталог ролей - редизайн.dc.html`. ## Что меняется по сути Раньше окно строилось вокруг настроек импорта + плоского списка строк. Теперь — вокруг **наборов как карточек**: у каждого набора сводный статус (виден без раскрытия) и **одно главное действие**. Настройки (язык, конфликты) уведены на второй план, результат импорта виден не закрывая окно. ## Зависимости ``` @mantine/core >= 7, @mantine/hooks, @tabler/icons-react ``` Компонент использует только штатные Mantine-компоненты: `Modal, Accordion, Badge, Button, Checkbox, SegmentedControl, Alert, Skeleton, ThemeIcon, Loader, Tooltip`. Кастомных контролов нет. Цвета/радиусы — дефолтная тема Mantine, поэтому светлая/тёмная работают из коробки через `MantineProvider` (никаких хардкод-цветов, кроме точек-индикаторов статуса). ## Модель данных ```ts RoleStatus = 'import' | 'installed' | 'update' | 'skipped' CatalogRole { id, emoji, name, description, version, newVersion?, status, installedLang? } CatalogBundle { id, name, description, roles: CatalogRole[] } ``` Фаза набора вычисляется из статусов ролей (`allNew | allInstalled | updates | mixed`) — она и определяет сводку и главную кнопку. Ничего дополнительно с бэка про «фазу» слать не нужно. ## Точки интеграции с API Ищи в коде метки `TODO` и `[API]`. 1. **Загрузка каталога** — `GET catalog(lang)` → `CatalogBundle[]`. Статус каждой роли считается на бэке относительно воркспейса. Важно: роль, установленная на другом языке, при текущем `lang` должна прийти как `import` + `installedLang` (для подсказки Р5). Раскомментировать `useEffect`, повесить `setView('loading' | 'error' | 'empty' | 'ready')`. 2. **Импорт набора** — `POST import(bundleId, lang, roleIds[], policy)` → `{ installed[], skipped[] }`. **Работает на текущем API** (один набор за запрос). Передаём `policy='skip'`; конфликты возвращаются в `skipped[]`, показываем инлайн-плашку. 3. **[API] Пакетное обновление** — «Update all (N)» внутри набора и глобальное «Update all» между наборами. Сейчас обновление по одной роли. До доработки — **клиентская серия** вызовов `updateRole()` с общим прогрессом на кнопке (кнопка уже в состоянии `loading`). Помечено `[API #3]`. 4. **[API, опционально] Диалог конфликта в момент импорта (Р6-б)** — предпочтительный по ТЗ, но требует dry-run/паузы импорта. **В компоненте реализован fallback на текущем API**: импорт с `policy='skip'` → инлайн-плашка «Rename & install» → повторный импорт конфликтной роли с `policy='rename'`. Если/когда API научится возвращать конфликты до применения — заменить fallback на модальный диалог (макет `1g` в файле редизайна). Помечено `[API #4]`. ## Как закрыты требования ТЗ - **Р1** сводка в шапке набора — `statusParts` + `Accordion.Control`. - **Р2** главное действие уровня набора; при «нечего ставить» — статус `Installed`, не задизейбленная кнопка — `primary` в `BundlePanel`. - **Р3** чекбоксы + «select/deselect all», по умолчанию выбраны все `import`-роли — `defaultSelection`, strip над списком. - **Р4** единая строка роли, различие только в правой зоне — `RoleRow`. - **Р5** компактный `SegmentedControl`, дефолт = `uiLang`, подсказка при установках на другом языке — блок `otherLangInstalls`. - **Р6** конфликты убраны из шапки; fallback-плашка (вариант б помечен как API-доработка). - **Р7** результат импорта — `ResultBanner` (success / partial / updated), инлайн, окно не закрывается. - **Р8** все системные состояния — `LoadingState / ErrorState / EmptyState` + `loading` на кнопках. - **Р9** темы и доступность — дефолтная тема Mantine, видимый фокус штатный, бейджи `variant="light"`, хит-зоны кнопок/чекбоксов ≥ стандарта Mantine. - **Р10** глобальное «Update all» — Alert сверху при апдейтах в ≥2 наборах. - **Р11** развёрнутость по умолчанию при малом каталоге — `defaultValue={bundles.length <= 3 ? [bundles[0].id] : []}`. - **Р12** задел на рост — при большом каталоге по умолчанию всё свёрнуто; поиск заложен визуально в макете `1c` (в компонент не добавлен — по решению команды). ## Поведение подгрузки Сейчас контент набора грузится при раскрытии — компонент это сохраняет (данные ролей приходят с `getCatalog`, аккордеон только показывает/прячет). Если решите грузить лениво по набору — добавьте `onChange` на `Accordion` и ленивую загрузку ролей; сводка в шапке при этом должна приходить сразу (иначе статус нельзя показать без раскрытия). ## Локализация Все строки в компоненте — английские литералы, вынести в i18n при интеграции. Закладывайте запас ширины: RU-лейблы длиннее EN до ~40% (кнопки набора уже гибкие, `white-space:nowrap` не ставить на локализуемых кнопках). ## Известные упрощения мока - `mockImport` симулирует конфликт по флагу `conflict` на роли Proofreader — удалить вместе с `SEED`/`mockImport`. - Прогресс пакетных операций — единый спиннер на кнопке; при клиентской серии запросов можно показать «Updating 2/3…» (заготовка в `updateBundleAll`).
Owner

Хендофф (2/2): handoff/RoleCatalogModal.tsx — референс-компонент (Mantine v7; мок-данные и мок-API помечены и удаляются при интеграции).

/**
 * RoleCatalogModal — редизайн окна «Каталог ролей».
 * Mantine v7. Заменяет старое окно импорта.
 *
 * Философия: наборы = продуктовые карточки со сводным статусом и ОДНИМ главным
 * действием (Install bundle / Update all / Installed). Настройки (язык, конфликты)
 * уведены на второй план; результат импорта виден, не закрывая окно.
 *
 * Зависимости: @mantine/core >= 7, @mantine/hooks. Иконки — @tabler/icons-react.
 * Данные/эффекты замоканы TODO-комментариями — подключить к реальному API.
 *
 * === МЕСТА, ТРЕБУЮЩИЕ ДОРАБОТКИ API (помечены [API]) ===
 *  1. GET catalog(lang) -> bundles[] со статусом каждой роли относительно воркспейса.
 *     Статус роли: 'import' | 'installed' | 'update'. Установка на другом языке = 'import'.
 *  2. POST import(bundleId, lang, roleIds[], conflictPolicy) -> {installed[], skipped[]}.
 *     Текущее API это уже умеет (один набор за запрос). conflictPolicy: 'skip' | 'rename'.
 *  3. [API] Пакетное обновление ролей набора («Update all (N)») и глобальное
 *     «Update all» между наборами — сейчас обновление идёт по одной роли.
 *     До доработки — клиентская серия запросов updateRole() с общим прогрессом.
 *  4. [API, опционально] Диалог конфликта «спросить в момент импорта» (Р6-б) требует
 *     dry-run / приостановки импорта. Реализованный здесь fallback работает на текущем
 *     API: импорт с policy='skip', затем инлайн-плашка «Rename & install» повторяет
 *     импорт конфликтной роли с policy='rename'. См. handoff.md.
 */

import { useMemo, useState } from 'react';
import {
  Modal, Accordion, Badge, Button, Checkbox, SegmentedControl, Alert,
  Skeleton, Group, Stack, Text, ThemeIcon, Loader, Tooltip, ActionIcon,
  Box, Center, Anchor,
} from '@mantine/core';
import {
  IconCheck, IconRefresh, IconAlertTriangle, IconInfoCircle,
  IconFolderOff, IconX,
} from '@tabler/icons-react';

// ----------------------------------------------------------------------------
// Типы
// ----------------------------------------------------------------------------
export type RoleStatus = 'import' | 'installed' | 'update' | 'skipped';

export interface CatalogRole {
  id: string;
  emoji: string;
  name: string;
  description: string;
  version: number;
  newVersion?: number;        // для status === 'update'
  status: RoleStatus;
  installedLang?: string;     // язык, на котором роль установлена (для подсказки Р5)
}

export interface CatalogBundle {
  id: string;
  name: string;
  description: string;
  roles: CatalogRole[];
}

type ViewState = 'loading' | 'error' | 'empty' | 'ready';
type ImportResult =
  | { type: 'success'; installed: number; renamed?: number }
  | { type: 'updated'; count: number }
  | { type: 'partial'; installed: number; skipped: number; roleName: string; roleId: string };

interface Props {
  opened: boolean;
  onClose: () => void;
  uiLang: string;                 // язык интерфейса пользователя — дефолт переключателя (Р5)
  availableLangs: string[];       // напр. ['EN', 'RU']
}

// ----------------------------------------------------------------------------
// Компонент
// ----------------------------------------------------------------------------
export function RoleCatalogModal({ opened, onClose, uiLang, availableLangs }: Props) {
  const [lang, setLang] = useState(uiLang);
  const [view, setView] = useState<ViewState>('ready');
  const [bundles, setBundles] = useState<CatalogBundle[]>(SEED); // TODO: from GET catalog(lang)
  const [selected, setSelected] = useState<Record<string, boolean>>(() => defaultSelection(SEED));
  const [results, setResults] = useState<Record<string, ImportResult | undefined>>({});
  const [busyBundle, setBusyBundle] = useState<string | null>(null);
  const [busyRole, setBusyRole] = useState<string | null>(null);

  // TODO: useEffect(() => { setView('loading'); api.getCatalog(lang).then(...).catch(() => setView('error')); }, [lang]);

  const key = (bid: string, rid: string) => `${bid}:${rid}`;
  const importable = (b: CatalogBundle) => b.roles.filter((r) => r.status === 'import');
  const updatable = (b: CatalogBundle) => b.roles.filter((r) => r.status === 'update');
  const selectedImportable = (b: CatalogBundle) =>
    importable(b).filter((r) => selected[key(b.id, r.id)]);

  // Глобальное «обновить всё» (Р10) — если апдейты в ≥2 наборах
  const bundlesWithUpdates = bundles.filter((b) => updatable(b).length > 0);
  const totalUpdates = bundles.reduce((n, b) => n + updatable(b).length, 0);

  // Есть ли установки на другом языке (Р5-подсказка)
  const otherLangInstalls = bundles
    .flatMap((b) => b.roles)
    .filter((r) => r.status === 'import' && r.installedLang && r.installedLang !== lang).length;

  // --- actions ---
  function toggleRole(bid: string, rid: string) {
    setSelected((s) => ({ ...s, [key(bid, rid)]: !s[key(bid, rid)] }));
  }
  function toggleAll(b: CatalogBundle) {
    const all = importable(b).every((r) => selected[key(b.id, r.id)]);
    setSelected((s) => {
      const next = { ...s };
      importable(b).forEach((r) => (next[key(b.id, r.id)] = !all));
      return next;
    });
  }

  async function installBundle(b: CatalogBundle) {
    const roleIds = selectedImportable(b).map((r) => r.id);
    if (roleIds.length === 0) return;
    setBusyBundle(b.id);
    // [API #2] policy='skip' — конфликты вернутся в skipped[], покажем инлайн-плашку
    // const res = await api.import(b.id, lang, roleIds, 'skip');
    const res = await mockImport(b, roleIds);
    setBusyBundle(null);
    applyImportResult(b.id, res);
  }

  async function renameInstall(bid: string, roleId: string) {
    setBusyBundle(bid);
    // [API #2] policy='rename' для одной конфликтной роли
    // await api.import(bid, lang, [roleId], 'rename');
    await delay(900);
    setBundles((bs) =>
      bs.map((b) =>
        b.id !== bid ? b : { ...b, roles: b.roles.map((r) => (r.id === roleId ? { ...r, status: 'installed' } : r)) },
      ),
    );
    const total = bundles.find((b) => b.id === bid)!.roles.filter((r) => r.status === 'installed').length + 1;
    setResults((r) => ({ ...r, [bid]: { type: 'success', installed: total, renamed: 1 } }));
    setBusyBundle(null);
  }

  async function updateRole(bid: string, rid: string) {
    setBusyRole(key(bid, rid));
    // [API] обновление по одной роли — текущее API
    await delay(900);
    setBundles((bs) =>
      bs.map((b) =>
        b.id !== bid ? b : { ...b, roles: b.roles.map((r) => (r.id === rid ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) },
      ),
    );
    setBusyRole(null);
  }

  async function updateBundleAll(b: CatalogBundle) {
    const ups = updatable(b);
    if (!ups.length) return;
    setBusyBundle(b.id);
    // [API #3] нет пакетного эндпоинта — серия updateRole с общим прогрессом
    await delay(1100);
    setBundles((bs) =>
      bs.map((x) =>
        x.id !== b.id ? x : { ...x, roles: x.roles.map((r) => (r.status === 'update' ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) },
      ),
    );
    setResults((r) => ({ ...r, [b.id]: { type: 'updated', count: ups.length } }));
    setBusyBundle(null);
  }

  async function updateAllGlobal() {
    setBusyBundle('__all__');
    await delay(1200); // [API #3]
    setBundles((bs) =>
      bs.map((b) => ({ ...b, roles: b.roles.map((r) => (r.status === 'update' ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) })),
    );
    setBusyBundle(null);
  }

  function applyImportResult(bid: string, res: { installed: string[]; skipped: CatalogRole[] }) {
    setBundles((bs) =>
      bs.map((b) => {
        if (b.id !== bid) return b;
        return {
          ...b,
          roles: b.roles.map((r) => {
            if (res.installed.includes(r.id)) return { ...r, status: 'installed' as RoleStatus };
            if (res.skipped.find((s) => s.id === r.id)) return { ...r, status: 'skipped' as RoleStatus };
            return r;
          }),
        };
      }),
    );
    setResults((r) => ({
      ...r,
      [bid]:
        res.skipped.length > 0
          ? { type: 'partial', installed: res.installed.length, skipped: res.skipped.length, roleName: res.skipped[0].name, roleId: res.skipped[0].id }
          : { type: 'success', installed: res.installed.length },
    }));
  }

  // --- render ---
  return (
    <Modal
      opened={opened}
      onClose={onClose}
      size={640}
      title={<Text fw={600} fz="lg">Role catalog</Text>}
      styles={{ header: { alignItems: 'center' } }}
    >
      {/* Переключатель языка контента — компактный, в правой части шапки (Р5).
          В Mantine удобнее вынести в область title через кастомную шапку; здесь — строкой сверху. */}
      <Group justify="flex-end" gap="xs" mb="md">
        <Text fz="xs" c="dimmed">Content</Text>
        <SegmentedControl
          size="xs"
          value={lang}
          onChange={setLang}
          data={availableLangs.map((l) => ({ label: l, value: l }))}
        />
        <Tooltip label="Content language of the roles" withArrow>
          <ThemeIcon variant="transparent" c="dimmed" size="sm"><IconInfoCircle size={16} /></ThemeIcon>
        </Tooltip>
      </Group>

      {view === 'error' && <ErrorState onRetry={() => setView('ready')} />}
      {view === 'empty' && <EmptyState />}
      {view === 'loading' && <LoadingState />}

      {view === 'ready' && (
        <Stack gap="sm">
          {/* Р10 — глобальное «обновить всё» */}
          {bundlesWithUpdates.length >= 2 && (
            <Alert variant="light" color="blue" icon={<IconRefresh size={16} />} p="xs">
              <Group gap="sm" wrap="nowrap">
                <Text fz="sm" fw={500}>{totalUpdates} updates available in {bundlesWithUpdates.length} bundles</Text>
                <Button size="compact-sm" ml="auto" onClick={updateAllGlobal} loading={busyBundle === '__all__'}>
                  Update all ({totalUpdates})
                </Button>
              </Group>
            </Alert>
          )}

          {/* Р5 — подсказка про установку на другом языке */}
          {otherLangInstalls > 0 && (
            <Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} p="xs">
              <Text fz="sm">
                {otherLangInstalls} roles are installed in another language. Versions in a different
                language install separately and appear as new.
              </Text>
            </Alert>
          )}

          <Accordion multiple defaultValue={bundles.length <= 3 ? [bundles[0]?.id] : []} variant="separated">
            {bundles.map((b) => (
              <BundlePanel
                key={b.id}
                bundle={b}
                lang={lang}
                selected={selected}
                result={results[b.id]}
                busyBundle={busyBundle}
                busyRole={busyRole}
                importable={importable}
                updatable={updatable}
                selectedImportable={selectedImportable}
                onToggleRole={toggleRole}
                onToggleAll={toggleAll}
                onInstall={installBundle}
                onUpdateAll={updateBundleAll}
                onUpdateRole={updateRole}
                onRenameInstall={renameInstall}
                onDismissResult={(id) => setResults((r) => ({ ...r, [id]: undefined }))}
              />
            ))}
          </Accordion>
        </Stack>
      )}
    </Modal>
  );
}

// ----------------------------------------------------------------------------
// Панель набора
// ----------------------------------------------------------------------------
function BundlePanel(props: any) {
  const {
    bundle: b, selected, result, busyBundle, busyRole,
    importable, updatable, selectedImportable,
    onToggleRole, onToggleAll, onInstall, onUpdateAll, onUpdateRole, onRenameInstall, onDismissResult,
  } = props;

  const imp = importable(b);
  const ups = updatable(b);
  const installedCount = b.roles.filter((r: CatalogRole) => r.status === 'installed').length;
  const selCount = selectedImportable(b).length;
  const busy = busyBundle === b.id || busyBundle === '__all__';
  const k = (rid: string) => `${b.id}:${rid}`;

  // Сводный статус (Р1) — считывается без раскрытия
  const phase =
    imp.length === 0 && ups.length === 0 ? 'allInstalled'
    : ups.length > 0 && imp.length === 0 ? 'updates'
    : imp.length > 0 && installedCount === 0 && ups.length === 0 ? 'allNew'
    : 'mixed';

  const statusParts = useMemo(() => {
    if (phase === 'allNew') return [<StatusDot key="n" color="blue">{imp.length} new  none installed</StatusDot>];
    if (phase === 'allInstalled') return [<StatusDot key="a" color="green">All installed · up to date</StatusDot>];
    if (phase === 'updates') return [<StatusDot key="u" color="orange">{ups.length} update{ups.length > 1 ? 's' : ''} · {installedCount} up to date</StatusDot>];
    return [
      imp.length ? <StatusDot key="n" color="blue">{imp.length} new</StatusDot> : null,
      installedCount ? <StatusDot key="i" color="gray">{installedCount} installed</StatusDot> : null,
      ups.length ? <StatusDot key="u" color="orange">{ups.length} update{ups.length > 1 ? 's' : ''}</StatusDot> : null,
    ].filter(Boolean);
  }, [phase, imp.length, ups.length, installedCount]);

  // Главное действие набора (Р2)
  const primary = (() => {
    if (phase === 'allInstalled')
      return <Group gap={5} c="green.7"><IconCheck size={16} /><Text fz="sm" fw={600}>Installed</Text></Group>;
    if (phase === 'updates')
      return <Button size="xs" color="orange" variant="light" loading={busy} onClick={(e: any) => { e.stopPropagation(); onUpdateAll(b); }}>Update all ({ups.length})</Button>;
    const label = phase === 'mixed' ? (selCount > 0 ? `Install ${selCount} selected` : 'Install bundle') : `Install bundle (${selCount})`;
    return <Button size="xs" loading={busy} disabled={selCount === 0} onClick={(e: any) => { e.stopPropagation(); onInstall(b); }}>{label}</Button>;
  })();

  const allChecked = imp.length > 0 && imp.every((r: CatalogRole) => selected[k(r.id)]);

  return (
    <Accordion.Item value={b.id}>
      <Accordion.Control>
        <Group justify="space-between" wrap="nowrap" align="flex-start">
          <div style={{ minWidth: 0 }}>
            <Group gap="xs" align="baseline"><Text fw={600}>{b.name}</Text><Text fz="sm" c="dimmed">{b.roles.length} roles</Text></Group>
            <Text fz="sm" c="dimmed">{b.description}</Text>
            <Group gap="md" mt={4}>{statusParts}</Group>
          </div>
          {/* onClick.stopPropagation чтобы клик по кнопке не раскрывал аккордеон */}
          <Box onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>{primary}</Box>
        </Group>
      </Accordion.Control>

      <Accordion.Panel>
        {/* Р7 — результат импорта */}
        {result && <ResultBanner result={result} onRename={onRenameInstall} onDismiss={() => onDismissResult(b.id)} bundleId={b.id} />}

        {/* Р3 — выбрать/снять все (вторичный слой) */}
        {imp.length > 1 && (
          <Group gap="sm" py={6} px="xs" mb={4} bg="var(--mantine-color-default-hover)" style={{ borderRadius: 6 }}>
            <Checkbox
              size="xs"
              checked={allChecked}
              indeterminate={!allChecked && selCount > 0}
              onChange={() => onToggleAll(b)}
              label={<Text fz="xs" fw={500}>{selCount} of {imp.length} selected</Text>}
            />
            <Anchor component="button" fz="xs" fw={600} ml="auto" onClick={() => onToggleAll(b)}>
              {allChecked ? 'Deselect all' : 'Select all'}
            </Anchor>
          </Group>
        )}

        {/* Р4 — единая строка роли: слева emoji+имя+описание, справа фиксированная зона статуса/действия */}
        <Stack gap={0}>
          {b.roles.map((r: CatalogRole) => (
            <RoleRow
              key={r.id}
              role={r}
              checked={!!selected[k(r.id)]}
              busy={busyRole === k(r.id)}
              onToggle={() => onToggleRole(b.id, r.id)}
              onUpdate={() => onUpdateRole(b.id, r.id)}
            />
          ))}
        </Stack>
      </Accordion.Panel>
    </Accordion.Item>
  );
}

// ----------------------------------------------------------------------------
// Строка роли — одинаковая структура для всех состояний (Р4)
// ----------------------------------------------------------------------------
function RoleRow({ role: r, checked, busy, onToggle, onUpdate }: any) {
  return (
    <Group gap="sm" wrap="nowrap" py={8} px="xs" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
      <Text fz="lg" w={24} ta="center" style={{ flexShrink: 0 }}>{r.emoji}</Text>
      <div style={{ flex: 1, minWidth: 0 }}>
        <Group gap={6} align="baseline">
          <Text fz="sm" fw={600}>{r.name}</Text>
          <Text fz={11} c="dimmed">v{r.version}</Text>
        </Group>
        <Text fz="xs" c="dimmed" truncate>{r.description}</Text>
      </div>
      {/* фиксированная правая зона — различается ТОЛЬКО содержимым */}
      <Group justify="flex-end" style={{ minWidth: 100, flexShrink: 0 }}>
        {r.status === 'import' && <Checkbox checked={checked} onChange={onToggle} aria-label={`Select ${r.name}`} />}
        {r.status === 'installed' && <Badge color="gray" variant="light">Installed</Badge>}
        {r.status === 'skipped' && <Badge color="yellow" variant="light">Skipped</Badge>}
        {r.status === 'update' && (
          <Group gap="xs" wrap="nowrap">
            <Badge color="orange" variant="light">v{r.version}  v{r.newVersion}</Badge>
            <Button size="compact-xs" color="orange" variant="light" loading={busy} onClick={onUpdate}>Update</Button>
          </Group>
        )}
      </Group>
    </Group>
  );
}

// ----------------------------------------------------------------------------
// Плашка результата импорта (Р7)
// ----------------------------------------------------------------------------
function ResultBanner({ result, onRename, onDismiss, bundleId }: any) {
  if (result.type === 'success') {
    return (
      <Alert variant="light" color="green" icon={<IconCheck size={16} />} p="xs" mb="sm"
        withCloseButton onClose={onDismiss}>
        <Text fz="sm" fw={500}>{result.installed} roles installed{result.renamed ? ` · ${result.renamed} renamed` : ''}</Text>
      </Alert>
    );
  }
  if (result.type === 'updated') {
    return (
      <Alert variant="light" color="green" icon={<IconCheck size={16} />} p="xs" mb="sm"
        withCloseButton onClose={onDismiss}>
        <Text fz="sm" fw={500}>{result.count} roles updated</Text>
      </Alert>
    );
  }
  // partial — конфликт имени (Р6 fallback / Р7 частичный успех)
  return (
    <Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs" mb="sm">
      <Group wrap="nowrap" align="flex-start">
        <div style={{ flex: 1 }}>
          <Text fz="sm" fw={600}>Installed {result.installed} · {result.skipped} skipped</Text>
          <Text fz="xs" c="dimmed">A role named {result.roleName} already exists in this workspace.</Text>
        </div>
        <Button size="compact-xs" color="yellow" variant="default" onClick={() => onRename(bundleId, result.roleId)}>
          Rename &amp; install
        </Button>
      </Group>
    </Alert>
  );
}

// ----------------------------------------------------------------------------
// Системные состояния (Р8)
// ----------------------------------------------------------------------------
function LoadingState() {
  return (
    <Stack gap="sm">
      {[0, 1].map((i) => (
        <Group key={i} justify="space-between" p="md" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }}>
          <Stack gap={8} style={{ flex: 1 }}>
            <Skeleton h={12} w="42%" /><Skeleton h={9} w="75%" /><Skeleton h={9} w="32%" />
          </Stack>
          <Skeleton h={30} w={130} />
        </Group>
      ))}
    </Stack>
  );
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
  return (
    <Center py={48}><Stack align="center" gap="sm">
      <ThemeIcon color="red" variant="light" size={46} radius="xl"><IconAlertTriangle size={22} /></ThemeIcon>
      <Text fw={600}>Couldnt load the catalog</Text>
      <Text fz="sm" c="dimmed" ta="center" maw={300}>Check your connection and try again. Installed roles are not affected.</Text>
      <Button variant="default" leftSection={<IconRefresh size={16} />} onClick={onRetry} mt={4}>Retry</Button>
    </Stack></Center>
  );
}
function EmptyState() {
  return (
    <Center py={48}><Stack align="center" gap="sm">
      <ThemeIcon color="gray" variant="light" size={46} radius="xl"><IconFolderOff size={22} /></ThemeIcon>
      <Text fw={600}>The catalog is empty</Text>
      <Text fz="sm" c="dimmed" ta="center" maw={300}>No role bundles are published for this language yet. Try switching the content language.</Text>
    </Stack></Center>
  );
}

// ----------------------------------------------------------------------------
// Мелочи
// ----------------------------------------------------------------------------
function StatusDot({ color, children }: { color: string; children: React.ReactNode }) {
  const c = { blue: 'blue.7', gray: 'gray.6', orange: 'orange.7', green: 'green.7' }[color] ?? 'dimmed';
  const dot = { blue: '#228be6', gray: '#adb5bd', orange: '#fd7e14', green: '#40c057' }[color];
  return (
    <Group gap={6} wrap="nowrap">
      <Box w={7} h={7} style={{ borderRadius: '50%', background: dot }} />
      <Text fz="xs" fw={600} c={c}>{children}</Text>
    </Group>
  );
}

// ----------------------------------------------------------------------------
// МОК-данные и мок-API — удалить при интеграции
// ----------------------------------------------------------------------------
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));

function defaultSelection(bs: CatalogBundle[]): Record<string, boolean> {
  const sel: Record<string, boolean> = {};
  bs.forEach((b) => b.roles.forEach((r) => { if (r.status === 'import') sel[`${b.id}:${r.id}`] = true; }));
  return sel; // Р3 — по умолчанию выбраны все доступные к установке
}

async function mockImport(b: CatalogBundle, roleIds: string[]) {
  await delay(1100);
  const skipped = b.roles.filter((r) => roleIds.includes(r.id) && (r as any).conflict);
  const installed = roleIds.filter((id) => !skipped.find((s) => s.id === id));
  return { installed, skipped };
}

const SEED: CatalogBundle[] = [
  {
    id: 'editorial', name: 'Editorial bundle', description: 'Writing, editing and QA roles for content teams.',
    roles: [
      { id: 'copy', emoji: '✍️', name: 'Copywriter', description: 'Drafts articles, posts and landing copy.', version: 1, status: 'import' },
      { id: 'edit', emoji: '🪶', name: 'Editor', description: 'Rewrites drafts for clarity, tone and structure.', version: 1, status: 'import' },
      { id: 'proof', emoji: '🔍', name: 'Proofreader', description: 'Catches typos, grammar and style slips.', version: 1, status: 'import', ...( { conflict: true } as any) },
      { id: 'head', emoji: '📰', name: 'Headline writer', description: 'Generates titles, decks and social hooks.', version: 1, status: 'import' },
      { id: 'style', emoji: '🧭', name: 'Style keeper', description: 'Enforces your style guide across texts.', version: 1, status: 'import' },
    ],
  },
  {
    id: 'marketing', name: 'Marketing bundle', description: 'Campaign planning, email and performance analysis.',
    roles: [
      { id: 'camp', emoji: '📣', name: 'Campaign planner', description: 'Plans multi-channel campaign timelines.', version: 1, status: 'import' },
      { id: 'mail', emoji: '✉️', name: 'Email writer', description: 'Writes lifecycle and campaign emails.', version: 1, status: 'import' },
      { id: 'ads', emoji: '📊', name: 'Ads analyst', description: 'Reviews ad spend and performance.', version: 1, status: 'installed' },
      { id: 'seo', emoji: '🧲', name: 'SEO specialist', description: 'Optimizes pages for search intent.', version: 1, newVersion: 2, status: 'update' },
      { id: 'pos', emoji: '🎯', name: 'Positioning coach', description: 'Sharpens value props and messaging.', version: 1, status: 'installed' },
    ],
  },
  {
    id: 'research', name: 'Research', description: 'Deep-dive analysis and source-backed answers.',
    roles: [
      { id: 'ana', emoji: '🔬', name: 'Research analyst', description: 'Synthesizes sources into cited briefs.', version: 2, status: 'installed' },
    ],
  },
];

**Хендофф (2/2): `handoff/RoleCatalogModal.tsx`** — референс-компонент (Mantine v7; мок-данные и мок-API помечены и удаляются при интеграции). ```tsx /** * RoleCatalogModal — редизайн окна «Каталог ролей». * Mantine v7. Заменяет старое окно импорта. * * Философия: наборы = продуктовые карточки со сводным статусом и ОДНИМ главным * действием (Install bundle / Update all / Installed). Настройки (язык, конфликты) * уведены на второй план; результат импорта виден, не закрывая окно. * * Зависимости: @mantine/core >= 7, @mantine/hooks. Иконки — @tabler/icons-react. * Данные/эффекты замоканы TODO-комментариями — подключить к реальному API. * * === МЕСТА, ТРЕБУЮЩИЕ ДОРАБОТКИ API (помечены [API]) === * 1. GET catalog(lang) -> bundles[] со статусом каждой роли относительно воркспейса. * Статус роли: 'import' | 'installed' | 'update'. Установка на другом языке = 'import'. * 2. POST import(bundleId, lang, roleIds[], conflictPolicy) -> {installed[], skipped[]}. * Текущее API это уже умеет (один набор за запрос). conflictPolicy: 'skip' | 'rename'. * 3. [API] Пакетное обновление ролей набора («Update all (N)») и глобальное * «Update all» между наборами — сейчас обновление идёт по одной роли. * До доработки — клиентская серия запросов updateRole() с общим прогрессом. * 4. [API, опционально] Диалог конфликта «спросить в момент импорта» (Р6-б) требует * dry-run / приостановки импорта. Реализованный здесь fallback работает на текущем * API: импорт с policy='skip', затем инлайн-плашка «Rename & install» повторяет * импорт конфликтной роли с policy='rename'. См. handoff.md. */ import { useMemo, useState } from 'react'; import { Modal, Accordion, Badge, Button, Checkbox, SegmentedControl, Alert, Skeleton, Group, Stack, Text, ThemeIcon, Loader, Tooltip, ActionIcon, Box, Center, Anchor, } from '@mantine/core'; import { IconCheck, IconRefresh, IconAlertTriangle, IconInfoCircle, IconFolderOff, IconX, } from '@tabler/icons-react'; // ---------------------------------------------------------------------------- // Типы // ---------------------------------------------------------------------------- export type RoleStatus = 'import' | 'installed' | 'update' | 'skipped'; export interface CatalogRole { id: string; emoji: string; name: string; description: string; version: number; newVersion?: number; // для status === 'update' status: RoleStatus; installedLang?: string; // язык, на котором роль установлена (для подсказки Р5) } export interface CatalogBundle { id: string; name: string; description: string; roles: CatalogRole[]; } type ViewState = 'loading' | 'error' | 'empty' | 'ready'; type ImportResult = | { type: 'success'; installed: number; renamed?: number } | { type: 'updated'; count: number } | { type: 'partial'; installed: number; skipped: number; roleName: string; roleId: string }; interface Props { opened: boolean; onClose: () => void; uiLang: string; // язык интерфейса пользователя — дефолт переключателя (Р5) availableLangs: string[]; // напр. ['EN', 'RU'] } // ---------------------------------------------------------------------------- // Компонент // ---------------------------------------------------------------------------- export function RoleCatalogModal({ opened, onClose, uiLang, availableLangs }: Props) { const [lang, setLang] = useState(uiLang); const [view, setView] = useState<ViewState>('ready'); const [bundles, setBundles] = useState<CatalogBundle[]>(SEED); // TODO: from GET catalog(lang) const [selected, setSelected] = useState<Record<string, boolean>>(() => defaultSelection(SEED)); const [results, setResults] = useState<Record<string, ImportResult | undefined>>({}); const [busyBundle, setBusyBundle] = useState<string | null>(null); const [busyRole, setBusyRole] = useState<string | null>(null); // TODO: useEffect(() => { setView('loading'); api.getCatalog(lang).then(...).catch(() => setView('error')); }, [lang]); const key = (bid: string, rid: string) => `${bid}:${rid}`; const importable = (b: CatalogBundle) => b.roles.filter((r) => r.status === 'import'); const updatable = (b: CatalogBundle) => b.roles.filter((r) => r.status === 'update'); const selectedImportable = (b: CatalogBundle) => importable(b).filter((r) => selected[key(b.id, r.id)]); // Глобальное «обновить всё» (Р10) — если апдейты в ≥2 наборах const bundlesWithUpdates = bundles.filter((b) => updatable(b).length > 0); const totalUpdates = bundles.reduce((n, b) => n + updatable(b).length, 0); // Есть ли установки на другом языке (Р5-подсказка) const otherLangInstalls = bundles .flatMap((b) => b.roles) .filter((r) => r.status === 'import' && r.installedLang && r.installedLang !== lang).length; // --- actions --- function toggleRole(bid: string, rid: string) { setSelected((s) => ({ ...s, [key(bid, rid)]: !s[key(bid, rid)] })); } function toggleAll(b: CatalogBundle) { const all = importable(b).every((r) => selected[key(b.id, r.id)]); setSelected((s) => { const next = { ...s }; importable(b).forEach((r) => (next[key(b.id, r.id)] = !all)); return next; }); } async function installBundle(b: CatalogBundle) { const roleIds = selectedImportable(b).map((r) => r.id); if (roleIds.length === 0) return; setBusyBundle(b.id); // [API #2] policy='skip' — конфликты вернутся в skipped[], покажем инлайн-плашку // const res = await api.import(b.id, lang, roleIds, 'skip'); const res = await mockImport(b, roleIds); setBusyBundle(null); applyImportResult(b.id, res); } async function renameInstall(bid: string, roleId: string) { setBusyBundle(bid); // [API #2] policy='rename' для одной конфликтной роли // await api.import(bid, lang, [roleId], 'rename'); await delay(900); setBundles((bs) => bs.map((b) => b.id !== bid ? b : { ...b, roles: b.roles.map((r) => (r.id === roleId ? { ...r, status: 'installed' } : r)) }, ), ); const total = bundles.find((b) => b.id === bid)!.roles.filter((r) => r.status === 'installed').length + 1; setResults((r) => ({ ...r, [bid]: { type: 'success', installed: total, renamed: 1 } })); setBusyBundle(null); } async function updateRole(bid: string, rid: string) { setBusyRole(key(bid, rid)); // [API] обновление по одной роли — текущее API await delay(900); setBundles((bs) => bs.map((b) => b.id !== bid ? b : { ...b, roles: b.roles.map((r) => (r.id === rid ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) }, ), ); setBusyRole(null); } async function updateBundleAll(b: CatalogBundle) { const ups = updatable(b); if (!ups.length) return; setBusyBundle(b.id); // [API #3] нет пакетного эндпоинта — серия updateRole с общим прогрессом await delay(1100); setBundles((bs) => bs.map((x) => x.id !== b.id ? x : { ...x, roles: x.roles.map((r) => (r.status === 'update' ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) }, ), ); setResults((r) => ({ ...r, [b.id]: { type: 'updated', count: ups.length } })); setBusyBundle(null); } async function updateAllGlobal() { setBusyBundle('__all__'); await delay(1200); // [API #3] setBundles((bs) => bs.map((b) => ({ ...b, roles: b.roles.map((r) => (r.status === 'update' ? { ...r, status: 'installed', version: r.newVersion ?? r.version } : r)) })), ); setBusyBundle(null); } function applyImportResult(bid: string, res: { installed: string[]; skipped: CatalogRole[] }) { setBundles((bs) => bs.map((b) => { if (b.id !== bid) return b; return { ...b, roles: b.roles.map((r) => { if (res.installed.includes(r.id)) return { ...r, status: 'installed' as RoleStatus }; if (res.skipped.find((s) => s.id === r.id)) return { ...r, status: 'skipped' as RoleStatus }; return r; }), }; }), ); setResults((r) => ({ ...r, [bid]: res.skipped.length > 0 ? { type: 'partial', installed: res.installed.length, skipped: res.skipped.length, roleName: res.skipped[0].name, roleId: res.skipped[0].id } : { type: 'success', installed: res.installed.length }, })); } // --- render --- return ( <Modal opened={opened} onClose={onClose} size={640} title={<Text fw={600} fz="lg">Role catalog</Text>} styles={{ header: { alignItems: 'center' } }} > {/* Переключатель языка контента — компактный, в правой части шапки (Р5). В Mantine удобнее вынести в область title через кастомную шапку; здесь — строкой сверху. */} <Group justify="flex-end" gap="xs" mb="md"> <Text fz="xs" c="dimmed">Content</Text> <SegmentedControl size="xs" value={lang} onChange={setLang} data={availableLangs.map((l) => ({ label: l, value: l }))} /> <Tooltip label="Content language of the roles" withArrow> <ThemeIcon variant="transparent" c="dimmed" size="sm"><IconInfoCircle size={16} /></ThemeIcon> </Tooltip> </Group> {view === 'error' && <ErrorState onRetry={() => setView('ready')} />} {view === 'empty' && <EmptyState />} {view === 'loading' && <LoadingState />} {view === 'ready' && ( <Stack gap="sm"> {/* Р10 — глобальное «обновить всё» */} {bundlesWithUpdates.length >= 2 && ( <Alert variant="light" color="blue" icon={<IconRefresh size={16} />} p="xs"> <Group gap="sm" wrap="nowrap"> <Text fz="sm" fw={500}>{totalUpdates} updates available in {bundlesWithUpdates.length} bundles</Text> <Button size="compact-sm" ml="auto" onClick={updateAllGlobal} loading={busyBundle === '__all__'}> Update all ({totalUpdates}) </Button> </Group> </Alert> )} {/* Р5 — подсказка про установку на другом языке */} {otherLangInstalls > 0 && ( <Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} p="xs"> <Text fz="sm"> {otherLangInstalls} roles are installed in another language. Versions in a different language install separately and appear as new. </Text> </Alert> )} <Accordion multiple defaultValue={bundles.length <= 3 ? [bundles[0]?.id] : []} variant="separated"> {bundles.map((b) => ( <BundlePanel key={b.id} bundle={b} lang={lang} selected={selected} result={results[b.id]} busyBundle={busyBundle} busyRole={busyRole} importable={importable} updatable={updatable} selectedImportable={selectedImportable} onToggleRole={toggleRole} onToggleAll={toggleAll} onInstall={installBundle} onUpdateAll={updateBundleAll} onUpdateRole={updateRole} onRenameInstall={renameInstall} onDismissResult={(id) => setResults((r) => ({ ...r, [id]: undefined }))} /> ))} </Accordion> </Stack> )} </Modal> ); } // ---------------------------------------------------------------------------- // Панель набора // ---------------------------------------------------------------------------- function BundlePanel(props: any) { const { bundle: b, selected, result, busyBundle, busyRole, importable, updatable, selectedImportable, onToggleRole, onToggleAll, onInstall, onUpdateAll, onUpdateRole, onRenameInstall, onDismissResult, } = props; const imp = importable(b); const ups = updatable(b); const installedCount = b.roles.filter((r: CatalogRole) => r.status === 'installed').length; const selCount = selectedImportable(b).length; const busy = busyBundle === b.id || busyBundle === '__all__'; const k = (rid: string) => `${b.id}:${rid}`; // Сводный статус (Р1) — считывается без раскрытия const phase = imp.length === 0 && ups.length === 0 ? 'allInstalled' : ups.length > 0 && imp.length === 0 ? 'updates' : imp.length > 0 && installedCount === 0 && ups.length === 0 ? 'allNew' : 'mixed'; const statusParts = useMemo(() => { if (phase === 'allNew') return [<StatusDot key="n" color="blue">{imp.length} new — none installed</StatusDot>]; if (phase === 'allInstalled') return [<StatusDot key="a" color="green">All installed · up to date</StatusDot>]; if (phase === 'updates') return [<StatusDot key="u" color="orange">{ups.length} update{ups.length > 1 ? 's' : ''} · {installedCount} up to date</StatusDot>]; return [ imp.length ? <StatusDot key="n" color="blue">{imp.length} new</StatusDot> : null, installedCount ? <StatusDot key="i" color="gray">{installedCount} installed</StatusDot> : null, ups.length ? <StatusDot key="u" color="orange">{ups.length} update{ups.length > 1 ? 's' : ''}</StatusDot> : null, ].filter(Boolean); }, [phase, imp.length, ups.length, installedCount]); // Главное действие набора (Р2) const primary = (() => { if (phase === 'allInstalled') return <Group gap={5} c="green.7"><IconCheck size={16} /><Text fz="sm" fw={600}>Installed</Text></Group>; if (phase === 'updates') return <Button size="xs" color="orange" variant="light" loading={busy} onClick={(e: any) => { e.stopPropagation(); onUpdateAll(b); }}>Update all ({ups.length})</Button>; const label = phase === 'mixed' ? (selCount > 0 ? `Install ${selCount} selected` : 'Install bundle') : `Install bundle (${selCount})`; return <Button size="xs" loading={busy} disabled={selCount === 0} onClick={(e: any) => { e.stopPropagation(); onInstall(b); }}>{label}</Button>; })(); const allChecked = imp.length > 0 && imp.every((r: CatalogRole) => selected[k(r.id)]); return ( <Accordion.Item value={b.id}> <Accordion.Control> <Group justify="space-between" wrap="nowrap" align="flex-start"> <div style={{ minWidth: 0 }}> <Group gap="xs" align="baseline"><Text fw={600}>{b.name}</Text><Text fz="sm" c="dimmed">{b.roles.length} roles</Text></Group> <Text fz="sm" c="dimmed">{b.description}</Text> <Group gap="md" mt={4}>{statusParts}</Group> </div> {/* onClick.stopPropagation чтобы клик по кнопке не раскрывал аккордеон */} <Box onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>{primary}</Box> </Group> </Accordion.Control> <Accordion.Panel> {/* Р7 — результат импорта */} {result && <ResultBanner result={result} onRename={onRenameInstall} onDismiss={() => onDismissResult(b.id)} bundleId={b.id} />} {/* Р3 — выбрать/снять все (вторичный слой) */} {imp.length > 1 && ( <Group gap="sm" py={6} px="xs" mb={4} bg="var(--mantine-color-default-hover)" style={{ borderRadius: 6 }}> <Checkbox size="xs" checked={allChecked} indeterminate={!allChecked && selCount > 0} onChange={() => onToggleAll(b)} label={<Text fz="xs" fw={500}>{selCount} of {imp.length} selected</Text>} /> <Anchor component="button" fz="xs" fw={600} ml="auto" onClick={() => onToggleAll(b)}> {allChecked ? 'Deselect all' : 'Select all'} </Anchor> </Group> )} {/* Р4 — единая строка роли: слева emoji+имя+описание, справа фиксированная зона статуса/действия */} <Stack gap={0}> {b.roles.map((r: CatalogRole) => ( <RoleRow key={r.id} role={r} checked={!!selected[k(r.id)]} busy={busyRole === k(r.id)} onToggle={() => onToggleRole(b.id, r.id)} onUpdate={() => onUpdateRole(b.id, r.id)} /> ))} </Stack> </Accordion.Panel> </Accordion.Item> ); } // ---------------------------------------------------------------------------- // Строка роли — одинаковая структура для всех состояний (Р4) // ---------------------------------------------------------------------------- function RoleRow({ role: r, checked, busy, onToggle, onUpdate }: any) { return ( <Group gap="sm" wrap="nowrap" py={8} px="xs" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}> <Text fz="lg" w={24} ta="center" style={{ flexShrink: 0 }}>{r.emoji}</Text> <div style={{ flex: 1, minWidth: 0 }}> <Group gap={6} align="baseline"> <Text fz="sm" fw={600}>{r.name}</Text> <Text fz={11} c="dimmed">v{r.version}</Text> </Group> <Text fz="xs" c="dimmed" truncate>{r.description}</Text> </div> {/* фиксированная правая зона — различается ТОЛЬКО содержимым */} <Group justify="flex-end" style={{ minWidth: 100, flexShrink: 0 }}> {r.status === 'import' && <Checkbox checked={checked} onChange={onToggle} aria-label={`Select ${r.name}`} />} {r.status === 'installed' && <Badge color="gray" variant="light">Installed</Badge>} {r.status === 'skipped' && <Badge color="yellow" variant="light">Skipped</Badge>} {r.status === 'update' && ( <Group gap="xs" wrap="nowrap"> <Badge color="orange" variant="light">v{r.version} → v{r.newVersion}</Badge> <Button size="compact-xs" color="orange" variant="light" loading={busy} onClick={onUpdate}>Update</Button> </Group> )} </Group> </Group> ); } // ---------------------------------------------------------------------------- // Плашка результата импорта (Р7) // ---------------------------------------------------------------------------- function ResultBanner({ result, onRename, onDismiss, bundleId }: any) { if (result.type === 'success') { return ( <Alert variant="light" color="green" icon={<IconCheck size={16} />} p="xs" mb="sm" withCloseButton onClose={onDismiss}> <Text fz="sm" fw={500}>{result.installed} roles installed{result.renamed ? ` · ${result.renamed} renamed` : ''}</Text> </Alert> ); } if (result.type === 'updated') { return ( <Alert variant="light" color="green" icon={<IconCheck size={16} />} p="xs" mb="sm" withCloseButton onClose={onDismiss}> <Text fz="sm" fw={500}>{result.count} roles updated</Text> </Alert> ); } // partial — конфликт имени (Р6 fallback / Р7 частичный успех) return ( <Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs" mb="sm"> <Group wrap="nowrap" align="flex-start"> <div style={{ flex: 1 }}> <Text fz="sm" fw={600}>Installed {result.installed} · {result.skipped} skipped</Text> <Text fz="xs" c="dimmed">A role named “{result.roleName}” already exists in this workspace.</Text> </div> <Button size="compact-xs" color="yellow" variant="default" onClick={() => onRename(bundleId, result.roleId)}> Rename &amp; install </Button> </Group> </Alert> ); } // ---------------------------------------------------------------------------- // Системные состояния (Р8) // ---------------------------------------------------------------------------- function LoadingState() { return ( <Stack gap="sm"> {[0, 1].map((i) => ( <Group key={i} justify="space-between" p="md" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 8 }}> <Stack gap={8} style={{ flex: 1 }}> <Skeleton h={12} w="42%" /><Skeleton h={9} w="75%" /><Skeleton h={9} w="32%" /> </Stack> <Skeleton h={30} w={130} /> </Group> ))} </Stack> ); } function ErrorState({ onRetry }: { onRetry: () => void }) { return ( <Center py={48}><Stack align="center" gap="sm"> <ThemeIcon color="red" variant="light" size={46} radius="xl"><IconAlertTriangle size={22} /></ThemeIcon> <Text fw={600}>Couldn’t load the catalog</Text> <Text fz="sm" c="dimmed" ta="center" maw={300}>Check your connection and try again. Installed roles are not affected.</Text> <Button variant="default" leftSection={<IconRefresh size={16} />} onClick={onRetry} mt={4}>Retry</Button> </Stack></Center> ); } function EmptyState() { return ( <Center py={48}><Stack align="center" gap="sm"> <ThemeIcon color="gray" variant="light" size={46} radius="xl"><IconFolderOff size={22} /></ThemeIcon> <Text fw={600}>The catalog is empty</Text> <Text fz="sm" c="dimmed" ta="center" maw={300}>No role bundles are published for this language yet. Try switching the content language.</Text> </Stack></Center> ); } // ---------------------------------------------------------------------------- // Мелочи // ---------------------------------------------------------------------------- function StatusDot({ color, children }: { color: string; children: React.ReactNode }) { const c = { blue: 'blue.7', gray: 'gray.6', orange: 'orange.7', green: 'green.7' }[color] ?? 'dimmed'; const dot = { blue: '#228be6', gray: '#adb5bd', orange: '#fd7e14', green: '#40c057' }[color]; return ( <Group gap={6} wrap="nowrap"> <Box w={7} h={7} style={{ borderRadius: '50%', background: dot }} /> <Text fz="xs" fw={600} c={c}>{children}</Text> </Group> ); } // ---------------------------------------------------------------------------- // МОК-данные и мок-API — удалить при интеграции // ---------------------------------------------------------------------------- const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); function defaultSelection(bs: CatalogBundle[]): Record<string, boolean> { const sel: Record<string, boolean> = {}; bs.forEach((b) => b.roles.forEach((r) => { if (r.status === 'import') sel[`${b.id}:${r.id}`] = true; })); return sel; // Р3 — по умолчанию выбраны все доступные к установке } async function mockImport(b: CatalogBundle, roleIds: string[]) { await delay(1100); const skipped = b.roles.filter((r) => roleIds.includes(r.id) && (r as any).conflict); const installed = roleIds.filter((id) => !skipped.find((s) => s.id === id)); return { installed, skipped }; } const SEED: CatalogBundle[] = [ { id: 'editorial', name: 'Editorial bundle', description: 'Writing, editing and QA roles for content teams.', roles: [ { id: 'copy', emoji: '✍️', name: 'Copywriter', description: 'Drafts articles, posts and landing copy.', version: 1, status: 'import' }, { id: 'edit', emoji: '🪶', name: 'Editor', description: 'Rewrites drafts for clarity, tone and structure.', version: 1, status: 'import' }, { id: 'proof', emoji: '🔍', name: 'Proofreader', description: 'Catches typos, grammar and style slips.', version: 1, status: 'import', ...( { conflict: true } as any) }, { id: 'head', emoji: '📰', name: 'Headline writer', description: 'Generates titles, decks and social hooks.', version: 1, status: 'import' }, { id: 'style', emoji: '🧭', name: 'Style keeper', description: 'Enforces your style guide across texts.', version: 1, status: 'import' }, ], }, { id: 'marketing', name: 'Marketing bundle', description: 'Campaign planning, email and performance analysis.', roles: [ { id: 'camp', emoji: '📣', name: 'Campaign planner', description: 'Plans multi-channel campaign timelines.', version: 1, status: 'import' }, { id: 'mail', emoji: '✉️', name: 'Email writer', description: 'Writes lifecycle and campaign emails.', version: 1, status: 'import' }, { id: 'ads', emoji: '📊', name: 'Ads analyst', description: 'Reviews ad spend and performance.', version: 1, status: 'installed' }, { id: 'seo', emoji: '🧲', name: 'SEO specialist', description: 'Optimizes pages for search intent.', version: 1, newVersion: 2, status: 'update' }, { id: 'pos', emoji: '🎯', name: 'Positioning coach', description: 'Sharpens value props and messaging.', version: 1, status: 'installed' }, ], }, { id: 'research', name: 'Research', description: 'Deep-dive analysis and source-backed answers.', roles: [ { id: 'ana', emoji: '🔬', name: 'Research analyst', description: 'Synthesizes sources into cited briefs.', version: 2, status: 'installed' }, ], }, ]; ```
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#371