From 1cff9e2183e8e57685232d16092ab86b88aa65ea Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 13 Sep 2026 07:22:35 +0300 Subject: [PATCH 01/10] =?UTF-8?q?:sparkles:=20feat(onboarding):=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=80=D0=B4=20=C2=AB=D0=9D=D0=B0=D1=87=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=C2=BB=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=97=D0=B0=D0=BF=D1=83=D1=81=D0=BA=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Онбординг из восьми шагов: приветствие, визард с точным путём и превью результата на каждом шаге, тур «Показать где» с подсветкой реального элемента и финальный экран. Точки возврата — ссылка внизу субменю «Настроек» и пилюля в углу рабочей области. Вёрстка по макету design/baseline/Онбординг. Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а не в localStorage: требование — показать визард всем, кто его ещё не закрывал, включая тех, кто работает в установке давно. У каждого он свой, поэтому один администратор не прячет визард команде. Прогресс шагов считается по факту настройки, ручное «Далее» его не подменяет. Заодно все селекты приложения переведены на общее меню: SelectMenu в shared/ui-controls, поверх него FilterDropdown (фильтры списков) и SelectField (поля форм). Нативных setCategoryId(event.target.value)}> - {catalog.categories.map((item) => ( - - ))} - - - - + [String(item.id), knowledgeCategoryPath(catalog.categories, item.id) || item.name])} + /> - - - - - -
-
-

Поиск и переключатели

Компактный поиск, switch и segmented control.

- ui-controls.tsx · table-controls.css -
-
-
-
SearchInputSearchInput
-
- -
-
-
-
SwitchButton.ui-switch
-
- - - -
-
-
-
SegmentedSegmented
-
-
-
-
-
-
- -
-
-

Вкладки и пагинация

Навигация внутри раздела и управление страницами таблицы.

- ui-controls.tsx · ui-controls.css -
-
-
-
UnderlineTabsUnderlineTabs
-
-
- - - - -
-
-
-
-
TablePaginationTablePagination
-
-
- Показано 1–20 из 74 - -
-
-
-
-
- - - - - - - diff --git a/design/design-system/data-display.html b/design/design-system/data-display.html deleted file mode 100644 index 6460a12..0000000 --- a/design/design-system/data-display.html +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - Данные и статусы · Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Данные и статусы
- Data display -
-
-
-
-
Data display
-

Представление данных

-

Компоненты для статусов, ролей, продуктов, каналов, идентичности и кратких показателей.

-
-
- -
-
-

Статусы

Полный набор ключей StatusPillKey.

- src/shared/ui.tsx -
-
-
StatusPill12 состояний
-
- Работает - Активен - Опубликован - Заблокирован - Неактивен - Приглашён - Архивный - Черновик - Ошибка - Проверяется - Не проверялось -
-
-
- -
-
-

Роли

Роли сотрудников организации.

- src/shared/ui.tsx -
-
-
RoleBadgeOWNER · ADMIN · EMPLOYEE
-
- Владелец - Администратор - Сотрудник -
-
-
- -
-
-

Продукты

Плотный tag для таблиц и нейтральный mark для текста.

- ui.tsx · utils.ts -
-
-
-
ProductTagproductAccent()
-
- FoxRay - Chatballs - FirePage -
-
-
-
ProductMarkProductMark
-
- FoxRay - Chatballs -
-
-
-
- -
-
-

Каналы

Короткие метки провайдеров из общего словаря.

- badges.tsx · providers.ts -
-
-
ChannelBadgeEMAIL · MAX · TG · WEB
-
- Email - MAX - TG - Web - Unknown -
-
-
- -
-
-

Идентичность и показатели

Avatar, KeyValue и MetricBox.

- ui.tsx · form-controls.tsx -
-
-
-
Avatarinitials()
-
- АЕ - ИК - EH -
-
-
-
KeyValueKeyValue
-
-
ОрганизацияООО «ЭДЕВС»
-
ВладелецАндрей Ермолаев
-
-
-
-
MetricBoxMetricBox
-
-
Активные агенты6
-
-
-
-
- -
-
-

Иконки

Набор Icon из shared. Здесь показаны наиболее частые роли.

- src/shared/icons.tsx -
-
-
-

grid

-

box

-

list

-

columns

-

search

-

more

-

edit

-

trash

-

warning

-

bell

-

user

-

message

-
-
-
-
-
-
- - - - diff --git a/design/design-system/foundations.html b/design/design-system/foundations.html deleted file mode 100644 index df3c97b..0000000 --- a/design/design-system/foundations.html +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - Токены · Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Токены
- Foundations -
-
-
-
-
Foundations
-

Токены интерфейса

-

Значения собраны из текущих общих CSS-файлов и темы UI. Названия отражают фактическое назначение значений во фронтенде.

-
-
- -
-
-

Нейтральная палитра

Текст, границы, фоны и disabled-состояния.

- src/styles/base.css -
-
-
neutral.1--n-1 · #1f1f1f
-
neutral.2--n-2 · #262626
-
neutral.3--n-3 · #595959
-
neutral.4--n-4 · #8c8c8c
-
neutral.5--n-5 · #bfbfbf
-
neutral.6--n-6 · #d9d9d9
-
neutral.7--n-7 · #e8e8e8
-
neutral.8--n-8 · #f0f0f0
-
neutral.9--n-9 · #f5f5f5
-
neutral.10--n-10 · #fafafa
-
-
- -
-
-

Основные и семантические цвета

Интерактивные элементы, статусы и AI-акцент.

- base.css · theme.ts -
-
-
color.primary--primary · #1677ff
-
color.primary.hover--primary-hover · #0958d9
-
color.primary.bg--primary-bg · #e6f4ff
-
color.primary.border--primary-border · #91caff
-
color.success--success · #52c41a
-
color.success.bg--success-bg · #f6ffed
-
color.warning.text--warning-text · #d48806
-
color.warning.bg--warning-bg · #fffbe6
-
color.error.text--error-text · #cf1322
-
color.error.bg--error-bg · #fff2f0
-
color.ai--ai · #722ed1
-
color.ai.bg--ai-bg · #f9f0ff
-
-
- -
-
-

Каналы

Акцентные пары из общего словаря провайдеров.

- src/shared/providers.ts -
-
-
provider.email.bg#fff7e6 · text #d48806
-
provider.max.bg#f2f0ff · text #6b5be0
-
provider.telegram.bg#eaf6fd · text #2f8fd0
-
provider.web.bg#e8f7f4 · text #0f9b8e
-
-
- -
-
-

Типографика

Системный sans-serif и моноширинный стек для идентификаторов.

- base.css · page.css -
-
-
-
Основной шрифт--font-sans
-
-
Заголовок страницы · 24 / 700
-
Заголовок компонента · 14 / 600
-
Основной текст интерфейса · 13.5 / 400
-
Подпись поля · 12 / 500
-
-
-
-
Моноширинный--font-mono
-
-
org_01J9R7Q6ZWPB
-
connection_telegram_primary
-
2026-07-30T14:13:00Z
-
-
-
-
- -
-
-

Радиусы

Корневые токены и используемые малые значения controls.

- base.css · shared/*.css -
-
-
radius.4
4px · focus
-
radius.6
6px · badges
-
radius.md
8px · controls
-
radius.lg
9px · buttons
-
radius.xl
10px · cards
-
radius.2xl
12px · panels
-
radius.4xl
14px · dialogs
-
radius.pill
999px · tags
-
-
- -
-
-

Интервалы и размеры controls

Повторяющиеся значения общих компонентов.

- shared/*.css · layout/*.css -
-
-
-

Spacing scale

-
space.1
4px
-
space.2
6px
-
space.3
8px
-
space.4
12px
-
space.5
14px
-
space.6
16px
-
space.7
20px
-
space.8
24px
-
space.9
28px
-
-
-

Control heights

-
control.xs
24px
-
control.sm
30px
-
control.compact
34px
-
control.action
36px
-
control.md
38px
-
switch.md
42×24
-
switch.lg
46×26
-
shell.bar
56px
-
-
-
- -
-
-

Тени

Общие elevation-уровни и характерные component shadows.

- base.css · shared/*.css -
-
-
shadow.xs
cards
-
shadow.sm
panels
-
shadow.button
primary
-
shadow.lg
dialogs
-
-
-
-
-
- - - - diff --git a/design/design-system/index.html b/design/design-system/index.html deleted file mode 100644 index 4081770..0000000 --- a/design/design-system/index.html +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Обзор
- Текущий frontend -
-
-
-
-
Chatballs UI inventory
-

Дизайн-система текущего фронтенда

-

Статический справочник существующих токенов, компонентов, паттернов и компоновок Chatballs. Примеры повторяют действующие CSS-классы и визуальные решения из apps/internal-ui/src.

-
-
- -
- Назначение: перед разработкой нового интерфейса открыть нужный раздел, найти существующий элемент и использовать его как эталон. Каталог ничего не меняет в приложении и не требует React. -
- -
-
-

Разделы

От базовых значений до компоновки целых экранов.

-
- -
- -
-
-

Уровни системы

Как читать и использовать каталог.

-
-
-

1. Foundations

Фактические значения, на которых построен интерфейс: цвет, размер, радиус, тень и типографика.

-

2. Components

Переиспользуемые элементы из src/shared и их состояния.

-

3. Patterns

Устоявшиеся композиции: таблица с фильтрами, карточка, форма, диалог.

-

4. Layouts

Каркас целой страницы или рабочего пространства из существующих частей.

-
-
- -
-
-

Основные источники

Файлы фронтенда, по которым собран этот справочник.

-
-
-
-
Токеныsrc/styles/base.css · packages/ui/src/theme.ts
-
Controlssrc/shared/ui-controls.* · form-controls.*
-
Data displaysrc/shared/ui.tsx · badges.tsx · design-components.css
-
Таблицыsrc/shared/table-controls.css
-
Layoutsrc/layout/styles.css · topbar.css · page.css
-
Иконкиsrc/shared/icons.tsx
-
-
-
-
-
-
- - - - diff --git a/design/design-system/inventory.html b/design/design-system/inventory.html deleted file mode 100644 index d8022fb..0000000 --- a/design/design-system/inventory.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - Инвентарь · Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Инвентарь
- Inventory -
-
-
-
-
Inventory
-

Инвентарь фронтенда

-

Реестр включает PascalCase-компоненты из TSX-файлов, системные CSS-компоненты, дизайн-токены и основные layout-паттерны. Всего записей: —.

-
-
- -
- Область: в таблице отдельно помечены системные, layout-, application- и feature-компоненты. Это инвентаризация существующего состава без оценки и без проверки соблюдения дизайн-системы. -
- -
- -
-
- - - - - -
НазваниеТипКатегорияОбластьИсходникВарианты / роль
-
-
-
-
-
-
- - - - - diff --git a/design/design-system/inventory.json b/design/design-system/inventory.json deleted file mode 100644 index 026f43f..0000000 --- a/design/design-system/inventory.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"0.1.0","generatedFrom":"apps/internal-ui/src","generatedOn":"2026-07-30","count":315,"items":[{"name":"Link","kind":"CSS component","category":"Actions","scope":"System","visibility":"system","source":"src/shared/links.css","variants":"base, strong, neutral, muted, mono, icon","documentedAt":"components.html"},{"name":"AppDropdown","kind":"CSS pattern","category":"Overlays","scope":"System","visibility":"system","source":"src/shared/menu.css","variants":"default, wide, channels, warning, danger, success","documentedAt":"patterns.html"},{"name":"BaselineTable","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"header, row hover, numeric, actions","documentedAt":"patterns.html"},{"name":"FilterBar","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, employees","documentedAt":"patterns.html"},{"name":"TableCard","kind":"CSS pattern","category":"Tables","scope":"System","visibility":"system","source":"src/shared/table-controls.css","variants":"default, scroll, footer","documentedAt":"patterns.html"},{"name":"ApplicationShell","kind":"Layout pattern","category":"Layouts","scope":"System","visibility":"system","source":"src/layout/*.css","variants":"sidebar, topbar, scroll, page","documentedAt":"layouts.html"},{"name":"ConversationWorkspacePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"features/conversations","variants":"dialog list, thread, context","documentedAt":"layouts.html"},{"name":"DetailPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature detail pages","variants":"header, main content, rail","documentedAt":"layouts.html"},{"name":"ListPage","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"feature list pages","variants":"header, filters, table, pagination","documentedAt":"layouts.html"},{"name":"SettingsPagePattern","kind":"Page pattern","category":"Layouts","scope":"System","visibility":"system","source":"settings/profile/administration","variants":"section cards, forms, local actions","documentedAt":"layouts.html"},{"name":"App","kind":"React component","category":"Application","scope":"Application","visibility":"exported","source":"apps/internal-ui/src/App.tsx","variants":"—","documentedAt":""},{"name":"SurfaceApp","kind":"React component","category":"Application","scope":"Application","visibility":"local","source":"apps/internal-ui/src/main.tsx","variants":"—","documentedAt":""},{"name":"AdministrationPage","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AdministrationPage.tsx","variants":"—","documentedAt":""},{"name":"AuditTable","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/AuditTable.tsx","variants":"—","documentedAt":""},{"name":"CloudSubscriptionPanel","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/CloudSubscriptionPanel.tsx","variants":"—","documentedAt":""},{"name":"OrganizationLogoField","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationLogoField.tsx","variants":"—","documentedAt":""},{"name":"OrganizationSettingsForm","kind":"React component","category":"Feature: administration","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx","variants":"—","documentedAt":""},{"name":"AgentEditForm","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx","variants":"—","documentedAt":""},{"name":"AgentsKpiStrip","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx","variants":"—","documentedAt":""},{"name":"AgentsTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AgentsTable.tsx","variants":"—","documentedAt":""},{"name":"AiAgentCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"AiAgentDetailPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx","variants":"—","documentedAt":""},{"name":"AiAgentInstructionsTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentKnowledgeTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentMetricsTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentMetricsTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentOverviewTab","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/detail/AiAgentOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"AiAgentsPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/AiAgentsPage.tsx","variants":"—","documentedAt":""},{"name":"CategoryManagement","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/CategoryManagement.tsx","variants":"—","documentedAt":""},{"name":"CreateAgentFooter","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/CreateAgentFooter.tsx","variants":"—","documentedAt":""},{"name":"CreateAgentStepCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/CreateAgentStepCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeAccessNotice","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeAttachmentsCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeAttachmentsCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeBulkActions","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkActions.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeBulkDialog","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeBulkDialog.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeCategoryTree","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeCategoryTree.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeCreatePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeCreatePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDepartmentPicker","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDepartmentPicker.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDetailPage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailPage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeDetailSidebar","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeDetailSidebar.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorBreadcrumb","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorBreadcrumb.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorCard","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorCard.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeEditorFields","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorFields.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeImportModal","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeImportModal.tsx","variants":"—","documentedAt":""},{"name":"KnowledgePage","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePage.tsx","variants":"—","documentedAt":""},{"name":"KnowledgePageHeader","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgePageHeader.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeScopeConflictModal","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeScopeConflictModal.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/KnowledgeStep.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeTable","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeTable.tsx","variants":"—","documentedAt":""},{"name":"KnowledgeToolbar","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/knowledge/KnowledgeToolbar.tsx","variants":"—","documentedAt":""},{"name":"ModelStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/ModelStep.tsx","variants":"—","documentedAt":""},{"name":"ProductChoiceStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/ProductChoiceStep.tsx","variants":"—","documentedAt":""},{"name":"PromptStep","kind":"React component","category":"Feature: ai","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/ai/create/PromptStep.tsx","variants":"—","documentedAt":""},{"name":"AuthChangePassword","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthChangePassword.tsx","variants":"—","documentedAt":""},{"name":"AuthCodeInput","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthCodeInput.tsx","variants":"—","documentedAt":""},{"name":"AuthField","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthField.tsx","variants":"—","documentedAt":""},{"name":"AuthFrame","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthFrame.tsx","variants":"—","documentedAt":""},{"name":"AuthLogin","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthLogin.tsx","variants":"—","documentedAt":""},{"name":"AuthPasswordRecovery","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthPasswordRecovery.tsx","variants":"—","documentedAt":""},{"name":"AuthResetPassword","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthResetPassword.tsx","variants":"—","documentedAt":""},{"name":"AuthTotpCode","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthTotpCode.tsx","variants":"—","documentedAt":""},{"name":"AuthTotpSetup","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/AuthTotpSetup.tsx","variants":"—","documentedAt":""},{"name":"TotpQr","kind":"React component","category":"Feature: auth","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/auth/TotpQr.tsx","variants":"—","documentedAt":""},{"name":"AgentCell","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelRow.tsx","variants":"—","documentedAt":""},{"name":"ChannelAgentSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelAgentSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelArchivedNotice","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelArchivedNotice.tsx","variants":"—","documentedAt":""},{"name":"ChannelAssignmentSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelAssignmentSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelConnectionsSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelConnectionsSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelConnectionsTable","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelConnectionsTable.tsx","variants":"—","documentedAt":""},{"name":"ChannelCountersSection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelCountersSection.tsx","variants":"—","documentedAt":""},{"name":"ChannelCreateWizard","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelCreateWizard.tsx","variants":"—","documentedAt":""},{"name":"ChannelDangerActions","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDangerActions.tsx","variants":"—","documentedAt":""},{"name":"ChannelDeactivationDialog","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDeactivationDialog.tsx","variants":"—","documentedAt":""},{"name":"ChannelDeleteDialog","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDeleteDialog.tsx","variants":"—","documentedAt":""},{"name":"ChannelDetailHeader","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"ChannelDetailPage","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelDetailPage.tsx","variants":"—","documentedAt":""},{"name":"ChannelPolicySection","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelPolicySection.tsx","variants":"—","documentedAt":""},{"name":"ChannelRow","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelRow.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardAssignmentStep","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardAssignmentStep.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardChannelStep","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardChannelStep.tsx","variants":"—","documentedAt":""},{"name":"ChannelWizardSummary","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelWizardSummary.tsx","variants":"—","documentedAt":""},{"name":"ChannelsPage","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelsPage.tsx","variants":"—","documentedAt":""},{"name":"ChannelsTable","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/channels/ChannelsTable.tsx","variants":"—","documentedAt":""},{"name":"DestinationCard","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelWizardSummary.tsx","variants":"—","documentedAt":""},{"name":"PolicyRows","kind":"React component","category":"Feature: channels","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/channels/ChannelPolicySection.tsx","variants":"—","documentedAt":""},{"name":"CommandCenter","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandCenterPage.tsx","variants":"—","documentedAt":""},{"name":"CommandHeader","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandHeader.tsx","variants":"—","documentedAt":""},{"name":"CommandRail","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CommandRail.tsx","variants":"—","documentedAt":""},{"name":"CompanyStatusBanner","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx","variants":"—","documentedAt":""},{"name":"DepartmentCard","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"DepartmentsColumn","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/SalesDepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"MetricGroup","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/MetricGroup.tsx","variants":"—","documentedAt":""},{"name":"RailCard","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/RailCard.tsx","variants":"—","documentedAt":""},{"name":"SmallAiMetric","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/CommandRail.tsx","variants":"—","documentedAt":""},{"name":"SmallMetric","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/command/center/CompanyStatusBanner.tsx","variants":"—","documentedAt":""},{"name":"StatusLabel","kind":"React component","category":"Feature: command","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/command/center/StatusLabel.tsx","variants":"—","documentedAt":""},{"name":"CallOverlay","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/CallOverlay.tsx","variants":"—","documentedAt":""},{"name":"Composer","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/Composer.tsx","variants":"—","documentedAt":""},{"name":"ContactAvatar","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ContactAvatar.tsx","variants":"—","documentedAt":""},{"name":"ContextSection","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ContextSection.tsx","variants":"—","documentedAt":""},{"name":"ConversationActions","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationActions.tsx","variants":"—","documentedAt":""},{"name":"ConversationThread","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"ConversationWorkspace","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"DialogList","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"DialogListItem","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"DialogTab","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/DialogList.tsx","variants":"—","documentedAt":""},{"name":"EmailMessageBody","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/EmailMessageBody.tsx","variants":"—","documentedAt":""},{"name":"FieldRow","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/conversations/FieldRow.tsx","variants":"—","documentedAt":""},{"name":"Message","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"MessageRow","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"StatusBadge","kind":"React component","category":"Feature: conversations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/conversations/ConversationThread.tsx","variants":"—","documentedAt":""},{"name":"DepartmentCard","kind":"React component","category":"Feature: departments","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/departments/DepartmentCard.tsx","variants":"—","documentedAt":""},{"name":"DepartmentsPage","kind":"React component","category":"Feature: departments","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/departments/DepartmentsPage.tsx","variants":"—","documentedAt":""},{"name":"AccessProfileEditor","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfileEditor.tsx","variants":"—","documentedAt":""},{"name":"AccessProfileList","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfileList.tsx","variants":"—","documentedAt":""},{"name":"AccessProfilesPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/AccessProfilesPage.tsx","variants":"—","documentedAt":""},{"name":"CreateField","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"CreateStep","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"EmployeeAccessSection","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeAccessSection.tsx","variants":"—","documentedAt":""},{"name":"EmployeeCreateDrawer","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailHeader","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailPage.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailRail","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailRail.tsx","variants":"—","documentedAt":""},{"name":"EmployeeDetailSections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeDetailSections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeIdentitySections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeIdentitySections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeRow","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeTable.tsx","variants":"—","documentedAt":""},{"name":"EmployeeSecuritySections","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeSecuritySections.tsx","variants":"—","documentedAt":""},{"name":"EmployeeTable","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeeTable.tsx","variants":"—","documentedAt":""},{"name":"EmployeesFilters","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeesFilters.tsx","variants":"—","documentedAt":""},{"name":"EmployeesPage","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/EmployeesListPage.tsx","variants":"—","documentedAt":""},{"name":"OwnershipTransferModal","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx","variants":"—","documentedAt":""},{"name":"RoleOption","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/EmployeeCreateDrawer.tsx","variants":"—","documentedAt":""},{"name":"RoleOption","kind":"React component","category":"Feature: employees","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/employees/OwnershipTransferModal.tsx","variants":"—","documentedAt":""},{"name":"Heading","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/help-center/MarkdownContent.tsx","variants":"—","documentedAt":""},{"name":"HelpArrowIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpArticle","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpArticle.tsx","variants":"—","documentedAt":""},{"name":"HelpCenterApp","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpCenterApp.tsx","variants":"—","documentedAt":""},{"name":"HelpChevronIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpFolderIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpHome","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpHome.tsx","variants":"—","documentedAt":""},{"name":"HelpLayout","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpLayout.tsx","variants":"—","documentedAt":""},{"name":"HelpMessageIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpSearch","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpSearch.tsx","variants":"—","documentedAt":""},{"name":"HelpSearchIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"HelpThumbIcon","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/HelpIcons.tsx","variants":"—","documentedAt":""},{"name":"MarkdownContent","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/MarkdownContent.tsx","variants":"—","documentedAt":""},{"name":"PortalWebWidget","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/PortalWebWidget.tsx","variants":"—","documentedAt":""},{"name":"SupportLauncher","kind":"React component","category":"Feature: help-center","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/help-center/SupportLauncher.tsx","variants":"—","documentedAt":""},{"name":"ConnectionIcon","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"ConnectionsTable","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/ConnectionsTable.tsx","variants":"—","documentedAt":""},{"name":"EmailFields","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/EmailFields.tsx","variants":"—","documentedAt":""},{"name":"IntegrationForm","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/IntegrationForm.tsx","variants":"—","documentedAt":""},{"name":"IntegrationsPage","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/IntegrationsPage.tsx","variants":"—","documentedAt":""},{"name":"MailboxSection","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/integrations/EmailFields.tsx","variants":"—","documentedAt":""},{"name":"ProvidersTable","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/ProvidersTable.tsx","variants":"—","documentedAt":""},{"name":"RowActions","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"StatusCell","kind":"React component","category":"Feature: integrations","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/integrations/rows.tsx","variants":"—","documentedAt":""},{"name":"NotificationDrawer","kind":"React component","category":"Feature: notifications","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/notifications/NotificationDrawer.tsx","variants":"—","documentedAt":""},{"name":"Section","kind":"React component","category":"Feature: notifications","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/notifications/NotificationDrawer.tsx","variants":"—","documentedAt":""},{"name":"OfferFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/OfferFormModal.tsx","variants":"—","documentedAt":""},{"name":"PriceFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/PriceFormModal.tsx","variants":"—","documentedAt":""},{"name":"ProductAccessTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductAccessTab.tsx","variants":"—","documentedAt":""},{"name":"ProductAgentCard","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductAgentCard.tsx","variants":"—","documentedAt":""},{"name":"ProductChannelsTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductChannelsTab.tsx","variants":"—","documentedAt":""},{"name":"ProductDetailHeader","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductDetailHeader.tsx","variants":"—","documentedAt":""},{"name":"ProductDetailPage","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductDetailPage.tsx","variants":"—","documentedAt":""},{"name":"ProductEmptyTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductEmptyTab.tsx","variants":"—","documentedAt":""},{"name":"ProductFormModal","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductFormModal.tsx","variants":"—","documentedAt":""},{"name":"ProductOffersTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductOffersTab.tsx","variants":"—","documentedAt":""},{"name":"ProductOverviewTab","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/detail/ProductOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"ProductRow","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductRow.tsx","variants":"—","documentedAt":""},{"name":"ProductsHeaderActions","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsHeaderActions.tsx","variants":"—","documentedAt":""},{"name":"ProductsPage","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsPage.tsx","variants":"—","documentedAt":""},{"name":"ProductsTable","kind":"React component","category":"Feature: products","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/products/ProductsTable.tsx","variants":"—","documentedAt":""},{"name":"ProfileField","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileField.tsx","variants":"—","documentedAt":""},{"name":"ProfileHeader","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileHeader.tsx","variants":"—","documentedAt":""},{"name":"ProfileInfoForm","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileInfoForm.tsx","variants":"—","documentedAt":""},{"name":"ProfileNotificationsCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx","variants":"—","documentedAt":""},{"name":"ProfilePage","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfilePage.tsx","variants":"—","documentedAt":""},{"name":"ProfilePasswordForm","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfilePasswordForm.tsx","variants":"—","documentedAt":""},{"name":"ProfileSessionsCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileSessionsCard.tsx","variants":"—","documentedAt":""},{"name":"ProfileTotpCard","kind":"React component","category":"Feature: profile","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/profile/ProfileTotpCard.tsx","variants":"—","documentedAt":""},{"name":"ActivityList","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"AddSaleDialog","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/AddSaleDialog.tsx","variants":"—","documentedAt":""},{"name":"ClientContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx","variants":"—","documentedAt":""},{"name":"FilterSelect","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryFilters.tsx","variants":"—","documentedAt":""},{"name":"FulfillmentTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/FulfillmentTable.tsx","variants":"—","documentedAt":""},{"name":"HistoryContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/HistoryContext.tsx","variants":"—","documentedAt":""},{"name":"OrdersTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/OrdersTable.tsx","variants":"—","documentedAt":""},{"name":"PaymentsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/tables/PaymentsTable.tsx","variants":"—","documentedAt":""},{"name":"ProductContext","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/ProductContext.tsx","variants":"—","documentedAt":""},{"name":"RightTabButton","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/dialogs/context/SalesContextTabs.tsx","variants":"—","documentedAt":""},{"name":"SaleActionPanel","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleActionPanel.tsx","variants":"—","documentedAt":""},{"name":"SaleDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SaleEventTimeline","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/sale-detail/SaleEventTimeline.tsx","variants":"—","documentedAt":""},{"name":"SalesActor","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/overview/SalesActorsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesActorsCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesActorsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesChannelsCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesChannelsCard.tsx","variants":"—","documentedAt":""},{"name":"SalesChart","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesChart.tsx","variants":"—","documentedAt":""},{"name":"SalesClientAuditTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientAuditTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientConsentTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientConsentTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SalesClientDialogsTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientDialogsTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesClientIdentitiesTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientIdentitiesTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientOverviewTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientRow","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientRow.tsx","variants":"—","documentedAt":""},{"name":"SalesClientSalesTab","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientSalesTab.tsx","variants":"—","documentedAt":""},{"name":"SalesClientTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsEmpty","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsFilters","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesClientsPage.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsPagination","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsPagination.tsx","variants":"—","documentedAt":""},{"name":"SalesClientsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesContextPanel","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/SalesContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SalesContextTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/dialogs/context/SalesContextTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesDialogsPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesDialogsPage.tsx","variants":"—","documentedAt":""},{"name":"SalesFilterDropdown","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesKpiCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/overview/SalesKpiSection.tsx","variants":"—","documentedAt":""},{"name":"SalesKpiSection","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesKpiSection.tsx","variants":"—","documentedAt":""},{"name":"SalesListCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesListCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrderDetailPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/order-detail/SalesOrderDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersEmpty","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTableCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersPage.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersPagination","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersPagination.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersTableCard","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTableCard.tsx","variants":"—","documentedAt":""},{"name":"SalesOrdersTabs","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/SalesOrdersTabs.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewGrid","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesOverviewGrid.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewHeader","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesOverviewHeader.tsx","variants":"—","documentedAt":""},{"name":"SalesOverviewPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/SalesOverviewPage.tsx","variants":"—","documentedAt":""},{"name":"SalesProductsTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/overview/SalesProductsTable.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryFilters","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryFilters.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryPage","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryPage.tsx","variants":"—","documentedAt":""},{"name":"SalesRegistryTable","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/registry/SalesRegistryTable.tsx","variants":"—","documentedAt":""},{"name":"SortableTh","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/clients/SalesClientsTable.tsx","variants":"—","documentedAt":""},{"name":"StatusBadge","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/sales/orders/StatusBadge.tsx","variants":"—","documentedAt":""},{"name":"SummaryGrid","kind":"React component","category":"Feature: sales","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/sales/client-detail/SalesClientOverviewTab.tsx","variants":"—","documentedAt":""},{"name":"SettingsPage","kind":"React component","category":"Feature: settings","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/settings/SettingsPage.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"OperatorCardSection","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"OperatorCards","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"OperatorField","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/OperatorCards.tsx","variants":"—","documentedAt":""},{"name":"RightTabButton","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"local","source":"apps/internal-ui/src/features/support/context/SupportContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SupportContextPanel","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/SupportContextPanel.tsx","variants":"—","documentedAt":""},{"name":"SupportDialogsPage","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/SupportDialogsPage.tsx","variants":"—","documentedAt":""},{"name":"SupportHistory","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/context/SupportHistory.tsx","variants":"—","documentedAt":""},{"name":"SupportOverviewPage","kind":"React component","category":"Feature: support","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support/SupportOverviewPage.tsx","variants":"—","documentedAt":""},{"name":"PortalArticleEditor","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalArticleEditor.tsx","variants":"—","documentedAt":""},{"name":"PortalArticleTable","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalArticleTable.tsx","variants":"—","documentedAt":""},{"name":"PortalCategoryManagement","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalCategoryManagement.tsx","variants":"—","documentedAt":""},{"name":"PortalContent","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalContent.tsx","variants":"—","documentedAt":""},{"name":"PortalCreateDialog","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx","variants":"—","documentedAt":""},{"name":"PortalDomainSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalDomainSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalProductSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalProductSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalSettings.tsx","variants":"—","documentedAt":""},{"name":"PortalWidgetSettings","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/PortalWidgetSettings.tsx","variants":"—","documentedAt":""},{"name":"SupportPortalDetailPage","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/SupportPortalDetailPage.tsx","variants":"—","documentedAt":""},{"name":"SupportPortalsPage","kind":"React component","category":"Feature: support-portals","scope":"Feature","visibility":"exported","source":"apps/internal-ui/src/features/support-portals/SupportPortalsPage.tsx","variants":"—","documentedAt":""},{"name":"Shell","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/Shell.tsx","variants":"—","documentedAt":"layouts.html"},{"name":"ShellRouteContent","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/ShellRouteContent.tsx","variants":"—","documentedAt":""},{"name":"Sidebar","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/Sidebar.tsx","variants":"—","documentedAt":""},{"name":"SidebarLink","kind":"React component","category":"Layout","scope":"System","visibility":"local","source":"apps/internal-ui/src/layout/Sidebar.tsx","variants":"—","documentedAt":""},{"name":"SidebarNavSection","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/SidebarNavSection.tsx","variants":"—","documentedAt":""},{"name":"SidebarUserMenu","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/SidebarUserMenu.tsx","variants":"—","documentedAt":""},{"name":"TopBar","kind":"React component","category":"Layout","scope":"System","visibility":"exported","source":"apps/internal-ui/src/layout/TopBar.tsx","variants":"—","documentedAt":""},{"name":"ActionButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"Avatar","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"Button","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"primary, secondary, action, danger-outline","documentedAt":"components.html"},{"name":"CategoryManagementDialog","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryManagementDialog.tsx","variants":"—","documentedAt":""},{"name":"CategoryManagementRow","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryManagementRow.tsx","variants":"—","documentedAt":""},{"name":"CategoryTree","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/CategoryTree.tsx","variants":"—","documentedAt":""},{"name":"ChannelBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/badges.tsx","variants":"email, max, telegram, web, plain","documentedAt":"data-display.html"},{"name":"ContentEditorBreadcrumb","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentEditorBreadcrumb.tsx","variants":"—","documentedAt":""},{"name":"ContentEditorCard","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentEditorCard.tsx","variants":"—","documentedAt":""},{"name":"ContentLibraryTable","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentLibraryTable.tsx","variants":"—","documentedAt":""},{"name":"ContentLibraryToolbar","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/content-library/ContentLibraryToolbar.tsx","variants":"—","documentedAt":""},{"name":"ContentState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"primary, warning","documentedAt":"patterns.html"},{"name":"DecisionDialog","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/DecisionDialog.tsx","variants":"warning, danger","documentedAt":"patterns.html"},{"name":"EmptyState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"patterns.html"},{"name":"ErrorScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"FormField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"editable, readonly, disabled, invalid, mono, wide","documentedAt":"components.html"},{"name":"Icon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"IconButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"bordered, bare, danger","documentedAt":"components.html"},{"name":"KeyValue","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"LoadingScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"LoadingState","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"page, inline","documentedAt":"patterns.html"},{"name":"LogoIcon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"MaxLogo","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"MetricBox","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"PageHeader","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"patterns.html"},{"name":"PasswordField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"PermissionScreen","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"ProductMark","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":"data-display.html"},{"name":"ProductTag","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"foxray, default product accent","documentedAt":"data-display.html"},{"name":"ReadOnlyField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"—","documentedAt":""},{"name":"RoleBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"owner, admin, employee","documentedAt":"data-display.html"},{"name":"Rows","kind":"React component","category":"Shared UI","scope":"System","visibility":"local","source":"apps/internal-ui/src/shared/content-library/CategoryTree.tsx","variants":"—","documentedAt":""},{"name":"SearchInput","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"SelectField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"ShieldIcon","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"StatusPill","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui.tsx","variants":"12 status keys","documentedAt":"data-display.html"},{"name":"SwitchButton","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"off, on, disabled, large","documentedAt":"components.html"},{"name":"TablePagination","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"default, active, disabled, ellipsis","documentedAt":"components.html"},{"name":"TelegramLogo","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/icons.tsx","variants":"—","documentedAt":""},{"name":"TextAreaField","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/form-controls.tsx","variants":"—","documentedAt":"components.html"},{"name":"ToneBadge","kind":"React component","category":"Shared UI","scope":"System","visibility":"exported","source":"apps/internal-ui/src/shared/ui-controls.tsx","variants":"—","documentedAt":""},{"name":"Control sizes","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/*.css","variants":"24, 30, 34, 36, 38, 42×24, 46×26, 56","documentedAt":"foundations.html"},{"name":"Neutral palette","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"10 neutral values","documentedAt":"foundations.html"},{"name":"Provider colors","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/providers.ts","variants":"email, max, telegram, web","documentedAt":"foundations.html"},{"name":"Radius scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"4, 6, 8, 9, 10, 12, 14, pill","documentedAt":"foundations.html"},{"name":"Semantic colors","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"primary, success, warning, error, ai","documentedAt":"foundations.html"},{"name":"Shadow scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"xs, sm, button, lg","documentedAt":"foundations.html"},{"name":"Spacing scale","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/shared/*.css","variants":"4–28 px recurring values","documentedAt":"foundations.html"},{"name":"Typography","kind":"Token set","category":"Foundations","scope":"System","visibility":"system","source":"src/styles/base.css","variants":"sans, mono, page heading, body, caption","documentedAt":"foundations.html"}]} diff --git a/design/design-system/layouts.html b/design/design-system/layouts.html deleted file mode 100644 index 1c2d9d5..0000000 --- a/design/design-system/layouts.html +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - Макеты страниц · Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Макеты страниц
- Layouts -
-
-
-
-
Layouts
-

Компоновка экранов

-

Наглядные схемы существующих типов экранов. Они показывают состав и иерархию, сохраняя текущий shell, интервалы, поверхности и controls.

-
-
- -
-
-

Application shell + list page

Sidebar, topbar, page header, filter bar и table card.

- layout/* · list features -
-
- -
-
Организация / СотрудникиРабочий контур
-
-
СотрудникиУправление доступом и ролями
-
ВсеАктивные
-
-
СотрудникРольСтатус
-
Андрей ЕрмолаевВладелецАктивен•••
-
Ирина КовалеваАдминистраторПриглашён•••
-
Максим СеровСотрудникНеактивен•••
-
Елена МироноваСотрудникАктивен•••
-
-
-
-
-
- -
-
-

Detail page

Основная колонка, боковой rail и секции редактирования.

- employee/channel detail features -
-
-
Entity detailheader + content + rail
-
- -
-
-

Основные параметры

-

Назначение и политика

-
- -
-
-
-
- -
-
-

Settings page

Последовательность тематических секций с локальными действиями.

- settings · profile · administration -
-
-
Settings compositionpage header + section cards
-
- -
-

Организация

-

Безопасность

Двухфакторная аутентификация
Уведомления о входе
-
-
-
-
- -
-
-

Conversation workspace

Список диалогов, центральный thread и контекст клиента.

- features/conversations -
-
- -
-
Иван Петров · Telegram
-
Здравствуйте! Подскажите, какой тариф подходит для двух агентов?
Для двух активных ИИ-агентов подойдёт тариф Startup.
-
-
- -
-
-
-
-
- - - - diff --git a/design/design-system/patterns.html b/design/design-system/patterns.html deleted file mode 100644 index 786f488..0000000 --- a/design/design-system/patterns.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - Паттерны · Chatballs Design System - - - - - - - - - -
- -
-
-
Design System/Паттерны
- Compositions -
-
-
-
-
Patterns
-

Композиции компонентов

-

Устоявшиеся конструкции текущего интерфейса: заголовок страницы, фильтры, таблица, состояния, меню и диалоги решений.

-
-
- -
-
-

Заголовок страницы

Название, пояснение и основное действие справа.

- PageHeader · layout/page.css -
-
-
PageHeader.page-header
-
- -
-
-
- -
-
-

Таблица со служебной панелью

Filter bar, стандартная таблица, row actions и пагинация.

- table-controls.css · feature tables -
-
-
List table pattern.filter-bar · .baseline-table
-
-
- -
Статус
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
СотрудникРольСтатусПродукты
АЕ
ВладелецАктивенChatballs
ИК
АдминистраторПриглашёнFoxRay
МС
СотрудникНеактивенFirePage
-
- -
-
-
-
- -
-
-

Состояния контента

Пустой раздел, предупреждение и загрузка.

- ContentState · EmptyState · LoadingState -
-
-
-
Primary stateContentState
-
-
Данных пока нет

Создайте первый элемент, чтобы начать работу.

-
-
-
-
Warning statetone="warning"
-
-
Подключение требует внимания

Проверьте параметры интеграции.

-
-
-
-
Inline statesEmptyState · LoadingState
-
-
Загрузка…
-
Нет результатов
-
-
-
-
- -
-
-

Выпадающее меню

Единая структура пунктов действий и семантических состояний.

- menu.css -
-
-
App dropdown.app-dropdown
-
- -
-
-
- -
-
-

Decision dialogs

Предупреждающее и опасное подтверждение.

- DecisionDialog.tsx · design-components.css -
-
-
-
Warningtone="warning"
-
-
-
-
Dangertone="danger"
-
-
-
-
-
-
-
- -
- -
- -
- -
- - - - diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index f741a94..4962bd4 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000"; const SECOND_ORGANIZATION_PUBLIC_ID = "223e4567-e89b-12d3-a456-426614174000"; @@ -18,6 +18,36 @@ const MANAGER_NAV = ["Чат", "Контакты", "Агенты", "Сотруд // интерфейс: именно их ждали прежние редакции этих тестов. const REMOVED_FROM_UI = ["Командный центр", "Отделы", "Продажи", "Каналы", "Подключения", "Продукты"]; +// Состояние онбординга «Начало работы». По умолчанию сценарии получают +// пройденный визард — иначе он перекрывал бы проверяемый экран; тесты самого +// онбординга подставляют «ещё не открывал». +const ONBOARDING_STEPS_DONE = { + providerConnected: true, + agentActive: true, + knowledgeFilled: true, + connectionBound: true, + widgetPublished: true, + employeeInvited: true, + platformConfigured: true, + firstConversation: true, +}; + +type OnboardingMock = { steps: Record; dismissedAt: string | null; completedAt: string | null }; + +const ONBOARDING_DISMISSED: OnboardingMock = { + steps: ONBOARDING_STEPS_DONE, + dismissedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:00Z", +}; + +// Человек в системе давно, но онбординга ещё не видел: признаков нет, часть +// шагов уже выполнена по факту. +const ONBOARDING_FRESH: OnboardingMock = { + steps: { ...ONBOARDING_STEPS_DONE, widgetPublished: false, platformConfigured: false, firstConversation: false }, + dismissedAt: null, + completedAt: null, +}; + const GROUPS = [ { id: 1, name: "Операторы", color: "#1677ff", memberCount: 1, memberIds: [7], createdAt: "2026-02-02T10:00:00Z" }, { id: 2, name: "Поддержка", color: "#2aa876", memberCount: 0, memberIds: [], createdAt: "2026-02-02T10:00:00Z" }, @@ -86,6 +116,9 @@ function identityFor(role: Role, memberships = [membershipFor(role)]) { } const OWNER_IDENTITY = identityFor("OWNER"); +// Администратор организации, но не установки: разделы и шаги про саму +// установку («Платформа», «Домен и почта») ему не показываются. +const ADMIN_IDENTITY = identityFor("ADMIN"); const EMPLOYEE_IDENTITY = identityFor("EMPLOYEE"); const EMPLOYEE_PERMISSIONS = { @@ -161,7 +194,7 @@ const INVITATION = { expiresAt: "2026-09-17T10:00:00Z", }; -async function mockInstance(page: Page) { +async function mockInstance(page: Page, onboarding: OnboardingMock = ONBOARDING_DISMISSED, onboardingActions: string[] = []) { await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } })); await page.route("**/api/v1/instance/**", (route) => route.fulfill({ json: {} })); await page.route("**/api/v1/organizations/*/conversations/**", (route) => { @@ -177,8 +210,20 @@ async function mockInstance(page: Page) { }); await page.route("**/api/v1/organizations/*/company/**", (route) => { const path = new URL(route.request().url()).pathname; - if (path.endsWith("/company/launch-checklist/")) { - return route.fulfill({ json: { agentCreated: true, connectionBound: true, employeeInvited: true, done: true } }); + if (path.endsWith("/company/onboarding/")) { + if (route.request().method() === "POST") { + const action = JSON.parse(route.request().postData() ?? "{}").action; + onboardingActions.push(action); + const now = "2026-03-01T10:00:00Z"; + return route.fulfill({ + json: { + steps: onboarding.steps, + dismissedAt: action === "restart" ? null : now, + completedAt: action === "complete" ? now : null, + }, + }); + } + return route.fulfill({ json: onboarding }); } return route.fulfill({ json: { items: GROUPS } }); }); @@ -223,9 +268,9 @@ async function mockSession(page: Page, user: object | null) { ); } -async function login(page: Page, user: object) { +async function login(page: Page, user: object, onboarding: OnboardingMock = ONBOARDING_DISMISSED, onboardingActions: string[] = []) { await mockSession(page, null); - await mockInstance(page); + await mockInstance(page, onboarding, onboardingActions); await mockEmployees(page); await page.route("**/api/v1/auth/login/", (route) => route.fulfill({ json: { authenticated: true, user } })); await page.goto("/"); @@ -561,10 +606,196 @@ test("экран сотрудников: список, создание, кар await expect(transferDialog).toBeVisible(); // Кандидатами могут быть только активные администраторы: сотрудника в списке // быть не должно. - const candidates = transferDialog.locator("select"); - await expect(candidates.locator("option")).toHaveCount(1); - await expect(candidates.locator("option")).toContainText("Анна Ким"); + await transferDialog.getByRole("button", { name: "Новый владелец" }).click(); + const candidates = page.locator(".app-dropdown.is-field .ant-dropdown-menu-item"); + await expect(candidates).toHaveCount(1); + await expect(candidates).toContainText("Анна Ким"); + await page.keyboard.press("Escape"); await page.screenshot({ path: "test-results/employees-ownership.png", fullPage: true }); await transferDialog.getByRole("button", { name: "Отмена" }).click(); await expect(transferDialog).toHaveCount(0); }); + +// Онбординг «Начало работы» (дизайн-базлайн, handoff 2026-09-13). Требование +// заказчика: визард показывается владельцу и администратору, пока он его не +// закроет, — в том числе тем, кто работает в установке давно. Поэтому признак +// «закрыл» живёт на сервере, а не в localStorage, и мок отдаёт его пустым. + +/** Ждёт конца ob-rise: до него карточка полупрозрачна и снимок выходит блёклым. */ +const settled = (locator: Locator) => expect(locator).toHaveCSS("opacity", "1"); + +test("онбординг открывается владельцу, который его ещё не закрывал", async ({ page }) => { + const actions: string[] = []; + await login(page, OWNER_IDENTITY, ONBOARDING_FRESH, actions); + + const welcome = page.locator(".ob-welcome"); + await expect(welcome).toBeVisible(); + // Число в заголовке — словом, как в макете, а не цифрой. + await expect(welcome.getByRole("heading")).toHaveText("Восемь шагов до первого ответа клиенту"); + // Карточка выезжает 320ms: снимок до конца анимации ловит полупрозрачность. + await settled(welcome); + await page.screenshot({ path: "test-results/onboarding-welcome.png" }); + + await welcome.getByRole("button", { name: "Начать настройку" }).click(); + const wizard = page.locator(".ob-steps"); + await expect(wizard).toBeVisible(); + // Владелец здесь ещё и администратор установки — значит все восемь шагов. + await expect(wizard.locator(".ob-rail-step")).toHaveCount(8); + await expect(wizard.locator(".ob-chip.is-accent")).toContainText("Шаг 1 из 8"); + await expect(wizard.getByRole("heading")).toHaveText("Подключите AI-провайдера"); + // Прогресс приходит фактами с сервера, а не нажатиями «Далее». + await expect(wizard.locator(".ob-rail-progress-row span")).toHaveText("Готово 5 из 8"); + await settled(wizard); + await page.screenshot({ path: "test-results/onboarding-steps.png" }); + + await wizard.getByRole("button", { name: "Далее" }).click(); + await expect(wizard.getByRole("heading")).toHaveText("Создайте AI-агента"); + await wizard.getByRole("button", { name: "Назад" }).click(); + await expect(wizard.getByRole("heading")).toHaveText("Подключите AI-провайдера"); + + // «Закрыть и настроить самому» записывает отказ на сервер, а не в браузер. + await wizard.getByRole("button", { name: "Закрыть и настроить самому" }).click(); + await expect(wizard).toHaveCount(0); + await expect.poll(() => actions).toEqual(["dismiss"]); + + // Закрытый визард оставляет пилюлю возврата: настроено ещё не всё. + const launcher = page.locator(".ob-launcher"); + await expect(launcher).toBeVisible(); + await expect(launcher).toContainText("5/8"); + await page.screenshot({ path: "test-results/onboarding-launcher.png" }); +}); + +test("администратору организации шаг про домен и почту не показывают", async ({ page }) => { + await login(page, ADMIN_IDENTITY, ONBOARDING_FRESH); + + await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click(); + const wizard = page.locator(".ob-steps"); + // Раздел «Платформа» — свойство установки, а не организации: шагов семь, и + // счётчик считает от семи. + await expect(wizard.locator(".ob-rail-step")).toHaveCount(7); + await expect(wizard.locator(".ob-rail-step")).not.toContainText(["Домен и почта"]); + await expect(wizard.locator(".ob-rail-progress-row span")).toHaveText("Готово 5 из 7"); + await expect(page.locator(".ob-steps")).toBeVisible(); +}); + +test("у семи шагов заголовок приветствия тоже числом-словом", async ({ page }) => { + await login(page, ADMIN_IDENTITY, ONBOARDING_FRESH); + + await expect(page.locator(".ob-welcome").getByRole("heading")).toHaveText("Семь шагов до первого ответа клиенту"); +}); + +test("«Показать где» затемняет приложение и подсвечивает пункт сайдбара", async ({ page }) => { + await login(page, OWNER_IDENTITY, ONBOARDING_FRESH); + + const wizard = page.locator(".ob-steps"); + await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click(); + // Второй шаг рассказывает про «Агентов» — их пункт тур и подсвечивает. + await wizard.getByRole("button", { name: "Далее" }).click(); + await wizard.getByRole("button", { name: "Показать где" }).click(); + + await expect(wizard).toHaveCount(0); + const callout = page.locator(".ob-tour-callout"); + await expect(callout).toBeVisible(); + await expect(callout).toContainText("Раздел «Агенты»"); + await expect(page.locator(".ob-tour-dim")).toHaveCount(4); + await settled(callout); + await page.screenshot({ path: "test-results/onboarding-tour.png" }); + + await callout.getByRole("button", { name: "Понятно, к шагам" }).click(); + await expect(page.locator(".ob-tour-callout")).toHaveCount(0); + await expect(wizard.getByRole("heading")).toHaveText("Создайте AI-агента"); +}); + +test("закрытый онбординг открывается заново ссылкой внизу субменю «Настроек»", async ({ page }) => { + await login(page, OWNER_IDENTITY, ONBOARDING_DISMISSED); + + // Визард закрыт и всё настроено: ни окна, ни пилюли. + await expect(page.locator(".ob-overlay")).toHaveCount(0); + await expect(page.locator(".ob-launcher")).toHaveCount(0); + + // Переход внутри приложения: перезагрузка страницы вернула бы форму входа — + // сессия в моках живёт только до неё. + await page.locator("nav.hub-nav").getByRole("button", { name: "Настройки", exact: true }).click(); + const link = page.locator(".settings-subnav-onboarding"); + await expect(link).toBeVisible(); + await expect(link).toContainText("Начало работы"); + await page.screenshot({ path: "test-results/onboarding-settings-link.png" }); + + await link.click(); + await expect(page.locator(".ob-steps")).toBeVisible(); +}); + +test("сотрудник онбординга не видит", async ({ page }) => { + await login(page, EMPLOYEE_IDENTITY, ONBOARDING_FRESH); + + await expect(page.locator(".ob-overlay")).toHaveCount(0); + await expect(page.locator(".ob-launcher")).toHaveCount(0); +}); + +test("последний шаг ведёт на финальный экран и записывает «пройден»", async ({ page }) => { + const actions: string[] = []; + await login(page, OWNER_IDENTITY, ONBOARDING_FRESH, actions); + + const wizard = page.locator(".ob-steps"); + await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click(); + // Навигация по рейке свободная: до последнего шага можно дойти одним кликом. + await wizard.locator(".ob-rail-step").last().click(); + await expect(wizard.getByRole("heading")).toHaveText("Напишите боту как клиент"); + + await wizard.getByRole("button", { name: "Завершить" }).click(); + const done = page.locator(".ob-done"); + await expect(done.getByRole("heading")).toHaveText("Всё готово — можно принимать клиентов"); + // Сводка честно показывает, что три шага так и не выполнены по факту. + await expect(done.locator(".ob-summary-item")).toHaveCount(8); + await expect(done.locator(".ob-summary-item.is-done")).toHaveCount(5); + await settled(done); + await page.screenshot({ path: "test-results/onboarding-done.png" }); + + await done.getByRole("button", { name: "Перейти в чат" }).click(); + await expect(page.locator(".ob-overlay")).toHaveCount(0); + await expect(page).toHaveURL(/\/chat$/); + await expect.poll(() => actions).toEqual(["complete"]); +}); + +test("подсветка тура встаёт ровно по элементу после смены экрана", async ({ page }) => { + await login(page, OWNER_IDENTITY, ONBOARDING_FRESH); + + const wizard = page.locator(".ob-steps"); + await page.locator(".ob-welcome").getByRole("button", { name: "Начать настройку" }).click(); + // Первый шаг ведёт в «Настройки → AI-провайдер». Новый маршрут въезжает + // анимацией `surface-enter` (translateY 6px): замер на первом кадре поставил + // бы рамку на 6px мимо строки, поэтому тур доводит его до остановки. + await wizard.getByRole("button", { name: "Показать где" }).click(); + + const callout = page.locator(".ob-tour-callout"); + await expect(callout).toContainText("Настройки → AI-провайдер"); + await settled(callout); + + const geometry = await page.evaluate(() => { + const box = (selector: string) => { + const rect = document.querySelector(selector)!.getBoundingClientRect(); + return [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)]; + }; + const root = box(".hub-shell"); + const target = box('[data-onboarding-target="settings-ai"]'); + const dim = [...document.querySelectorAll(".ob-tour-dim")].map((el) => { + const rect = el.getBoundingClientRect(); + return [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)]; + }); + return { root, target, spot: box(".ob-tour-spot"), dim }; + }); + + const [x, y, w, h] = geometry.target; + const [, , rootW, rootH] = geometry.root; + // Рамка — цель с отступом 6px со всех сторон. + expect(geometry.spot).toEqual([x - 6, y - 6, w + 12, h + 12]); + // Четыре затемнения смыкаются вокруг неё, не оставляя ни щели, ни просвета. + expect(geometry.dim).toEqual([ + [0, 0, rootW, y - 6], + [0, y + h + 6, rootW, rootH - (y + h + 6)], + [0, y - 6, x - 6, h + 12], + [x + w + 6, y - 6, rootW - (x + w + 6), h + 12], + ]); + + await page.screenshot({ path: "test-results/onboarding-tour-settings.png" }); +}); From eff6d7ac0cc765dec4e0a04f0081412c69429eff Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 13 Sep 2026 07:54:14 +0300 Subject: [PATCH 02/10] =?UTF-8?q?:fire:=20refactor(ai):=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BD=D0=B5=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D1=8B=20=D0=BE=D1=80=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт только OpenRouter; для Custom и локальной модели оставалась прайс-таблица из двух моделей, и на любой другой стоимость записывалась нулём. То есть на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал ложное чувство защиты. Общий лимит установки из переменной окружения CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся. Выбор валюты убран из настроек организации и из формы её создания. Сервер принимал только RUB, то есть в списке был один вариант, а само поле не читается нигде: ни одна сумма в продукте не считается в валюте организации. Колонка в базе остаётся, интерфейс её больше не спрашивает — сервер проставляет значение сам. Второй шаг онбординга больше не велит задавать дневной бюджет. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/ai/agent_card.py | 6 -- apps/backend/chatballs/ai/invocation.py | 2 +- apps/backend/chatballs/ai/limits.py | 14 +++-- .../ai/migrations/0018_remove_agent_limits.py | 18 ++++++ apps/backend/chatballs/ai/models.py | 4 -- apps/backend/chatballs/ai/services.py | 17 ------ apps/backend/chatballs/ai/tests.py | 18 ++++-- .../identity/demo_seed/loaders/channels_ai.py | 1 - .../identity/test_organization_creation.py | 2 + .../OrganizationSettingsForm.tsx | 7 --- .../src/features/agents/AgentDetailPage.tsx | 19 ------- apps/internal-ui/src/features/agents/model.ts | 14 ----- .../src/features/agents/styles.css | 55 +------------------ .../organizations/OrganizationCreatePage.tsx | 7 --- .../src/features/organizations/api.ts | 4 +- apps/internal-ui/src/i18n/en.ts | 15 +++-- apps/internal-ui/src/i18n/ru.ts | 15 +++-- tests/e2e/internal-ui.spec.ts | 3 +- 18 files changed, 62 insertions(+), 159 deletions(-) create mode 100644 apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 5e81f55..6192593 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) "model": agent.model, "providerIntegrationId": agent.provider_integration_id, "modelParams": agent.model_params, - "limits": agent.limits, "answerLanguage": agent.answer_language, "persona": agent.persona, "tone": agent.tone, @@ -232,7 +231,6 @@ def update_agent_card( ai_fields = { "providerIntegrationId", "modelParams", - "limits", "persona", "tone", "instructions", @@ -249,11 +247,8 @@ def update_agent_card( ): raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")}) model_params = body.get("modelParams", agent.model_params) - limits = body.get("limits", agent.limits) if not isinstance(model_params, dict): raise ValidationError({"modelParams": t("api.object_required")}) - if not isinstance(limits, dict): - raise ValidationError({"limits": t("api.object_required")}) provider_integration_id = body.get( "providerIntegrationId", agent.provider_integration_id ) @@ -270,7 +265,6 @@ def update_agent_card( provider_integration_id=provider_integration_id, model_params=model_params, allowed_tools=agent.allowed_tools, - limits=limits, persona=str(body.get("persona", agent.persona)), tone=str(body.get("tone", agent.tone)), instructions=str(body.get("instructions", agent.instructions)), diff --git a/apps/backend/chatballs/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py index f9d5e98..846da94 100644 --- a/apps/backend/chatballs/ai/invocation.py +++ b/apps/backend/chatballs/ai/invocation.py @@ -55,7 +55,7 @@ def invoke_chat( fallback_model = model or channel.ai_agent.model try: provider, model = _prepare_invocation(channel=channel, requested_model=model) - limits.assert_within_limits(channel, channel.ai_agent) + limits.assert_within_limits() except limits.LimitExceeded as error: _record_blocked( channel=channel, diff --git a/apps/backend/chatballs/ai/limits.py b/apps/backend/chatballs/ai/limits.py index 7853692..82c19c6 100644 --- a/apps/backend/chatballs/ai/limits.py +++ b/apps/backend/chatballs/ai/limits.py @@ -21,12 +21,14 @@ def daily_cost_micros(channel=None) -> int: return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 -def assert_within_limits(channel, agent) -> None: +def assert_within_limits() -> None: + """Единственный лимит расхода — общий по установке, из переменной окружения. + + Дневного бюджета на агенте нет: он считался по прайс-таблице, где всего две + модели, и на любой другой расход оставался нулевым — лимит не срабатывал + никогда и давал ложное чувство защиты. + """ + global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS if global_limit and daily_cost_micros() >= global_limit: raise LimitExceeded("Global daily AI cost limit reached") - # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается - # в micro-USD. 1 цент = 10 000 micro-USD. - channel_limit = (agent.limits or {}).get("dailyCostUsd") - if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000: - raise LimitExceeded("Channel daily AI cost limit reached") diff --git a/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py b/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py new file mode 100644 index 0000000..0108ac6 --- /dev/null +++ b/apps/backend/chatballs/ai/migrations/0018_remove_agent_limits.py @@ -0,0 +1,18 @@ +# Дневной бюджет агента снят вместе с полем `limits`: расход считался по +# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит +# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит +# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS). +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("ai", "0017_agent_answer_language"), + ] + + operations = [ + migrations.RemoveField( + model_name="aiagent", + name="limits", + ), + ] diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index c97598a..8efb503 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel): allowed_tools = models.JSONField(default=list, blank=True) - # Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD). - - limits = models.JSONField(default=dict, blank=True) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/backend/chatballs/ai/services.py b/apps/backend/chatballs/ai/services.py index 7f29767..0662b81 100644 --- a/apps/backend/chatballs/ai/services.py +++ b/apps/backend/chatballs/ai/services.py @@ -21,7 +21,6 @@ class AgentInput: provider_integration_id: int | None model_params: dict allowed_tools: list - limits: dict persona: str tone: str instructions: str @@ -39,20 +38,6 @@ class AgentCreateInput: knowledge_ids: list[int] -# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD -# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub, -# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются. -def _normalize_limits(raw: dict | None) -> dict: - if not isinstance(raw, dict): - return {} - value = raw.get("dailyCostUsd") - try: - cents = int(value) - except (TypeError, ValueError): - return {} - return {"dailyCostUsd": cents} if cents > 0 else {} - - def knowledge_for_agent_ids( *, context: TenantContext, channel: Channel, knowledge_ids: list[int] ) -> list[Knowledge]: @@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> locked.provider_integration = selection.integration locked.model_params = data.model_params locked.allowed_tools = data.allowed_tools - locked.limits = _normalize_limits(data.limits) locked.persona = data.persona locked.tone = data.tone locked.instructions = data.instructions @@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> "provider_integration", "model_params", "allowed_tools", - "limits", "persona", "tone", "instructions", diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index fbf41e0..f999e1c 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -378,22 +378,28 @@ class ChatInvocationTests(TestCase): self.assertNotIn("a@b.com", captured["messages"][0].content) - def test_limit_blocks_and_records(self) -> None: + def test_global_limit_blocks_and_records(self) -> None: + """Единственный лимит расхода — общий по установке, из окружения. + + Дневного бюджета на агенте больше нет: он опирался на прайс-таблицу из + двух моделей и на всех прочих не срабатывал. + """ + + from django.test import override_settings + from chatballs.ai import limits as ai_limits from chatballs.ai.invocation import invoke_chat from chatballs.ai.models import LlmInvocation, LlmInvocationStatus from chatballs.ai.provider.base import ChatMessage - self.agent.limits = {"dailyCostUsd": 1} - self.agent.save(update_fields=["limits"]) - # 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros. LlmInvocation.objects.create( channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001, status=LlmInvocationStatus.SUCCESS, ) - with self.assertRaises(ai_limits.LimitExceeded): - invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat") + with override_settings(CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=10_000): + with self.assertRaises(ai_limits.LimitExceeded): + invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat") self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists()) def test_invocation_records_used_fragment_ids(self) -> None: diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py index b7a68aa..d4b91b3 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py @@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ agent.persona = item.get("persona", "") agent.tone = item.get("tone", "") agent.instructions = item.get("instructions", "") - agent.limits = item.get("limits", {}) if agent.status in ("ACTIVE", "DISABLED"): agent.provider_integration = llm agent.model = (llm.config or {}).get("default_model", "demo") diff --git a/apps/backend/chatballs/identity/test_organization_creation.py b/apps/backend/chatballs/identity/test_organization_creation.py index 84223b9..a2be55f 100644 --- a/apps/backend/chatballs/identity/test_organization_creation.py +++ b/apps/backend/chatballs/identity/test_organization_creation.py @@ -60,6 +60,8 @@ class OrganizationCreationTests(TestCase): self.assertEqual(created.name, "Вторая компания") self.assertEqual(created.language, "en") self.assertEqual(created.status, "ACTIVE") + # Валюту интерфейс не спрашивает и не шлёт: сервер ставит её сам. + self.assertEqual(created.currency, "RUB") membership = OrganizationMembership.objects.get(organization=created, user=self.owner) self.assertEqual(membership.role, EmployeeRole.OWNER) self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists()) diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index db8100a..0312a5a 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -71,13 +71,6 @@ export function OrganizationSettingsForm({ onChange={(timezone) => onChange({ ...organization, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> - onChange({ ...organization, currency })} - options={[["RUB", t("admin.russian_rouble_rub")]]} - /> {/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов три, и выбор лучше видеть целиком, чем разворачивать список. В сетку diff --git a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx index b28fa29..956dc77 100644 --- a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx @@ -22,8 +22,6 @@ import { connectionStatusMeta, connectionSubtitle, createdLabel, - dailyCostCents, - dailyCostInput, deleteAgent, fetchAgent, knowledgeLine, @@ -534,17 +532,9 @@ function ModelCard({ card, providers, canManage, busy, apply }: { busy: boolean; apply: (patch: AgentPatch) => Promise; }) { - const [limit, setLimit] = useState(dailyCostInput(card.limits)); - useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]); const missingProvider = card.providerIntegrationId === null; const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? ""; - function saveLimit() { - const cents = dailyCostCents(limit); - if (cents === Number(card.limits.dailyCostUsd ?? 0)) return; - void apply({ limits: { ...card.limits, dailyCostUsd: cents } }); - } - return (

{t("common.model")}

@@ -567,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: { -
- {t("ai.daily_limit")} - - {canManage - ? setLimit(event.target.value)} onBlur={saveLimit} /> - : {limit || "0.00"}} - USD - -
); diff --git a/apps/internal-ui/src/features/agents/model.ts b/apps/internal-ui/src/features/agents/model.ts index 8783258..b6fee00 100644 --- a/apps/internal-ui/src/features/agents/model.ts +++ b/apps/internal-ui/src/features/agents/model.ts @@ -37,7 +37,6 @@ export type AgentCard = { model: string; providerIntegrationId: number | null; modelParams: Record; - limits: Record; // Режим языка ответов: MIRROR, ORGANIZATION или код языка. answerLanguage: string; persona: string; @@ -63,7 +62,6 @@ export type AgentPatch = Partial<{ tone: string; instructions: string; knowledgeIds: number[]; - limits: Record; }>; // Страница списка агентов (кадр G1): группа, поиск и страница — на сервере. @@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string { return `${head} · ${parts.join(" + ")}`; } -// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd, -// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками. -export function dailyCostInput(limits: Record): string { - const cents = Number(limits.dailyCostUsd ?? 0); - return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : ""; -} - -export function dailyCostCents(value: string): number { - const amount = Number(value.replace(",", ".").trim()); - if (!Number.isFinite(amount) || amount <= 0) return 0; - return Math.round(amount * 100); -} diff --git a/apps/internal-ui/src/features/agents/styles.css b/apps/internal-ui/src/features/agents/styles.css index f9ee428..b398c9b 100644 --- a/apps/internal-ui/src/features/agents/styles.css +++ b/apps/internal-ui/src/features/agents/styles.css @@ -841,15 +841,14 @@ .agent-field textarea:focus, .agent-field input:focus, .agent-create-modal .form-field.is-editable input:focus, -.agent-create-modal .select-like select:focus { +.agent-create-modal .select-box:focus-visible { border-color: var(--primary); background: var(--surface-card); box-shadow: 0 0 0 3px var(--primary-bg); } -/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */ -.agent-side-fields .select-like select:focus, -.agent-side-fields .select-box:focus-within { +/* Тот же фокус для общего селекта карточки. */ +.agent-side-fields .select-box:focus-visible { border-color: var(--primary); background: var(--surface-card); box-shadow: 0 0 0 3px var(--primary-bg); @@ -1045,54 +1044,6 @@ color: var(--n-5); } -.agent-limit { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 6px; -} - -.agent-limit > span:first-child { - color: var(--n-3); - font-size: 12.5px; - font-weight: 600; -} - -.agent-limit > span:last-child { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--n-1); - font-size: 13px; -} - -.agent-limit input, -.agent-limit-value { - display: inline-flex; - width: 56px; - height: 30px; - align-items: center; - padding: 0 10px; - border: 1px solid var(--n-7); - border-radius: 7px; - outline: none; - background: var(--n-10); - color: var(--n-1); - font-family: var(--font-mono); - font-size: 12.5px; -} - -.agent-limit input:focus { - border-color: var(--primary); - background: var(--surface-card); - box-shadow: 0 0 0 3px var(--primary-bg); -} - -.agent-limit small { - color: var(--n-4); -} - /* Подключения. */ .agent-connection { diff --git a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx index bb9a146..b436654 100644 --- a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx +++ b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx @@ -33,7 +33,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: { // Новая организация наследует региональные параметры текущей: чаще всего // человек заводит вторую компанию там же, где первую. timezone: "Europe/Moscow", - currency: "RUB", language: "", }); const [logo, setLogo] = useState(null); @@ -123,12 +122,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: { onChange={(timezone) => setDraft({ ...draft, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> - setDraft({ ...draft, currency })} - options={[["RUB", t("admin.russian_rouble_rub")]]} - />
{t("settings.language")} diff --git a/apps/internal-ui/src/features/organizations/api.ts b/apps/internal-ui/src/features/organizations/api.ts index 01e4c99..e6ebcdc 100644 --- a/apps/internal-ui/src/features/organizations/api.ts +++ b/apps/internal-ui/src/features/organizations/api.ts @@ -8,13 +8,13 @@ const BASE = "/api/v1/organizations/"; export type OrganizationCreateOptions = { timezones: string[]; languages: Array<{ code: string; label: string }>; - currencies: string[]; }; +// Валюты в черновике нет: в продукте нет ни одной суммы в валюте организации, +// сервер проставляет её сам. export type OrganizationDraft = { name: string; timezone: string; - currency: string; // Пустая строка — «как в установке». language: string; }; diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index d6ceca5..7be2419 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -308,7 +308,6 @@ export const en: Record = { "ai.created_on": "created {date}", "ai.creating": "Creating…", "ai.customers_reach_agent_through_these": "Customers reach the agent through these. One connection, one agent.", - "ai.daily_limit": "Daily limit", "ai.delete_agent": "Delete the agent", "ai.delete_agent_2": "Delete the agent?", "ai.delete_attachment": "Delete the attachment", @@ -1063,24 +1062,24 @@ export const en: Record = { "onboarding.s2.name": "AI agent", "onboarding.s2.time": "4 min", "onboarding.s2.title": "Create an AI agent", - "onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer, what rules they follow and how much they may spend per day.", + "onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer and what rules they follow.", "onboarding.s2.path": "Agents → Create agent", "onboarding.s2.do_1": "Open Agents in the sidebar and click Create agent.", "onboarding.s2.do_2": "Describe the role and tone: who they are to the customer, what they may promise, what they must not do.", - "onboarding.s2.do_3": "Pick a model from the connected provider and a daily budget in dollars.", + "onboarding.s2.do_3": "Pick a model from the connected provider.", "onboarding.s2.do_4": "Attach knowledge base articles — the agent answers only from those.", "onboarding.s2.do_5": "Switch the status from Draft to Active, otherwise the agent stays silent.", "onboarding.s2.warning": "If there is nothing to answer from, the agent says so honestly and offers to call a human — it will not make things up.", "onboarding.s2.preview": "Agents → Consultant", "onboarding.s2.row1_label": "Model", "onboarding.s2.row1_value": "openai/gpt-4o-mini", - "onboarding.s2.row2_label": "Daily budget", - "onboarding.s2.row2_value": "$5.00", - "onboarding.s2.row3_label": "Knowledge", - "onboarding.s2.row3_value": "12 articles", + "onboarding.s2.row2_label": "Knowledge", + "onboarding.s2.row2_value": "12 articles", + "onboarding.s2.row3_label": "Answer language", + "onboarding.s2.row3_value": "Same as the customer", "onboarding.s2.chip": "Active", "onboarding.s2.tour_title": "The Agents section", - "onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model, its budget and the knowledge attached to it.", + "onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model and the knowledge attached to it.", "onboarding.s3.name": "Knowledge base", "onboarding.s3.time": "from 10 min", "onboarding.s3.title": "Fill the knowledge base", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 9a1daf7..0f136b4 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -309,7 +309,6 @@ export const ru = { "ai.created_on": "создан {date}", "ai.creating": "Создание…", "ai.customers_reach_agent_through_these": "Через них клиенты попадают к агенту. Одно подключение — один агент.", - "ai.daily_limit": "Лимит в день", "ai.delete_agent": "Удалить агента", "ai.delete_agent_2": "Удалить агента?", "ai.delete_attachment": "Удалить вложение", @@ -1064,24 +1063,24 @@ export const ru = { "onboarding.s2.name": "AI-агент", "onboarding.s2.time": "4 мин", "onboarding.s2.title": "Создайте AI-агента", - "onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.", + "onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает и каким правилам подчиняется.", "onboarding.s2.path": "Агенты → Создать агента", "onboarding.s2.do_1": "Откройте «Агенты» в сайдбаре и нажмите «Создать агента».", "onboarding.s2.do_2": "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя.", - "onboarding.s2.do_3": "Выберите модель подключённого провайдера и дневной бюджет в долларах.", + "onboarding.s2.do_3": "Выберите модель подключённого провайдера.", "onboarding.s2.do_4": "Прикрепите статьи базы знаний — агент отвечает только из них.", "onboarding.s2.do_5": "Переведите статус из «Черновик» в «Активен», иначе агент молчит.", "onboarding.s2.warning": "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.", "onboarding.s2.preview": "Агенты → Консультант", "onboarding.s2.row1_label": "Модель", "onboarding.s2.row1_value": "openai/gpt-4o-mini", - "onboarding.s2.row2_label": "Дневной бюджет", - "onboarding.s2.row2_value": "$5.00", - "onboarding.s2.row3_label": "Знания", - "onboarding.s2.row3_value": "12 статей", + "onboarding.s2.row2_label": "Знания", + "onboarding.s2.row2_value": "12 статей", + "onboarding.s2.row3_label": "Язык ответов", + "onboarding.s2.row3_value": "Как у клиента", "onboarding.s2.chip": "Активен", "onboarding.s2.tour_title": "Раздел «Агенты»", - "onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.", + "onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель и прикреплённые знания.", "onboarding.s3.name": "База знаний", "onboarding.s3.time": "от 10 мин", "onboarding.s3.title": "Наполните базу знаний", diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 4962bd4..f8263b0 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -527,7 +527,8 @@ test("владелец добавляет организацию из перек // Сразу в новой организации: адрес и переключатель показывают её. await expect(page).toHaveURL(new RegExp(`/organizations/${NEW_ORGANIZATION_PUBLIC_ID}/chat`)); await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая компания"); - expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", currency: "RUB", language: "en" }); + // Валюты в теле нет: выбора её в интерфейсе больше нет, сервер ставит сам. + expect(created).toEqual({ name: "Вторая компания", timezone: "Europe/Moscow", language: "en" }); }); test("сотрудник без прав менеджера не видит «Добавить организацию»", async ({ page }) => { From 8580b480e7250ff582b1261d05582aba0bf75850 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 13 Sep 2026 08:12:22 +0300 Subject: [PATCH 03/10] =?UTF-8?q?:lipstick:=20fix(organization):=20=D1=8F?= =?UTF-8?q?=D0=B7=D1=8B=D0=BA=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=D0=B0=20=D1=81=D0=B5=D0=BB=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=BC,=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D0=B2=D1=91=D1=80=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=83=20=D0=BE=D0=B1=D0=B5=D0=B8=D1=85=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После удаления валюты в ряду полей осталась пустая половина. Теперь ряд занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину карточки, стал таким же селектом, как соседнее поле. Подпись под ним убрана вместе с ключом словаря. Форма создания организации и «Настройки → Организация» — одна форма по смыслу, поэтому у них теперь одна ширина колонки и одни отступы. Co-Authored-By: Claude Opus 5 --- .../OrganizationSettingsForm.tsx | 37 ++++--------------- .../src/features/administration/styles.css | 6 --- .../organizations/OrganizationCreatePage.tsx | 32 ++++------------ .../src/features/organizations/styles.css | 6 +-- apps/internal-ui/src/i18n/en.ts | 1 - apps/internal-ui/src/i18n/ru.ts | 1 - tests/e2e/internal-ui.spec.ts | 4 +- 7 files changed, 20 insertions(+), 67 deletions(-) diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index 0312a5a..941734a 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -71,37 +71,14 @@ export function OrganizationSettingsForm({ onChange={(timezone) => onChange({ ...organization, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> + onChange({ ...organization, language })} + options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]} + />
- {/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов - три, и выбор лучше видеть целиком, чем разворачивать список. В сетку - полей он не встаёт — она выравнивает поля по нижнему краю, и строка с - подписью ломала бы ряд. */} -
- {t("settings.language")} -
- - {languages.map((item) => ( - - ))} -
-
-

{t("settings.language_org_hint")}

{error &&
{error}
} {canManage && (
diff --git a/apps/internal-ui/src/features/administration/styles.css b/apps/internal-ui/src/features/administration/styles.css index 69fc981..63d21f0 100644 --- a/apps/internal-ui/src/features/administration/styles.css +++ b/apps/internal-ui/src/features/administration/styles.css @@ -112,12 +112,6 @@ grid-column: 1 / -1; } -/* Строка «Язык интерфейса» в карточке организации одна, разделять её сверху - нечем: линия из «Профиля», где такие строки идут подряд, здесь лишняя. */ -.administration-language { - border-top: 0; -} - .administration-message { margin-top: 14px; padding: 9px 12px; diff --git a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx index b436654..27f9de6 100644 --- a/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx +++ b/apps/internal-ui/src/features/organizations/OrganizationCreatePage.tsx @@ -122,32 +122,14 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: { onChange={(timezone) => setDraft({ ...draft, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> + setDraft({ ...draft, language })} + options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]} + />
-
- {t("settings.language")} -
- - {languages.map((item) => ( - - ))} -
-
-

{t("settings.language_org_hint")}

- {error &&
{error}
} + {error &&
{error}
}
diff --git a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx index 67cb49b..4e8d4d0 100644 --- a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx @@ -48,6 +48,10 @@ export function PortalCreateDialog({ const [error, setError] = useState(""); const [fieldErrors, setFieldErrors] = useState>({}); const portalKeyValid = validPortalKey(slug.trim()); + // Базовый домен — адрес самой установки; пока она известна только по IP, + // размещать портал негде, и честнее сказать это здесь, чем выдать адрес, + // который никуда не ведёт. + const baseDomainKnown = Boolean(address.baseDomain); async function submit() { setBusy(true); @@ -73,24 +77,28 @@ export function PortalCreateDialog({

{t("portals.portal_public_help_centre_with")}

- + {baseDomainKnown ? ( + + ) : ( +
{t("portals.installation_domain_missing")}
+ )} {error}
}
-
diff --git a/apps/internal-ui/src/features/support-portals/styles-list.css b/apps/internal-ui/src/features/support-portals/styles-list.css index 9027688..8dbc326 100644 --- a/apps/internal-ui/src/features/support-portals/styles-list.css +++ b/apps/internal-ui/src/features/support-portals/styles-list.css @@ -359,7 +359,7 @@ .portal-address-field > div { display: flex; - height: 40px; + height: 36px; overflow: hidden; border: 1px solid var(--border-input); border-radius: var(--radius-md); @@ -374,7 +374,12 @@ padding: 0 12px; border: 0; outline: 0; + /* Скин поля — из базлайна (bg подложки, текст --n-1): без явных цветов поле + красилось системным белым и в тёмной теме светилось. */ + background: transparent; + color: var(--n-1); font-family: var(--font-mono); + font-size: 13px; } .portal-address-field b { @@ -382,10 +387,10 @@ align-items: center; padding: 0 12px; border-left: 1px solid var(--border-input); - background: var(--n-10); - color: var(--text-secondary); + background: var(--n-9); + color: var(--n-4); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; font-weight: 500; } diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 059daa0..bd58696 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -878,6 +878,7 @@ export const en: Record = { "portals.import_articles_from_yaml": "Import articles from YAML", "portals.importing": "Importing…", "portals.insert_image": "Insert an image", + "portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.", "portals.insert_into_text": "Insert into the text", "portals.inserted_into_text": "· inserted into the text", "portals.invalid_yaml": "Invalid YAML", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index 2d889db..f14b6b1 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -879,6 +879,7 @@ export const ru = { "portals.import_articles_from_yaml": "Импорт статей из YAML", "portals.importing": "Импорт…", "portals.insert_image": "Вставить изображение", + "portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.", "portals.insert_into_text": "Вставить в текст", "portals.inserted_into_text": "· вставлен в текст", "portals.invalid_yaml": "Невалидный YAML", From eeed0ed51a0e23793fcb91a6ea3066f2f6919010 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 14 Sep 2026 01:49:33 +0300 Subject: [PATCH 09/10] =?UTF-8?q?:memo:=20docs(readme):=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D1=83=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=83=D1=87=D1=91=D1=82=D0=B0=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хвост рефакторинга 09fb0177: возможности больше нет, а описания её обещали. --- README.md | 9 ++++----- README.ru.md | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1947693..996bc19 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification. -The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key and set your own budget. +The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key. --- @@ -101,7 +101,7 @@ Everything else is done in **Settings**. |---|---| | **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. | | **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. | -| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model and a daily budget. Attach articles from the knowledge base. | +| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. | | **Employees** | Invite your team by email, assign roles and groups. | The home screen shows a launch checklist: create an agent, connect an entry point, invite employees. @@ -192,7 +192,7 @@ Waiting conversations and new messages reach the employee in Telegram or MAX. Li ### Your own server and your own AI model -Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model. A daily budget per agent in dollars, token and cost accounting for every call. +Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model. ### Customer data protection @@ -244,8 +244,7 @@ Check in order: 1. The agent status is **Active**, not **Draft**. 2. The agent has an AI model provider selected. Without it no answer is possible. 3. The provider in **Integrations** has the **Connected** status. Run the check to refresh it. -4. The agent's daily budget is not exhausted. Blocked calls are visible in the AI usage log. -5. The conversation is not switched to **Operator** or **Paused** mode. +4. The conversation is not switched to **Operator** or **Paused** mode.
diff --git a/README.ru.md b/README.ru.md index d0f2125..0807406 100644 --- a/README.ru.md +++ b/README.ru.md @@ -45,7 +45,7 @@ Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением. -Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет. +Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу. --- @@ -101,7 +101,7 @@ docker compose up -d --wait |---|---| | **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. | | **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. | -| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. | +| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. | | **Сотрудники** | Пригласите команду по почте, распределите роли и группы. | На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников. @@ -192,7 +192,7 @@ Telegram, MAX, электронная почта и чат на сайте по ### Свой сервер и своя ИИ-модель -Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову. +Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. ### Защита данных клиентов @@ -244,8 +244,7 @@ docker compose logs gateway 1. Агент в статусе **Активен**, а не **Черновик**. 2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен. 3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус. -4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ. -5. Диалог не переведён в режим **Оператор** или **Пауза**. +4. Диалог не переведён в режим **Оператор** или **Пауза**.
From 5d8362e8f6441c238c385c282ede345ac7815f3d Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 14 Sep 2026 01:49:52 +0300 Subject: [PATCH 10/10] =?UTF-8?q?:sparkles:=20feat(queue):=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20=D0=BA=20=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=83,=20=D1=8D=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Очередь помнит, с какого момента ждёт клиент (waiting_since), и «дольше всех ждущий» больше не считается по последнему сообщению: клиент, напомнивший о себе, уезжал в конец очереди. Постановка в очередь сведена в одно место (conversations.queue) вместо шести копий одного правила. Уведомление видно тем же, кому виден диалог: раньше оно фильтровалось только правом, и оператор чужой группы получал оклик с именем клиента и куском переписки по диалогу, который не может открыть. Получатели считаются одним запросом вместо запроса на каждого сотрудника. Уведомления доезжают событием по сокету, а не следующим опросом: сокет поднят на уровень оболочки и работает на любом экране. Появились уведомления самого браузера — без service worker и Web Push, чтобы не тащить на self-hosted зависимость от чужого push-сервиса. Настройка «о чём звать» стала одна на все транспорты (NotificationPreference), набор событий приведён к макету: «новый диалог» и «клиент запросил оператора» разделены, добавлены «назначили на меня» и «долго ждёт человека». Забытый диалог больше не тонет в тишине: свип напоминает группе, потом всем, потом руководству; сроки — настройка организации в новом разделе «Когда звать на помощь». Назначение стало осмысленным актом — назначенного зовут лично, у него есть срок, и не взял — диалог возвращается всем. Присутствие берётся из открытого сокета и подсказывает двум местам: очереди — что напоминать некому, и выбору ответственного — кто сейчас за рабочим местом. Макет: design/baseline/Очередь и уведомления. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 10 +- VERSION | 2 +- .../conversations/chat_extras_views.py | 40 +- .../chatballs/conversations/consumers.py | 42 +- .../chatballs/conversations/escalation.py | 184 ++ .../backend/chatballs/conversations/ingest.py | 44 +- .../0022_conversation_waiting_since.py | 40 + .../migrations/0023_queue_escalation.py | 46 + .../migrations/0024_queue_policy_author.py | 26 + .../backend/chatballs/conversations/models.py | 21 + apps/backend/chatballs/conversations/queue.py | 69 + .../chatballs/conversations/queue_models.py | 53 + .../conversations/queue_policy_views.py | 62 + .../chatballs/conversations/realtime.py | 26 +- .../chatballs/conversations/selectors.py | 10 +- .../chatballs/conversations/serializers.py | 11 + .../chatballs/conversations/services.py | 78 +- .../chatballs/conversations/test_directory.py | 72 +- .../conversations/test_queue_escalation.py | 246 +++ .../conversations/test_queue_order.py | 172 ++ .../conversations/test_queue_policy.py | 64 + apps/backend/chatballs/conversations/tests.py | 4 +- apps/backend/chatballs/conversations/urls.py | 2 + apps/backend/chatballs/conversations/views.py | 23 +- .../events/management/commands/run_worker.py | 13 + apps/backend/chatballs/i18n/messages/en.py | 30 +- apps/backend/chatballs/i18n/messages/ru.py | 30 +- .../demo_seed/data/en/operations.json | 12 +- .../demo_seed/data/ru/operations.json | 12 +- .../identity/demo_seed/loaders/operations.py | 14 +- apps/backend/chatballs/identity/policy.py | 22 +- .../identity/test_membership_migration.py | 8 + .../chatballs/notifications/binding.py | 14 +- .../chatballs/notifications/delivery.py | 42 +- .../chatballs/notifications/event_handlers.py | 12 +- .../0011_notification_audience_group.py | 36 + .../0012_notification_preference.py | 87 + .../0013_notification_types_from_baseline.py | 89 + .../migrations/0014_new_dialog_type.py | 69 + .../backend/chatballs/notifications/models.py | 91 +- .../chatballs/notifications/preferences.py | 94 + .../chatballs/notifications/realtime.py | 27 + .../chatballs/notifications/recipients.py | 71 + .../chatballs/notifications/selectors.py | 34 +- .../chatballs/notifications/services.py | 30 +- .../notifications/test_group_visibility.py | 86 + .../notifications/test_realtime_fanout.py | 93 + apps/backend/chatballs/notifications/tests.py | 18 +- apps/backend/chatballs/notifications/urls.py | 1 + apps/backend/chatballs/notifications/views.py | 86 +- apps/backend/chatballs/presence.py | 67 + apps/backend/chatballs/realtime.py | 31 + .../0036_notification_preference_guards.py | 47 + .../migrations/0037_queue_policy_guards.py | 47 + apps/internal-ui/src/App.tsx | 7 +- .../src/features/chat/ChatPage.tsx | 7 +- .../conversations/ConversationThread.tsx | 11 +- .../conversations/ConversationWorkspace.tsx | 11 +- .../features/conversations/DialogControls.tsx | 53 +- .../src/features/conversations/DialogList.tsx | 13 +- .../features/conversations/WaitingBlock.tsx | 54 + .../conversations/assigneeOptions.tsx | 129 ++ .../src/features/conversations/model.ts | 52 +- .../src/features/conversations/types.ts | 7 +- .../conversations/useConversationEvents.ts | 97 +- .../conversations/useEmployeeDirectory.ts | 2 +- .../notifications/browserNotifications.ts | 88 + .../src/features/notifications/model.ts | 18 + .../notifications/useNotificationAlerts.ts | 65 + .../profile/NotificationTypeChecks.tsx | 37 + .../ProfileBrowserNotificationsRow.tsx | 135 ++ .../profile/ProfileNotificationsCard.tsx | 32 +- .../src/features/profile/ProfilePage.tsx | 2 +- .../internal-ui/src/features/profile/model.ts | 5 +- .../src/features/profile/styles.css | 62 + .../features/realtime/RealtimeProvider.tsx | 69 + .../src/features/realtime/connection.ts | 104 + .../src/features/realtime/subscribers.ts | 25 + .../src/features/sales/dialogs/context.css | 111 + .../sales/dialogs/context/ClientContext.tsx | 4 +- .../src/features/sales/dialogs/list.css | 9 + .../src/features/settings/QueuePolicyCard.tsx | 124 ++ .../src/features/settings/SettingsPage.tsx | 2 + .../src/features/settings/queuePolicy.ts | 50 + .../src/features/settings/sections.ts | 8 + .../src/features/settings/styles.css | 107 + apps/internal-ui/src/i18n/en.ts | 53 +- apps/internal-ui/src/i18n/ru.ts | 53 +- apps/internal-ui/src/layout/Shell.tsx | 41 +- apps/internal-ui/src/shared/icons.tsx | 4 +- .../baseline/Очередь и уведомления/README.md | 54 + .../baseline/Очередь и уведомления/support.js | 1911 +++++++++++++++++ .../Очередь и уведомления Baseline.dc.html | 385 ++++ tests/e2e/profile.spec.ts | 193 ++ 94 files changed, 6518 insertions(+), 336 deletions(-) create mode 100644 apps/backend/chatballs/conversations/escalation.py create mode 100644 apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py create mode 100644 apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py create mode 100644 apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py create mode 100644 apps/backend/chatballs/conversations/queue.py create mode 100644 apps/backend/chatballs/conversations/queue_models.py create mode 100644 apps/backend/chatballs/conversations/queue_policy_views.py create mode 100644 apps/backend/chatballs/conversations/test_queue_escalation.py create mode 100644 apps/backend/chatballs/conversations/test_queue_order.py create mode 100644 apps/backend/chatballs/conversations/test_queue_policy.py create mode 100644 apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py create mode 100644 apps/backend/chatballs/notifications/migrations/0012_notification_preference.py create mode 100644 apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py create mode 100644 apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py create mode 100644 apps/backend/chatballs/notifications/preferences.py create mode 100644 apps/backend/chatballs/notifications/realtime.py create mode 100644 apps/backend/chatballs/notifications/recipients.py create mode 100644 apps/backend/chatballs/notifications/test_group_visibility.py create mode 100644 apps/backend/chatballs/notifications/test_realtime_fanout.py create mode 100644 apps/backend/chatballs/presence.py create mode 100644 apps/backend/chatballs/realtime.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py create mode 100644 apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py create mode 100644 apps/internal-ui/src/features/conversations/WaitingBlock.tsx create mode 100644 apps/internal-ui/src/features/conversations/assigneeOptions.tsx create mode 100644 apps/internal-ui/src/features/notifications/browserNotifications.ts create mode 100644 apps/internal-ui/src/features/notifications/useNotificationAlerts.ts create mode 100644 apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx create mode 100644 apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx create mode 100644 apps/internal-ui/src/features/realtime/RealtimeProvider.tsx create mode 100644 apps/internal-ui/src/features/realtime/connection.ts create mode 100644 apps/internal-ui/src/features/realtime/subscribers.ts create mode 100644 apps/internal-ui/src/features/settings/QueuePolicyCard.tsx create mode 100644 apps/internal-ui/src/features/settings/queuePolicy.ts create mode 100644 design/baseline/Очередь и уведомления/README.md create mode 100644 design/baseline/Очередь и уведомления/support.js create mode 100644 design/baseline/Очередь и уведомления/Очередь и уведомления Baseline.dc.html create mode 100644 tests/e2e/profile.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 08050fd..6f65256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ Если в окружении не доступен инструмент, например - Python, PHP, попробуй docker, если в проекте есть его файлы. +Тесты: гонять только те, что относятся к изменённому в текущем шаге. Полный +прогон — только по моему явному указанию, никогда по своей инициативе. Полный +набор идёт ~20 минут, и гонять его после каждой правки запрещено. + Без моего явного указания не меняй ничего! Если я задал вопросы, это не значит что ты можешь менять файлы! Любые правки только после моего явного указания, например - "делай". @@ -56,7 +60,11 @@ Engineering rules / обязательные практики: - Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык. Definition of Done: -- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца. +- Запускать только тесты, относящиеся к изменениям текущей итерации: изменил + presence — гоняешь тесты присутствия, изменил тексты — гоняешь каталог i18n. + Полный прогон всех тестов выполнять ТОЛЬКО по явному указанию владельца. + Использовать `--reuse-db`; `--create-db` — лишняя минута на переигрывание + миграций, она нужна только когда схема действительно поменялась. - Измененные файлы должны быть проверены на NO GOD violations. - Новый компонент не должен владеть несвязанными ответственностями. - Не должно быть придуманных текстов, иконок, layout-решений или состояний вне design/docs. diff --git a/VERSION b/VERSION index 2eda823..9dbb0c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.1 \ No newline at end of file +1.7.0 \ No newline at end of file diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 1453523..fd75501 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -22,6 +22,7 @@ from chatballs.conversations.models import ( LifecycleState, ReplyTemplate, ) +from chatballs.conversations.queue_models import policy_for from chatballs.conversations.selectors import apply_conversation_visibility from chatballs.conversations.serializers import conversation_payload from chatballs.conversations.view_base import ConversationViewBase @@ -30,6 +31,7 @@ from chatballs.identity.avatars import user_avatar_url from chatballs.identity.group_models import EmployeeGroup from chatballs.identity.models import HumanUser, OrganizationMembership from chatballs.identity.policy import can_administer_access +from chatballs.presence import ONLINE_WITHIN_SECONDS, last_seen def _label_payload(label: ConversationLabel) -> dict[str, object]: @@ -258,10 +260,21 @@ class ConversationCountersView(ConversationViewBase): } for assignee in assignees: assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id) + # Два разных ожидания (макет Q3): диалог ничей — взять может любой; + # диалог назначен лично на меня и ждёт, пока я его возьму. Смешивать их + # в одном счётчике значит прятать своё среди чужого. + waiting_qs = open_qs.filter(control_mode=ControlMode.PAUSED) return Response( { "all": open_qs.count(), - "waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(), + "waiting": waiting_qs.count(), + "queue": waiting_qs.filter(assigned_operator__isnull=True).count(), + "waitingOnMe": waiting_qs.filter(assigned_operator_id=request.user.id).count(), + # Срок личной очереди: по нему клиент считает, через сколько + # диалог вернётся всем. + "assignmentTimeoutMinutes": policy_for( + request.tenant_context.organization + ).assignment_timeout_minutes, "mine": base.filter(assigned_operator_id=request.user.id).count(), "ungrouped": ungrouped, "groups": groups, @@ -301,6 +314,22 @@ class ConversationDirectoryView(APIView): # Ответственного можно назначить и вне выдачи — по поиску, поэтому # оставшихся не прячем молча, а сообщаем признаком hasMore. rows = list(members[: DIRECTORY_LIMIT + 1]) + shown = rows[:DIRECTORY_LIMIT] + # Присутствие и загрузка — второй и третий признак при выборе + # ответственного (макет «Очередь и уведомления», кадр Q5). Назначить + # отсутствующего можно: признак приблизительный и ничего не запрещает. + user_ids = [member.user_id for member in shown] + seen = last_seen(organization_id, user_ids) + now = timezone.now() + load = dict( + Conversation.objects.filter( + organization_id=organization_id, + lifecycle=LifecycleState.OPEN, + assigned_operator_id__in=user_ids, + ) + .values_list("assigned_operator_id") + .annotate(total=Count("id")) + ) return Response( { "groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups], @@ -309,8 +338,15 @@ class ConversationDirectoryView(APIView): "id": member.user_id, "name": member.user.full_name or member.user.email, "avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id), + "role": member.role, + "online": member.user_id in seen + and (now - seen[member.user_id]).total_seconds() <= ONLINE_WITHIN_SECONDS, + "lastSeenAt": ( + seen[member.user_id].isoformat() if member.user_id in seen else None + ), + "openDialogs": load.get(member.user_id, 0), } - for member in rows[:DIRECTORY_LIMIT] + for member in shown ], "hasMoreEmployees": len(rows) > DIRECTORY_LIMIT, } diff --git a/apps/backend/chatballs/conversations/consumers.py b/apps/backend/chatballs/conversations/consumers.py index 648a2cf..27d578e 100644 --- a/apps/backend/chatballs/conversations/consumers.py +++ b/apps/backend/chatballs/conversations/consumers.py @@ -1,4 +1,9 @@ -"""WebSocket оповещений о диалогах (см. chatballs.conversations.realtime). +"""WebSocket оповещений рабочего места (см. chatballs.conversations.realtime и +chatballs.notifications.realtime). + +Сокет один на сессию: по нему идут и события диалогов, и события уведомлений. +Второй сокет ради второго источника означал бы второе переподключение, вторую +аутентификацию и вторую точку отказа на ровном месте. Правила: - аутентификация — сессией того же SPA (AuthMiddlewareStack), отдельного токена @@ -23,6 +28,8 @@ from chatballs.conversations.models import Conversation from chatballs.conversations.realtime import conversation_group, inbox_group from chatballs.conversations.selectors import conversation_is_visible from chatballs.identity.models import OrganizationMembership +from chatballs.notifications.realtime import user_group +from chatballs.presence import touch from chatballs.tenancy.database import tenant_atomic from chatballs.tenancy.lookup import organization_by_public_id @@ -36,6 +43,8 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): self.organization_id: int | None = None self.membership_id: int | None = None self.watched: str | None = None + self.personal: str | None = None + self.user_id: int | None = None user = self.scope.get("user") if user is None or not user.is_authenticated: await self.close(code=NOT_A_MEMBER_CLOSE) @@ -46,20 +55,42 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): await self.close(code=NOT_A_MEMBER_CLOSE) return self.organization_id, self.membership_id = resolved + self.user_id = user.id + # Открытый сокет и есть присутствие: ничего специально «включать» для + # этого сотрудник не должен (chatballs.presence). + await self._touch_presence() await self.channel_layer.group_add(inbox_group(self.organization_id), self.channel_name) + # Уведомления адресованы человеку, а не организации: у каждого своя группа. + self.personal = user_group(user.id) + await self.channel_layer.group_add(self.personal, self.channel_name) await self.accept() async def disconnect(self, code: int) -> None: + if self.organization_id is not None and self.user_id is not None: + # Не «его нет», а «здесь он был в последний раз». + await database_sync_to_async(touch)(self.organization_id, self.user_id) if self.organization_id is not None: await self.channel_layer.group_discard( inbox_group(self.organization_id), self.channel_name ) + if self.personal is not None: + await self.channel_layer.group_discard(self.personal, self.channel_name) if self.watched is not None: await self.channel_layer.group_discard(self.watched, self.channel_name) async def receive_json(self, content: dict, **kwargs) -> None: - """Клиент сообщает, какой диалог открыт: событий по нему он и ждёт.""" - if content.get("type") != "watch" or self.organization_id is None: + """Клиент сообщает, какой диалог открыт: событий по нему он и ждёт. + + Он же раз в минуту присылает heartbeat — по нему продлевается отметка + присутствия. Без неё ключ истекает сам, и оборванное соединение + перестаёт считаться живым без отдельного уборщика. + """ + if self.organization_id is None: + return + if content.get("type") == "ping": + await self._touch_presence() + return + if content.get("type") != "watch": return conversation_id = content.get("conversationId") if self.watched is not None: @@ -80,6 +111,11 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer): async def fanout(self, event: dict) -> None: await self.send_json(event["payload"]) + async def _touch_presence(self) -> None: + if self.organization_id is None or self.user_id is None: + return + await database_sync_to_async(touch)(self.organization_id, self.user_id) + @database_sync_to_async def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None: try: diff --git a/apps/backend/chatballs/conversations/escalation.py b/apps/backend/chatballs/conversations/escalation.py new file mode 100644 index 0000000..2eb01ee --- /dev/null +++ b/apps/backend/chatballs/conversations/escalation.py @@ -0,0 +1,184 @@ +"""Что происходит, когда диалог ждёт слишком долго. + +Раньше не происходило ничего. Про ждущий диалог операторов окликали ровно один +раз, и дальше `dedup_key` на сутки гарантировал тишину: если в тот момент никто +не смотрел на экран, диалог мог провисеть до автозакрытия, и узнать об этом было +неоткуда. + +Уровень выбирается по времени ожидания, а не по счётчику попыток: состояние +хранить не нужно, потому что повтор гасит тот же `dedup_key` — свой у каждого +уровня. Свип идеемпотентен и может выполняться сколь угодно часто. + +Назначенный диалог не эскалируется: он не в общей очереди, а в личной, и у неё +свой срок — не взял, значит возвращаем всем. + +Присутствие сокращает ожидание, но не заменяет его. Если в группе диалога сейчас +никого нет за рабочим местом, ждать второго порога бессмысленно: напоминать +некому, и круг расширяется сразу. Обратного правила нет — присутствие никого не +задерживает и ничего не запрещает, потому что ошибается в обе стороны. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +from django.utils import timezone + +from chatballs.conversations.models import ( + ControlMode, + Conversation, + LifecycleState, + Message, + MessageAuthor, + SystemEvent, +) +from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for +from chatballs.conversations.services import operator_label +from chatballs.i18n import t +from chatballs.notifications.models import ( + NotificationAudience, + NotificationLevel, + NotificationType, +) +from chatballs.notifications.recipients import audience_user_ids +from chatballs.notifications.services import notify, notify_management +from chatballs.presence import online_user_ids +from chatballs.tenancy.context import TenantContext + +logger = logging.getLogger(__name__) + + +def _contact_name(conversation: Conversation) -> str: + return getattr(conversation.contact, "name", "") or t("conversations.guest") + + +def _waiting_conversations(context: TenantContext): + return Conversation.objects.filter( + organization=context.organization, + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + waiting_since__isnull=False, + ).select_related("contact", "assigned_operator", "group") + + +def sweep_waiting_conversations(context: TenantContext) -> int: + """Оклики по ждущим диалогам организации. Возвращает число новых уведомлений.""" + policy = policy_for(context.organization) + now = timezone.now() + fired = 0 + for conversation in _waiting_conversations(context): + if conversation.assigned_operator_id: + fired += _expire_stale_assignment(context, conversation, policy, now) + else: + fired += _escalate(context, conversation, policy, now) + return fired + + +def _nobody_is_watching(context: TenantContext, conversation: Conversation) -> bool: + """В группе диалога никого нет за рабочим местом.""" + watchers = audience_user_ids( + organization_id=context.organization_id, + audience=NotificationAudience.OPERATORS, + group_id=conversation.group_id, + ) + return not online_user_ids(context.organization_id, watchers) + + +def _escalate( + context: TenantContext, + conversation: Conversation, + policy: QueueEscalationPolicy, + now: datetime, +) -> int: + waited = now - conversation.waiting_since + contact = _contact_name(conversation) + common = { + "context": context, + "type": NotificationType.DIALOG_WAITING_LONG, + "text_params": {"contact": contact}, + "target_id": conversation.id, + "source_type": "Conversation", + "source_id": conversation.id, + } + fired = 0 + if waited >= timedelta(minutes=policy.remind_after_minutes): + # Тот же круг, что и в первый раз: смена на месте, просто не заметила. + fired += notify( + audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, + level=NotificationLevel.WARNING, + title=f"Диалог всё ещё ждёт · {contact}", + title_key="notifications.still_waiting", + body_key="notifications.still_waiting_body", + dedup_key=f"waiting:{conversation.id}:remind", + **common, + ) is not None + widen_after = policy.widen_after_minutes + if waited >= timedelta(minutes=policy.remind_after_minutes) and _nobody_is_watching( + context, conversation + ): + # Некому заметить напоминание — второй порог ждать незачем. + widen_after = min(widen_after, policy.remind_after_minutes) + if waited >= timedelta(minutes=widen_after): + # Круг шире группы: в своей группе ответить некому. + fired += notify( + audience=NotificationAudience.OPERATORS, + audience_group=None, + level=NotificationLevel.WARNING, + title=f"Диалог всё ещё ждёт · {contact}", + title_key="notifications.still_waiting", + body_key="notifications.still_waiting_body", + dedup_key=f"waiting:{conversation.id}:widen", + **common, + ) is not None + if waited >= timedelta(minutes=policy.escalate_after_minutes): + # Это уже не про сменщика, а про то, что смены нет. + fired += notify_management( + level=NotificationLevel.CRITICAL, + title=f"Диалог никто не берёт · {contact}", + title_key="notifications.waiting_unattended", + body_key="notifications.waiting_unattended_body", + dedup_key=f"waiting:{conversation.id}:management", + **common, + ) + return fired + + +def _expire_stale_assignment( + context: TenantContext, + conversation: Conversation, + policy: QueueEscalationPolicy, + now: datetime, +) -> int: + if conversation.assigned_at is None: + return 0 + if now - conversation.assigned_at < timedelta(minutes=policy.assignment_timeout_minutes): + return 0 + label = operator_label(conversation.assigned_operator) + conversation.assigned_operator = None + conversation.assigned_at = None + conversation.save(update_fields=["assigned_operator", "assigned_at"]) + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.SYSTEM, + system_event=SystemEvent.ASSIGNMENT_EXPIRED, + system_params={"operator": label}, + text=f"{label} не взял диалог — он вернулся в очередь", + ) + logger.info("Assignment on conversation %s expired", conversation.id) + contact = _contact_name(conversation) + return notify( + context=context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, + level=NotificationLevel.WARNING, + title=f"Диалог снова ничей · {contact}", + title_key="notifications.assignment_expired", + text_params={"contact": contact, "operator": label}, + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"unassigned:{conversation.id}:{conversation.waiting_since.isoformat()}", + ) is not None diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index 424e10b..997c05d 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -30,6 +30,7 @@ from chatballs.conversations.models import ( SystemEvent, TranscriptStatus, ) +from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting from chatballs.conversations.transports.base import InboundMessage from chatballs.events.models import EventOwnership, InboxEvent from chatballs.i18n import t @@ -249,6 +250,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: external_chat_id=inbound.chat_id, control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED, expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR, + waiting_since=None if ai_available else timezone.now(), previous_conversation=previous, ) elif inbound.chat_id and not conversation.external_chat_id: @@ -282,9 +284,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: conversation.last_activity_at = timezone.now() update_fields = ["external_chat_id", "last_activity_at"] if conversation.control_mode == ControlMode.AI and not ai_available: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - update_fields.extend(["control_mode", "expected_responder"]) + enter_queue(conversation) + update_fields.extend(QUEUE_FIELDS) if inbound.thread_meta: # Email: Message-ID последнего входящего — для ответа в тред; # тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035). @@ -298,10 +299,18 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: conversation.save(update_fields=update_fields) if is_new: + # Диалог, которым занялся агент, — это «новый диалог» и больше ничего. + # Диалог, отвечать в котором некому, — уже просьба о человеке: событие + # одно, а смысл для смены разный, и подписки на них тоже разные. notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=( + NotificationType.OPERATOR_REQUESTED + if is_waiting(conversation) + else NotificationType.NEW_DIALOG + ), audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Новый диалог · {channel.name}", body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}", title_key="notifications.new_dialog", @@ -324,6 +333,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: context=context, type=NotificationType.DIALOG_NEW_MESSAGE, audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS, + audience_group=conversation.group, recipient_user=operator, title=f"Новое сообщение · {contact.name or 'Гость'}", body=message_text[:120], @@ -353,15 +363,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ai_input = transcribe_voice_message(channel, message) if (is_voice and not ai_input) or files_only: if conversation.control_mode == ControlMode.AI: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "expected_responder"]) + enter_queue(conversation) + conversation.save(update_fields=QUEUE_FIELDS) if is_new: return notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Нужен оператор · {contact.name or 'Гость'}", title_key="notifications.operator_needed", text_params={"contact": contact.name or t("conversations.guest")}, @@ -385,10 +395,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: # Сбой AI не должен «терять» сообщение: переводим диалог в очередь к # оператору, уведомляем и отвечаем клиенту понятным fallback. logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR + enter_queue(conversation) conversation.last_activity_at = timezone.now() - conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"]) Message.objects.create( conversation=conversation, author_type=MessageAuthor.SYSTEM, @@ -399,8 +408,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback) notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"Нужен оператор · {contact.name or 'Гость'}", title_key="notifications.operator_needed", text_params={"contact": contact.name or t("conversations.guest")}, @@ -413,7 +423,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ) notify_management( context=context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, title=f"Ошибка AI · {channel.name}", body="AI временно недоступен, диалог передан оператору", title_key="notifications.ai_error", @@ -435,11 +445,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply) conversation.last_activity_at = timezone.now() if handoff: - conversation.control_mode = ControlMode.PAUSED - conversation.expected_responder = ExpectedResponder.OPERATOR + enter_queue(conversation) else: conversation.expected_responder = ExpectedResponder.CUSTOMER - conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"]) if handoff: Message.objects.create( @@ -450,8 +459,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: ) notify( context=context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, + audience_group=conversation.group, title=f"AI передал диалог · {contact.name or 'Гость'}", title_key="notifications.ai_handed_over", text_params={"contact": contact.name or t("conversations.guest")}, diff --git a/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py b/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py new file mode 100644 index 0000000..05e6ae1 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0022_conversation_waiting_since.py @@ -0,0 +1,40 @@ +"""Время ожидания в очереди — отдельным полем. + +Раньше «дольше всех ждущий» вычислялся по времени последнего сообщения, и +очередь работала обратно смыслу: клиент, напомнивший о себе, двигал +last_message_at вперёд и падал в конец очереди. Поле ставится один раз при входе +в очередь (chatballs.conversations.queue) и снимается при выходе из неё. + +Backfill берёт last_message_at — единственное, что известно про уже ждущие +диалоги. Для них порядок не ухудшится: в старой сортировке ключ был тот же. +""" + +from django.db import migrations, models + +BACKFILL = """ +UPDATE conversations_conversation +SET waiting_since = last_message_at +WHERE lifecycle = 'OPEN' AND control_mode = 'PAUSED' AND waiting_since IS NULL +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0021_i18n_events'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='waiting_since', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop), + migrations.AddIndex( + model_name='conversation', + index=models.Index( + fields=['organization', 'waiting_since'], name='conv_waiting_order' + ), + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py b/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py new file mode 100644 index 0000000..7af0d3a --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0023_queue_escalation.py @@ -0,0 +1,46 @@ +"""Личная очередь и пороги эскалации. + +assigned_at нужен, чтобы у назначения был срок: waiting_since для этого не +годится — назначить могут и через час после того, как диалог встал в очередь. +Пороги — строка на организацию с дефолтами: «долго» у круглосуточной +поддержки и у приёма по будням означает разное. +""" + + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0022_conversation_waiting_since'), + ('identity', '0039_remove_organization_currency'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='assigned_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='message', + name='system_event', + field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('assigned_to', 'Диалог назначен сотруднику'), ('assignment_expired', 'Назначение истекло'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32), + ), + migrations.CreateModel( + name='QueueEscalationPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remind_after_minutes', models.PositiveIntegerField(default=5)), + ('widen_after_minutes', models.PositiveIntegerField(default=15)), + ('escalate_after_minutes', models.PositiveIntegerField(default=30)), + ('assignment_timeout_minutes', models.PositiveIntegerField(default=10)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='queue_policy', to='identity.organization')), + ], + options={ + 'db_table': 'conversations_queueescalationpolicy', + }, + ), + ] diff --git a/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py b/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py new file mode 100644 index 0000000..48889bc --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0024_queue_policy_author.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.16 on 2026-09-13 19:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0023_queue_escalation'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='queueescalationpolicy', + name='updated_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='queueescalationpolicy', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 6478cdc..5f21e2f 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -166,6 +166,10 @@ class Conversation(models.Model): lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN) control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI) expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI) + # С какого момента диалог ждёт человека (chatballs.conversations.queue). + # Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а + # больше прежнего, и в очереди обязан оставаться выше, а не ниже. + waiting_since = models.DateTimeField(null=True, blank=True) # Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при # создании, переносится вручную. NULL — диалог виден всем сотрудникам. group = models.ForeignKey( @@ -177,6 +181,9 @@ class Conversation(models.Model): ) # «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп. assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations") + # Когда назначили. С этого момента идёт срок личной очереди: не взял — + # диалог возвращается в общую (chatballs.conversations.escalation). + assigned_at = models.DateTimeField(null=True, blank=True) # Дизайн-базлайн v2: приоритет, метки и заметка оператора. priority = models.CharField( max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE @@ -219,6 +226,10 @@ class Conversation(models.Model): models.Index( fields=["contact", "-last_activity_at"], name="conv_contact_recent" ), + # Очередь к оператору: кто ждёт дольше всех и не дождался порога. + models.Index( + fields=["organization", "waiting_since"], name="conv_waiting_order" + ), ] constraints = [ # Диалог всегда принадлежит контакту. @@ -274,6 +285,8 @@ class SystemEvent(models.TextChoices): RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь" AI_UNAVAILABLE = "ai_unavailable", "AI недоступен" AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору" + ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику" + ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло" CALL_REQUESTED = "call_requested", "Запрошен звонок" CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение" CALL_DECLINED = "call_declined", "Клиент отклонил приглашение" @@ -400,3 +413,11 @@ class ReplyTemplate(models.Model): def __str__(self) -> str: return f"template:{self.organization_id}/{self.title}" + + +# Django импортирует только models.py: пороги очереди лежат рядом, чтобы не +# растить этот файл, и переэкспортируются здесь ради регистрации модели. +from chatballs.conversations.queue_models import ( # noqa: E402, F401 + QueueEscalationPolicy, + policy_for, +) diff --git a/apps/backend/chatballs/conversations/queue.py b/apps/backend/chatballs/conversations/queue.py new file mode 100644 index 0000000..d2478c3 --- /dev/null +++ b/apps/backend/chatballs/conversations/queue.py @@ -0,0 +1,69 @@ +"""Очередь к оператору: единственное место, где диалог в неё входит и выходит. + +Правило «диалог ждёт человека» — это три поля сразу: control_mode = PAUSED, +expected_responder = OPERATOR и момент, с которого пошло ожидание. Раньше первые +два выставлялись в шести местах подряд (создание диалога без доступного AI, +клиент написал в диалог без AI, голосовое без расшифровки, сбой провайдера, +хендофф агента, ручной возврат оператором), а третьего не было вовсе: «дольше +всех ждущий» считался по времени последнего сообщения. + +Из-за этого очередь вела себя обратно смыслу. Клиент, который писал повторно, +двигал last_message_at вперёд и падал в конец очереди: чем настойчивее человек, +тем позже до него доходили руки. Поэтому waiting_since ставится один раз — при +входе в очередь — и не обновляется, пока диалог из неё не вышел. +""" + +from __future__ import annotations + +from datetime import datetime + +from django.utils import timezone + +from chatballs.conversations.models import ( + ControlMode, + Conversation, + ExpectedResponder, + LifecycleState, +) + +# Что пишет enter_queue. Вызывающий кладёт это в update_fields своего save(): +# состояние диалога меняется вместе с остальными полями, одной записью. +QUEUE_FIELDS = ("control_mode", "expected_responder", "waiting_since") + + +def is_waiting(conversation: Conversation) -> bool: + """Диалог стоит в очереди к человеку. + + Закрытый и спам тоже лежат в PAUSED, но никого не ждут — отсюда проверка + жизненного цикла. + """ + return ( + conversation.lifecycle == LifecycleState.OPEN + and conversation.control_mode == ControlMode.PAUSED + ) + + +def enter_queue(conversation: Conversation, *, now: datetime | None = None) -> bool: + """Ставит диалог в очередь. True — если он в неё только что попал. + + Возврат нужен вызывающему, чтобы решить, звать ли операторов: повторное + сообщение клиента в уже ждущий диалог очередь не меняет и второго оклика не + заслуживает. + """ + entered = not is_waiting(conversation) + conversation.control_mode = ControlMode.PAUSED + conversation.expected_responder = ExpectedResponder.OPERATOR + if entered: + conversation.waiting_since = now or timezone.now() + return entered + + +def leave_queue(conversation: Conversation) -> None: + """Диалог больше никого не ждёт. + + Несимметрично enter_queue намеренно: вход в очередь — одно состояние, а + выходов несколько (оператор взял, диалог вернули AI, закрыли, пометили + спамом), и control_mode у каждого свой. Общее у них только одно — ожидание + закончилось, и его начало больше не имеет смысла. + """ + conversation.waiting_since = None diff --git a/apps/backend/chatballs/conversations/queue_models.py b/apps/backend/chatballs/conversations/queue_models.py new file mode 100644 index 0000000..82e891f --- /dev/null +++ b/apps/backend/chatballs/conversations/queue_models.py @@ -0,0 +1,53 @@ +"""Пороги очереди: через сколько напоминать, расширять круг и звать руководство. + +Числа разные у разных организаций — у круглосуточной поддержки хостинга и у +клиники с приёмом по будням «долго» означает не одно и то же, — поэтому они +настройка, а не константа в коде. Строка одна на организацию и заводится с +дефолтами при первом обращении. +""" + +from __future__ import annotations + +from django.conf import settings +from django.db import models + + +class QueueEscalationPolicy(models.Model): + organization = models.OneToOneField( + "identity.Organization", on_delete=models.CASCADE, related_name="queue_policy" + ) + # Диалог ждёт дольше этого — повторный оклик той же группе. + remind_after_minutes = models.PositiveIntegerField(default=5) + # Ждёт ещё дольше — круг расширяется за пределы группы диалога. + widen_after_minutes = models.PositiveIntegerField(default=15) + # Совсем долго — это уже не про сменщика, а про руководство. + escalate_after_minutes = models.PositiveIntegerField(default=30) + # Назначили ответственного, а он не взял — диалог возвращается в общую + # очередь. Без этого назначение работает как способ спрятать диалог: из + # общей очереди он ушёл, а отвечать некому. + assignment_timeout_minutes = models.PositiveIntegerField(default=10) + # Кто и когда менял: в разделе настроек это подпись под формой. Сроки — + # правило работы смены, и знать, чьё это решение, важнее, чем кажется. + updated_at = models.DateTimeField(null=True, blank=True) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+" + ) + + # Значения по умолчанию — они же «обычные сроки» в кнопке сброса. + DEFAULTS = { + "remind_after_minutes": 5, + "widen_after_minutes": 15, + "escalate_after_minutes": 30, + "assignment_timeout_minutes": 10, + } + + class Meta: + db_table = "conversations_queueescalationpolicy" + + def __str__(self) -> str: + return f"queue-policy:{self.organization_id}" + + +def policy_for(organization) -> QueueEscalationPolicy: + policy, _ = QueueEscalationPolicy.objects.get_or_create(organization=organization) + return policy diff --git a/apps/backend/chatballs/conversations/queue_policy_views.py b/apps/backend/chatballs/conversations/queue_policy_views.py new file mode 100644 index 0000000..c9dcf5e --- /dev/null +++ b/apps/backend/chatballs/conversations/queue_policy_views.py @@ -0,0 +1,62 @@ +"""Сроки очереди: раздел «Когда звать на помощь» (макет Q2). + +До этого пороги правились только в служебной админке — то есть де-факто никем. +Читает их тот, кто видит настройки; меняет — тот, кто ими управляет. +""" + +from __future__ import annotations + +from django.utils import timezone +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for +from chatballs.i18n import t +from chatballs.identity.policy import ResourceScope, authorize + +FIELDS = tuple(QueueEscalationPolicy.DEFAULTS) +# Сутки: всё, что дольше, — это не «позвать на помощь», а другая задача. +MAX_MINUTES = 24 * 60 + + +def _payload(policy: QueueEscalationPolicy) -> dict: + author = policy.updated_by + return { + **{field: getattr(policy, field) for field in FIELDS}, + "defaults": dict(QueueEscalationPolicy.DEFAULTS), + "updatedAt": policy.updated_at.isoformat() if policy.updated_at else None, + "updatedBy": (author.full_name or author.email) if author else "", + } + + +class QueuePolicyView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request) -> Response: + context = request.tenant_context + if not authorize(context.membership, "settings.view", ResourceScope(context.organization_id)): + return Response({"detail": t("settings.queue_policy_forbidden")}, status=403) + return Response(_payload(policy_for(context.organization))) + + def patch(self, request: Request) -> Response: + context = request.tenant_context + if not authorize(context.membership, "settings.manage", ResourceScope(context.organization_id)): + return Response({"detail": t("settings.queue_policy_forbidden")}, status=403) + policy = policy_for(context.organization) + changed = [] + for field in FIELDS: + if field not in request.data: + continue + value = request.data[field] + if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_MINUTES: + return Response({"detail": t("settings.queue_minutes_range")}, status=400) + if getattr(policy, field) != value: + setattr(policy, field, value) + changed.append(field) + if changed: + policy.updated_at = timezone.now() + policy.updated_by = context.actor_user + policy.save(update_fields=[*changed, "updated_at", "updated_by"]) + return Response(_payload(policy)) diff --git a/apps/backend/chatballs/conversations/realtime.py b/apps/backend/chatballs/conversations/realtime.py index f0b6c2d..978dfbc 100644 --- a/apps/backend/chatballs/conversations/realtime.py +++ b/apps/backend/chatballs/conversations/realtime.py @@ -13,12 +13,7 @@ from __future__ import annotations -import logging - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer - -logger = logging.getLogger(__name__) +from chatballs.realtime import publish INBOX_EVENT = "inbox.changed" CONVERSATION_EVENT = "conversation.changed" @@ -32,27 +27,12 @@ def conversation_group(conversation_id: int) -> str: return f"conv.{conversation_id}" -def _publish(group: str, payload: dict[str, object]) -> None: - """Оповещение — вспомогательный путь: его сбой не должен ронять запись. - - Сообщение уже сохранено к моменту отправки; если канал недоступен, клиент - узнает об изменении следующим опросом — он остаётся как запасной путь. - """ - layer = get_channel_layer() - if layer is None: - return - try: - async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload}) - except Exception: # noqa: BLE001 — канал не должен ломать сохранение - logger.warning("realtime fanout failed for %s", group, exc_info=True) - - def notify_inbox_changed(organization_id: int) -> None: - _publish(inbox_group(organization_id), {"type": INBOX_EVENT}) + publish(inbox_group(organization_id), {"type": INBOX_EVENT}) def notify_conversation_changed(conversation_id: int, *, organization_id: int) -> None: - _publish( + publish( conversation_group(conversation_id), {"type": CONVERSATION_EVENT, "conversationId": conversation_id}, ) diff --git a/apps/backend/chatballs/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py index b078ef5..4d9c062 100644 --- a/apps/backend/chatballs/conversations/selectors.py +++ b/apps/backend/chatballs/conversations/selectors.py @@ -10,6 +10,7 @@ from django.db.models import ( Value, When, ) +from django.db.models.functions import Coalesce from chatballs.api.pagination import SortKey from chatballs.conversations.models import ( @@ -63,6 +64,11 @@ def order_conversations( Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми, дольше всех ждущий выше», остальные — по убыванию активности. + + «Дольше всех ждущий» считается от waiting_since — момента, когда диалог + встал в очередь. По времени последнего сообщения его считать нельзя: клиент, + написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он + напоминал о себе, тем позже до него доходила очередь. """ if sort != "waiting": return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS @@ -72,7 +78,9 @@ def order_conversations( When(waits, then=Value(0)), default=Value(1), output_field=IntegerField() ), _wait_at=Case( - When(waits, then=F("last_message_at")), + # Coalesce — страховка для строк, встававших в очередь до появления + # waiting_since: без неё они ушли бы в конец очереди вместо начала. + When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))), default=Value(_NOT_WAITING_AT), output_field=DateTimeField(), ), diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 8d54a20..afc3776 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -257,6 +257,17 @@ def conversation_payload( "isAssignedToViewer": bool( viewer_id and conversation.assigned_operator_id == viewer_id ), + # Ожидание (макет «Очередь и уведомления», кадры Q3 и Q4): с какого + # момента диалог ждёт человека и с какого — конкретного человека. + # Считать «сколько осталось» клиент должен сам: минута на сервере и + # минута на экране расходятся, и показывать замерший счётчик хуже, чем + # не показывать никакого. + "waitingSince": ( + conversation.waiting_since.isoformat() if conversation.waiting_since else None + ), + "assignedAt": ( + conversation.assigned_at.isoformat() if conversation.assigned_at else None + ), "group": ( {"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color} if conversation.group_id diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index 7276888..47b2789 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -13,9 +13,12 @@ from chatballs.conversations.models import ( MessageKind, SystemEvent, ) +from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue from chatballs.i18n import customer_language, t from chatballs.identity.models import EmployeeRole from chatballs.integrations.models import IntegrationProvider +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.services import notify from chatballs.tenancy.context import TenantContext @@ -37,7 +40,7 @@ def _require_open(conversation: Conversation) -> None: -def _operator_label(operator) -> str: +def operator_label(operator) -> str: return getattr(operator, "full_name", "") or operator.email @@ -101,7 +104,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + leave_queue(conversation) + + conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"]) Message.objects.create( @@ -111,9 +116,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat system_event=SystemEvent.OPERATOR_TOOK, - system_params={"operator": _operator_label(operator)}, + system_params={"operator": operator_label(operator)}, - text=f"Оператор {_operator_label(operator)} перехватил диалог", + text=f"Оператор {operator_label(operator)} перехватил диалог", ) @@ -147,7 +152,9 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati conversation.expected_responder = ExpectedResponder.AI - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + leave_queue(conversation) + + conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"]) Message.objects.create( conversation=conversation, @@ -176,13 +183,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa _require_open(conversation) - conversation.control_mode = ControlMode.PAUSED + enter_queue(conversation) conversation.assigned_operator = None - conversation.expected_responder = ExpectedResponder.OPERATOR - conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"]) + conversation.save(update_fields=[*QUEUE_FIELDS, "assigned_operator"]) Message.objects.create( conversation=conversation, @@ -337,6 +343,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve conversation.expected_responder = ExpectedResponder.NOBODY + leave_queue(conversation) + conversation.save( update_fields=[ @@ -348,6 +356,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve "assigned_operator", "expected_responder", + "waiting_since", ] @@ -383,6 +392,8 @@ def mark_conversation_as_spam( conversation.expected_responder = ExpectedResponder.NOBODY + leave_queue(conversation) + conversation.save( update_fields=[ @@ -394,6 +405,7 @@ def mark_conversation_as_spam( "assigned_operator", "expected_responder", + "waiting_since", ] @@ -401,3 +413,53 @@ def mark_conversation_as_spam( return conversation + + +@transaction.atomic +def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -> Conversation: + """Назначить ответственного за диалог (или снять назначение). + + Назначение — не взятие: человек ещё не ответил и мог даже не увидеть + диалог. Но из общей очереди диалог уходит — отвечать в нём, кроме + назначенного и руководства, уже никто не может, — поэтому назначенного надо + позвать лично и поставить срок. Не успел — диалог возвращается в общую + очередь (chatballs.conversations.escalation). + + Раньше эта операция молча писала внешний ключ: назначенный не узнавал, + диалог продолжал числиться в общей очереди, а взять его оттуда было уже + нельзя. + """ + conversation = Conversation.objects.select_for_update().get( + id=conversation_id, organization=context.organization + ) + _require_open(conversation) + conversation.assigned_operator = assignee + conversation.assigned_at = timezone.now() if assignee is not None else None + conversation.save(update_fields=["assigned_operator", "assigned_at"]) + if assignee is None: + return conversation + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.SYSTEM, + system_event=SystemEvent.ASSIGNED_TO, + system_params={"operator": operator_label(assignee)}, + text=f"Диалог назначен на {operator_label(assignee)}", + ) + # Себе назначил — сам и знает. + if context.actor_user is not None and assignee.pk == context.actor_user.pk: + return conversation + contact_name = getattr(conversation.contact, "name", "") or t("conversations.guest") + notify( + context=context, + type=NotificationType.DIALOG_ASSIGNED, + audience=NotificationAudience.USER, + recipient_user=assignee, + title=f"Вам назначен диалог · {contact_name}", + title_key="notifications.assigned_to_you", + text_params={"contact": contact_name}, + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"assign:{conversation.id}:{assignee.pk}", + ) + return conversation diff --git a/apps/backend/chatballs/conversations/test_directory.py b/apps/backend/chatballs/conversations/test_directory.py index b05d59c..bd60ef5 100644 --- a/apps/backend/chatballs/conversations/test_directory.py +++ b/apps/backend/chatballs/conversations/test_directory.py @@ -1,8 +1,15 @@ -"""Справочник выбора ответственного: ограниченная выдача и поиск на сервере.""" +"""Справочник выбора ответственного: ограниченная выдача, поиск и присутствие.""" +from datetime import timedelta +from unittest import mock + +from django.core.cache import cache from django.test import TestCase +from django.utils import timezone +from chatballs.channels.models import Channel from chatballs.conversations.chat_extras_views import DIRECTORY_LIMIT +from chatballs.conversations.models import Contact, Conversation, LifecycleState from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import ( EmployeeRole, @@ -10,6 +17,7 @@ from chatballs.identity.models import ( Organization, OrganizationMembership, ) +from chatballs.presence import touch from chatballs.testing import TenantAPIClient as APIClient @@ -54,3 +62,65 @@ class ConversationDirectoryTests(TestCase): def test_groups_are_returned_as_before(self) -> None: self.assertIn("groups", self._directory()) + + +class DirectoryPresenceTests(TestCase): + """Присутствие и загрузка в выборе ответственного (макет Q5). + + Назначить отсутствующего можно — признак ничего не запрещает; он лишь + отвечает на вопрос «кто сейчас за рабочим местом». + """ + + def setUp(self) -> None: + cache.clear() + self.organization = Organization.objects.create(name="Example", slug="directory-presence") + self.owner = self._employee("owner@dir.test", EmployeeRole.OWNER) + self.away = self._employee("away@dir.test", EmployeeRole.EMPLOYEE) + self.client = APIClient() + self.client.force_authenticate(self.owner.user) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _rows(self) -> dict[int, dict]: + response = self.client.get("/api/v1/conversations/directory/") + self.assertEqual(response.status_code, 200) + return {row["id"]: row for row in response.json()["employees"]} + + def test_presence_is_reported_for_whoever_is_in_the_app(self) -> None: + touch(self.organization.id, self.owner.user_id) + rows = self._rows() + self.assertTrue(rows[self.owner.user_id]["online"]) + self.assertIsNotNone(rows[self.owner.user_id]["lastSeenAt"]) + self.assertFalse(rows[self.away.user_id]["online"]) + self.assertIsNone(rows[self.away.user_id]["lastSeenAt"]) + + def test_long_gone_employee_is_not_online_but_remembered(self) -> None: + with mock.patch( + "chatballs.presence.timezone.now", + return_value=timezone.now() - timedelta(minutes=25), + ): + touch(self.organization.id, self.away.user_id) + row = self._rows()[self.away.user_id] + self.assertFalse(row["online"]) + self.assertIsNotNone(row["lastSeenAt"]) + + def test_load_counts_only_open_dialogs_of_that_person(self) -> None: + channel = Channel.objects.create( + organization=self.organization, code="dir-presence", name="Канал" + ) + contact = Contact.objects.create(organization=self.organization, name="Клиент") + for lifecycle in (LifecycleState.OPEN, LifecycleState.OPEN, LifecycleState.CLOSED): + Conversation.objects.create( + organization=self.organization, + channel=channel, + contact=contact, + lifecycle=lifecycle, + assigned_operator=self.owner.user, + ) + rows = self._rows() + self.assertEqual(rows[self.owner.user_id]["openDialogs"], 2) + self.assertEqual(rows[self.away.user_id]["openDialogs"], 0) diff --git a/apps/backend/chatballs/conversations/test_queue_escalation.py b/apps/backend/chatballs/conversations/test_queue_escalation.py new file mode 100644 index 0000000..b0eca2f --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_escalation.py @@ -0,0 +1,246 @@ +"""Эскалация ждущих диалогов и личная очередь назначенного. + +Регрессия, ради которой всё это заведено: про ждущий диалог операторов окликали +ровно один раз, и дальше суточный dedup гарантировал тишину. Назначение при этом +молча писало внешний ключ — назначенный не узнавал, а из общей очереди диалог +уже ушёл. +""" + +from datetime import timedelta + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone + +from chatballs.channels.models import Channel +from chatballs.conversations.escalation import sweep_waiting_conversations +from chatballs.conversations.models import ( + Contact, + ControlMode, + Conversation, + ExpectedResponder, + LifecycleState, + SystemEvent, +) +from chatballs.conversations.queue_models import policy_for +from chatballs.conversations.services import assign_operator +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.models import Notification, NotificationAudience +from chatballs.presence import touch +from chatballs.testing import tenant_context_for + + +class QueueTestBase(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="queue-escalation") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.owner = self._employee("owner@queue.test", EmployeeRole.OWNER) + self.operator = self._employee("operator@queue.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.operator + ) + self.context = tenant_context_for(self.owner.user, self.organization) + self.policy = policy_for(self.organization) + self.channel = Channel.objects.create( + organization=self.organization, group=self.support, code="esc", name="Очередь" + ) + self.conversation = self._waiting_conversation() + # Обычный случай: смена на месте. Пустую смену проверяет + # PresenceEscalationTests, и она её задаёт явно. + cache.clear() + touch(self.organization.id, self.operator.user_id) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _waiting_conversation(self) -> Conversation: + contact = Contact.objects.create(organization=self.organization, name="Клиент") + return Conversation.objects.create( + organization=self.organization, + channel=self.channel, + group=self.support, + contact=contact, + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + expected_responder=ExpectedResponder.OPERATOR, + waiting_since=timezone.now(), + ) + + def _wait_for(self, minutes: int) -> None: + Conversation.objects.filter(pk=self.conversation.pk).update( + waiting_since=timezone.now() - timedelta(minutes=minutes) + ) + + def _dedup_keys(self) -> set[str]: + return set( + Notification.objects.filter(organization=self.organization).values_list( + "dedup_key", flat=True + ) + ) + + + +class QueueEscalationTests(QueueTestBase): + def test_fresh_dialog_is_not_escalated(self) -> None: + self.assertEqual(sweep_waiting_conversations(self.context), 0) + self.assertEqual(self._dedup_keys(), set()) + + def test_reminder_goes_to_the_group_of_the_dialog(self) -> None: + self._wait_for(self.policy.remind_after_minutes + 1) + self.assertEqual(sweep_waiting_conversations(self.context), 1) + reminder = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:remind") + self.assertEqual(reminder.audience, NotificationAudience.OPERATORS) + self.assertEqual(reminder.audience_group_id, self.support.id) + + def test_circle_widens_beyond_the_group(self) -> None: + self._wait_for(self.policy.widen_after_minutes + 1) + sweep_waiting_conversations(self.context) + widened = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:widen") + self.assertIsNone(widened.audience_group_id) + + def test_management_is_called_last(self) -> None: + self._wait_for(self.policy.escalate_after_minutes + 1) + sweep_waiting_conversations(self.context) + management = Notification.objects.filter( + dedup_key__startswith=f"waiting:{self.conversation.id}:management" + ) + self.assertEqual( + {item.recipient_user_id for item in management}, {self.owner.user_id} + ) + + def test_second_sweep_does_not_repeat_itself(self) -> None: + self._wait_for(self.policy.escalate_after_minutes + 1) + first = sweep_waiting_conversations(self.context) + second = sweep_waiting_conversations(self.context) + self.assertGreater(first, 0) + self.assertEqual(second, 0) + + def test_assigned_dialog_is_not_escalated_to_everyone(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + self._wait_for(self.policy.escalate_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertEqual( + {key for key in self._dedup_keys() if key.startswith("waiting:")}, set() + ) + + +class AssignmentTests(QueueTestBase): + def test_assignment_notifies_the_assignee_and_writes_history(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + notification = Notification.objects.get( + dedup_key=f"assign:{self.conversation.id}:{self.operator.user_id}" + ) + self.assertEqual(notification.audience, NotificationAudience.USER) + self.assertEqual(notification.recipient_user_id, self.operator.user_id) + self.assertEqual( + list(self.conversation.messages.values_list("system_event", flat=True)), + [SystemEvent.ASSIGNED_TO], + ) + + def test_assigning_to_yourself_does_not_ping_you(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.owner.user, + ) + self.assertFalse( + Notification.objects.filter( + dedup_key=f"assign:{self.conversation.id}:{self.owner.user_id}" + ).exists() + ) + + def test_unclaimed_assignment_returns_the_dialog_to_the_queue(self) -> None: + assign_operator( + context=self.context, + conversation_id=self.conversation.id, + assignee=self.operator.user, + ) + Conversation.objects.filter(pk=self.conversation.pk).update( + assigned_at=timezone.now() + - timedelta(minutes=self.policy.assignment_timeout_minutes + 1) + ) + sweep_waiting_conversations(self.context) + + self.conversation.refresh_from_db() + self.assertIsNone(self.conversation.assigned_operator_id) + self.assertIsNone(self.conversation.assigned_at) + # Ожидание не обнулилось: клиент ждёт с того же момента, что и ждал. + self.assertIsNotNone(self.conversation.waiting_since) + self.assertIn( + SystemEvent.ASSIGNMENT_EXPIRED, + set(self.conversation.messages.values_list("system_event", flat=True)), + ) + + +class PresenceEscalationTests(QueueTestBase): + """Присутствие сокращает ожидание, но ничего не запрещает.""" + + def setUp(self) -> None: + super().setUp() + # Начинаем с пустой смены: за рабочим местом нет никого. + cache.clear() + + def test_circle_widens_at_the_first_threshold_when_nobody_is_online(self) -> None: + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_present_operator_keeps_the_second_threshold(self) -> None: + touch(self.organization.id, self.operator.user_id) + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + # Напомнили группе, но круг не расширили: в группе есть кому ответить. + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:remind" + ).exists() + ) + self.assertFalse( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_presence_never_delays_the_wider_circle(self) -> None: + touch(self.organization.id, self.operator.user_id) + self._wait_for(self.policy.widen_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) + + def test_operator_of_another_group_does_not_count_as_present(self) -> None: + outsider = self._employee("outsider@queue.test", EmployeeRole.EMPLOYEE) + touch(self.organization.id, outsider.user_id) + self._wait_for(self.policy.remind_after_minutes + 1) + sweep_waiting_conversations(self.context) + self.assertTrue( + Notification.objects.filter( + dedup_key=f"waiting:{self.conversation.id}:widen" + ).exists() + ) diff --git a/apps/backend/chatballs/conversations/test_queue_order.py b/apps/backend/chatballs/conversations/test_queue_order.py new file mode 100644 index 0000000..97e57d3 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_order.py @@ -0,0 +1,172 @@ +"""Очередь к оператору: с какого момента идёт ожидание и в каком порядке разбирают. + +Регрессия, ради которой заведён waiting_since: «дольше всех ждущий» считался по +времени последнего сообщения, поэтому клиент, напомнивший о себе, уезжал в конец +очереди. Чем настойчивее человек, тем позже до него доходили руки. +""" + +from datetime import timedelta +from unittest import mock + +from django.test import TestCase +from django.utils import timezone + +from chatballs.ai.models import AIAgent +from chatballs.channels.models import Channel +from chatballs.conversations.ingest import ingest_inbound +from chatballs.conversations.models import ControlMode, Conversation +from chatballs.conversations.selectors import order_conversations +from chatballs.conversations.services import ( + claim_conversation, + close_conversation, + return_to_queue, +) +from chatballs.conversations.transports.base import InboundMessage +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import HumanUser, Organization +from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider +from chatballs.notifications.models import Notification, NotificationType +from chatballs.testing import tenant_context_for + + +class QueueTestBase(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.owner = HumanUser.objects.get(email="owner@example.com") + self.context = tenant_context_for(self.owner, self.organization) + # Канал без активного агента: каждый входящий сразу создаёт очередь. + self.channel = Channel.objects.create( + organization=self.organization, code="queue-order", name="Очередь" + ) + self.integration = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="queue-bot", + channel=self.channel, + ) + + def _ingest(self, external_id: str, chat_id: str, text: str) -> None: + inbound = InboundMessage( + external_id=external_id, + user_id=chat_id, + chat_id=chat_id, + text=text, + display_name=chat_id, + ) + with ( + mock.patch("chatballs.conversations.ingest.run_channel_turn"), + mock.patch("chatballs.conversations.ingest.transports.send_reply"), + ): + ingest_inbound(self.integration, inbound) + + def _waiting_order(self) -> list[int]: + ordered, _ = order_conversations( + Conversation.objects.filter(organization=self.organization), "waiting" + ) + return list(ordered.values_list("id", flat=True)) + + + +class QueueOrderTests(QueueTestBase): + def test_new_dialog_without_agent_starts_waiting(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertIsNotNone(conversation.waiting_since) + + def test_reminder_from_customer_does_not_restart_the_wait(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + started = conversation.waiting_since + + self._ingest("m-2", "chat-1", "Ну что там?") + + conversation.refresh_from_db() + self.assertEqual(conversation.waiting_since, started) + # Свежесть диалога при этом обновилась — ожидание и активность разные вещи. + self.assertGreater(conversation.last_message_at, started) + + def test_longest_waiting_goes_first_even_after_a_reminder(self) -> None: + self._ingest("a-1", "chat-a", "Первый вопрос") + first = Conversation.objects.get(external_chat_id="chat-a") + # Первый диалог ждёт заметно дольше второго. + Conversation.objects.filter(pk=first.pk).update( + waiting_since=timezone.now() - timedelta(hours=2) + ) + self._ingest("b-1", "chat-b", "Второй вопрос") + second = Conversation.objects.get(external_chat_id="chat-b") + + # …и именно он напоминает о себе, двигая своё последнее сообщение вперёд. + self._ingest("a-2", "chat-a", "Всё ещё жду") + + self.assertEqual(self._waiting_order(), [first.id, second.id]) + + def test_claim_ends_the_wait_and_return_to_queue_starts_a_new_one(self) -> None: + self._ingest("m-1", "chat-1", "Здравствуйте") + conversation = self.channel.conversations.get() + first_wait = conversation.waiting_since + + claimed = claim_conversation(context=self.context, conversation_id=conversation.id) + self.assertEqual(claimed.control_mode, ControlMode.HUMAN) + self.assertIsNone(claimed.waiting_since) + + returned = return_to_queue(context=self.context, conversation_id=conversation.id) + self.assertEqual(returned.control_mode, ControlMode.PAUSED) + self.assertIsNotNone(returned.waiting_since) + # Ожидание началось заново: диалог успел побывать у оператора. + self.assertGreater(returned.waiting_since, first_wait) + + def test_closed_dialog_leaves_the_queue(self) -> None: + self._ingest("z-1", "chat-z", "Вопрос") + conversation = self.channel.conversations.get() + self.assertIsNotNone(conversation.waiting_since) + + closed = close_conversation(context=self.context, conversation_id=conversation.id) + self.assertIsNone(closed.waiting_since) + + +class NewDialogNotificationTests(QueueTestBase): + """«Новый диалог» и «клиент запросил оператора» — разные события. + + Диалог начинается и на канале с работающим агентом, где человека никто не + звал. Назвать такой оклик просьбой о человеке — соврать тому, кто на него + подписан. + """ + + def _last_type(self) -> str: + return ( + Notification.objects.filter(organization=self.organization) + .order_by("-id") + .values_list("type", flat=True) + .first() + ) + + def test_dialog_without_an_agent_asks_for_a_person(self) -> None: + self._ingest("n-1", "chat-n", "Здравствуйте") + self.assertEqual(self._last_type(), NotificationType.OPERATOR_REQUESTED) + + def test_dialog_handled_by_the_agent_is_just_a_new_dialog(self) -> None: + agent = AIAgent.objects.create( + organization=self.organization, + channel=self.channel, + name="Консультант", + is_active=True, + ) + self.assertTrue(agent.is_active) + with mock.patch( + "chatballs.conversations.ingest.run_channel_turn", + return_value=mock.Mock(text="Здравствуйте!"), + ), mock.patch("chatballs.conversations.ingest.transports.send_reply"): + ingest_inbound( + self.integration, + InboundMessage( + external_id="a-1", + user_id="chat-a", + chat_id="chat-a", + text="Здравствуйте", + display_name="chat-a", + ), + ) + self.assertEqual(self._last_type(), NotificationType.NEW_DIALOG) diff --git a/apps/backend/chatballs/conversations/test_queue_policy.py b/apps/backend/chatballs/conversations/test_queue_policy.py new file mode 100644 index 0000000..63d5c99 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_queue_policy.py @@ -0,0 +1,64 @@ +"""Сроки очереди через API (макет Q2). + +До этого пороги правились только в служебной админке — то есть де-факто никем. +""" + +from django.test import TestCase + +from chatballs.conversations.queue_models import QueueEscalationPolicy +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.testing import TenantAPIClient as APIClient + +URL = "/api/v1/conversations/queue-policy/" + + +class QueuePolicyApiTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="queue-policy") + self.owner = self._employee("owner@policy.test", EmployeeRole.OWNER) + self.operator = self._employee("operator@policy.test", EmployeeRole.EMPLOYEE) + self.client = APIClient() + self.client.force_authenticate(self.owner.user) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def test_first_read_returns_usual_delays(self) -> None: + response = self.client.get(URL) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["remind_after_minutes"], 5) + self.assertEqual(payload["assignment_timeout_minutes"], 10) + self.assertEqual(payload["defaults"], payload["defaults"] | {"widen_after_minutes": 15}) + self.assertIsNone(payload["updatedAt"]) + + def test_saving_remembers_who_and_when(self) -> None: + response = self.client.patch(URL, {"remind_after_minutes": 3}, format="json") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["remind_after_minutes"], 3) + self.assertEqual(payload["updatedBy"], self.owner.user.full_name or self.owner.user.email) + self.assertIsNotNone(payload["updatedAt"]) + self.assertEqual(QueueEscalationPolicy.objects.get().remind_after_minutes, 3) + + def test_nonsense_values_are_refused(self) -> None: + for value in (0, -5, 10000, "быстро", True): + with self.subTest(value=value): + response = self.client.patch(URL, {"widen_after_minutes": value}, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual(QueueEscalationPolicy.objects.get().widen_after_minutes, 15) + + def test_operator_reads_but_does_not_change(self) -> None: + self.client.force_authenticate(self.operator.user) + self.assertEqual(self.client.get(URL).status_code, 403) + self.assertEqual( + self.client.patch(URL, {"remind_after_minutes": 1}, format="json").status_code, 403 + ) diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index a54fecc..5e97821 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -421,7 +421,7 @@ class WebchatContactTests(TestCase): ) self.assertTrue( Notification.objects.filter( - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, target_id=str(conversation.id), ).exists() @@ -429,7 +429,7 @@ class WebchatContactTests(TestCase): self.assertEqual( set( Notification.objects.filter( - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.USER, target_id=str(conversation.id), ).values_list("recipient_user__email", flat=True) diff --git a/apps/backend/chatballs/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py index 66fcffa..7b09392 100644 --- a/apps/backend/chatballs/conversations/urls.py +++ b/apps/backend/chatballs/conversations/urls.py @@ -3,6 +3,7 @@ from django.urls import path from chatballs.conversations import ( attachment_views, chat_extras_views, + queue_policy_views, reporting_views, views, voice_views, @@ -30,6 +31,7 @@ urlpatterns = [ path("/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"), path("/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"), path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"), + path("queue-policy/", queue_policy_views.QueuePolicyView.as_view(), name="conversation-queue-policy"), path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"), path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"), path("labels//", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"), diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index df50e72..76ce43f 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -30,6 +30,7 @@ from chatballs.conversations.serializers import ( ) from chatballs.conversations.services import ( ClaimError, + assign_operator, claim_conversation, close_conversation, mark_conversation_as_spam, @@ -87,6 +88,21 @@ class ConversationListView(ConversationViewBase): items = items.filter( lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED ) + # Две вкладки ожидания (макет «Очередь и уведомления», кадр Q3): общая + # очередь — диалоги без ответственного, их берёт кто угодно; «на мне» — + # назначенные лично и ждущие, пока их возьмут. + if params.get("queue") == "1": + items = items.filter( + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + assigned_operator__isnull=True, + ) + if params.get("waitingOnMe") == "1": + items = items.filter( + lifecycle=LifecycleState.OPEN, + control_mode=ControlMode.PAUSED, + assigned_operator_id=request.user.id, + ) query = params.get("q", "").strip() if query: from django.contrib.postgres.search import SearchQuery, SearchVector @@ -469,8 +485,11 @@ class ConversationAssigneeView(ConversationViewBase): if membership is None: return Response({"detail": t("admin.employee_not_found")}, status=400) assignee = membership.user - conversation.assigned_operator = assignee - conversation.save(update_fields=["assigned_operator"]) + conversation = assign_operator( + context=request.tenant_context, + conversation_id=conversation.id, + assignee=assignee, + ) self._audit(request, "assignee_changed", conversation) return Response( { diff --git a/apps/backend/chatballs/events/management/commands/run_worker.py b/apps/backend/chatballs/events/management/commands/run_worker.py index 2808c87..07844e6 100644 --- a/apps/backend/chatballs/events/management/commands/run_worker.py +++ b/apps/backend/chatballs/events/management/commands/run_worker.py @@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand from django.utils import timezone from chatballs.calls.maintenance import expire_stale_calls +from chatballs.conversations.escalation import sweep_waiting_conversations from chatballs.conversations.maintenance import close_stale_conversations from chatballs.conversations.poller import poll_all_messengers from chatballs.events.handlers import dispatch @@ -22,6 +23,9 @@ logger = logging.getLogger(__name__) MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs) CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect) +# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом: +# проверка дешёвая, а повтор гасится dedup-ключом уровня. +QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps class Command(BaseCommand): @@ -39,6 +43,7 @@ class Command(BaseCommand): last_poll = 0.0 last_maintenance = 0.0 last_call_sweep = 0.0 + last_queue_sweep = 0.0 while True: try: event = claim_next_outbox_event() @@ -91,6 +96,14 @@ class Command(BaseCommand): expire_stale_calls(context) except Exception: # pragma: no cover logger.exception("Call sweep cycle failed") + if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL: + last_queue_sweep = now + try: + for context in self._tenant_contexts(): + with tenant_atomic(context): + sweep_waiting_conversations(context) + except Exception: # pragma: no cover + logger.exception("Waiting queue sweep cycle failed") if now - last_maintenance >= MAINTENANCE_INTERVAL: last_maintenance = now # Канал релизов спрашивается не чаще раза в несколько часов: diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index a64ffa6..390b312 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -54,6 +54,8 @@ MESSAGES: dict[str, object] = { "conversations.not_found": "Conversation not found", "conversations.note_too_long": "The note is longer than 4000 characters", "conversations.restore_admin_only": "Only an administrator can restore a conversation", + "conversations.system.assigned_to": "Conversation assigned to {operator}", + "conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue", "conversations.system.ai_handed_over": "AI handed the conversation to an operator", "conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator", "conversations.system.call_accepted": "The customer accepted the call invitation", @@ -96,6 +98,12 @@ MESSAGES: dict[str, object] = { "notifications.new_dialog_body": "{contact} · {provider}: {preview}", "notifications.new_message": "New message · {contact}", "notifications.operator_needed": "An operator is needed · {contact}", + "notifications.still_waiting": "Conversation is still waiting · {contact}", + "notifications.still_waiting_body": "Nobody has picked this conversation up. Open it if you can reply.", + "notifications.waiting_unattended": "Nobody is picking this conversation up · {contact}", + "notifications.waiting_unattended_body": "The customer has been waiting longer than usual and the queue is not being worked.", + "notifications.assigned_to_you": "A conversation was assigned to you · {contact}", + "notifications.assignment_expired": "Conversation is unassigned again · {contact}", "notifications.voice_without_transcript": "A voice message without a transcript", "onboarding.membership_required": "Onboarding is available to organization members", "onboarding.unknown_action": "Unknown onboarding action", @@ -103,6 +111,10 @@ MESSAGES: dict[str, object] = { "portals.file_field_required": "A file is required in the file field (multipart/form-data)", "portals.file_not_found": "File not found", "portals.helpful_must_be_boolean": "helpful must be a boolean", + "portals.installation_domain_missing": ( + "The installation address has no domain: set one in Settings, " + "otherwise there is nowhere to host the portal" + ), "portals.invalid_article": "Invalid article", "portals.invalid_article_data": "Invalid article data", "portals.invalid_category": "Invalid category", @@ -111,10 +123,6 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Invalid revision", "portals.invalid_section": "Invalid section", "portals.not_found": "Portal not found", - "portals.installation_domain_missing": ( - "The installation address has no domain: set one in Settings, " - "otherwise there is nowhere to host the portal" - ), "portals.section_not_found": "Section not found", "profile.accent_hex": "The accent is a HEX colour like #0f9b8e", "profile.choose_photo_file": "Choose a photo file", @@ -349,6 +357,10 @@ MESSAGES: dict[str, object] = { "identity.totp_challenge_inactive": "TOTP challenge is not active", "notifications.ids_list_or_all": "ids must be a list or use all=true", "notifications.push_types_list": "pushTypes must be a list", + "notifications.enabled_bool": "enabled must be true or false", + "settings.queue_minutes_range": "The delay is set in minutes, from 1 to 1440", + "settings.queue_policy_forbidden": "Only someone who manages settings can change these delays", + "notifications.unknown_transport": "Unknown delivery channel", "platform.idempotency_key_required": "Idempotency-Key header is required", "portals.articles_non_empty": "articles must be a non-empty list", "profile.current_password_invalid": "Current password is invalid", @@ -481,10 +493,12 @@ MESSAGES: dict[str, object] = { "ai.unexpected_provider_response": "Unexpected provider response: {error}", "settings.base_url_rejected": "Base URL: {error}", "settings.proxy_url_rejected": "Proxy: {error}", - "notifications.type_dialog_waiting": "A dialog is waiting for an operator", - "notifications.type_dialog_new_message": "A new message in a dialog", - "notifications.type_release_published": "An agent release was published", - "notifications.type_integration_error": "Integration error", + "notifications.type_new_dialog": "A new conversation", + "notifications.type_operator_requested": "A customer asked for a person", + "notifications.type_dialog_new_message": "A new message in my conversation", + "notifications.type_dialog_assigned": "A conversation was assigned to me", + "notifications.type_dialog_waiting_long": "A conversation has been waiting a long time", + "notifications.type_ai_stopped": "AI stopped by an error or a limit", "integrations.check_no_connection": "No connection: {error}", "integrations.check_api_key_missing": "The API key is not set", "integrations.check_base_url_missing": "Base URL is not set", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index bb0aecc..4bab94b 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -59,6 +59,8 @@ MESSAGES: dict[str, object] = { "conversations.note_too_long": "Заметка длиннее 4000 символов", "conversations.restore_admin_only": "Восстановить диалог может только администратор", "conversations.system.ai_handed_over": "AI передал диалог оператору", + "conversations.system.assigned_to": "Диалог назначен на {operator}", + "conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь", "conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору", "conversations.system.call_accepted": "Клиент принял приглашение на звонок", "conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок", @@ -101,12 +103,22 @@ MESSAGES: dict[str, object] = { "notifications.new_message": "Новое сообщение · {contact}", "notifications.operator_needed": "Нужен оператор · {contact}", "notifications.voice_without_transcript": "Голосовое без расшифровки", + "notifications.still_waiting": "Диалог всё ещё ждёт · {contact}", + "notifications.still_waiting_body": "Диалог никто не взял. Откройте его, если можете ответить.", + "notifications.waiting_unattended": "Диалог никто не берёт · {contact}", + "notifications.waiting_unattended_body": "Клиент ждёт ответа дольше обычного, и очередь не разбирают.", + "notifications.assigned_to_you": "Вам назначен диалог · {contact}", + "notifications.assignment_expired": "Диалог снова ничей · {contact}", "onboarding.membership_required": "Онбординг доступен участнику организации", "onboarding.unknown_action": "Неизвестное действие онбординга", "portals.article_not_found": "Статья не найдена", "portals.file_field_required": "Нужен файл в поле file (multipart/form-data)", "portals.file_not_found": "Файл не найден", "portals.helpful_must_be_boolean": "helpful должен быть boolean", + "portals.installation_domain_missing": ( + "Адрес установки задан без домена: укажите домен в «Настройках», " + "иначе портал негде разместить" + ), "portals.invalid_article": "Некорректная статья", "portals.invalid_article_data": "Некорректные данные статьи", "portals.invalid_category": "Некорректная категория", @@ -115,10 +127,6 @@ MESSAGES: dict[str, object] = { "portals.invalid_revision": "Некорректная версия", "portals.invalid_section": "Некорректный раздел", "portals.not_found": "Портал не найден", - "portals.installation_domain_missing": ( - "Адрес установки задан без домена: укажите домен в «Настройках», " - "иначе портал негде разместить" - ), "portals.section_not_found": "Раздел не найден", "profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e", "profile.choose_photo_file": "Выберите файл фото", @@ -353,6 +361,10 @@ MESSAGES: dict[str, object] = { "identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново", "notifications.ids_list_or_all": "ids — список, либо all=true", "notifications.push_types_list": "pushTypes — список", + "notifications.enabled_bool": "enabled — да или нет", + "settings.queue_minutes_range": "Срок указывается в минутах: от 1 до 1440", + "settings.queue_policy_forbidden": "Менять сроки может тот, кто управляет настройками", + "notifications.unknown_transport": "Неизвестный способ доставки", "platform.idempotency_key_required": "Нужен заголовок Idempotency-Key", "portals.articles_non_empty": "articles — непустой список", "profile.current_password_invalid": "Текущий пароль неверен", @@ -485,10 +497,12 @@ MESSAGES: dict[str, object] = { "ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}", "settings.base_url_rejected": "Base URL: {error}", "settings.proxy_url_rejected": "Прокси: {error}", - "notifications.type_dialog_waiting": "Диалог ждёт оператора", - "notifications.type_dialog_new_message": "Новое сообщение в диалоге", - "notifications.type_release_published": "Опубликован релиз агента", - "notifications.type_integration_error": "Ошибка интеграции", + "notifications.type_new_dialog": "Новый диалог", + "notifications.type_operator_requested": "Клиент запросил оператора", + "notifications.type_dialog_new_message": "Новое сообщение в моём диалоге", + "notifications.type_dialog_assigned": "Диалог назначили на меня", + "notifications.type_dialog_waiting_long": "Диалог долго ждёт человека", + "notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом", "integrations.check_no_connection": "Нет связи: {error}", "integrations.check_api_key_missing": "Не указан API-ключ", "integrations.check_base_url_missing": "Не указан Base URL", diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/operations.json b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json index a26fc4f..d030005 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/en/operations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json @@ -76,7 +76,7 @@ ], "notifications": [ { - "type": "DIALOG_WAITING", + "type": "OPERATOR_REQUESTED", "level": "WARNING", "title": "Dmitry Orlov is waiting for an operator", "body": "Web Chat: the client asked for a member of staff. Waiting 6 min.", @@ -107,7 +107,7 @@ "minutesAgo": 4 }, { - "type": "INTEGRATION_ERROR", + "type": "AI_STOPPED", "level": "CRITICAL", "title": "Telegram · delivery bot: connection error", "body": "401 Unauthorized: bot token was revoked in @BotFather. Check the token in the connection settings.", @@ -116,7 +116,7 @@ "minutesAgo": 4200 }, { - "type": "RELEASE_PUBLISHED", + "type": "DIALOG_ASSIGNED", "level": "SUCCESS", "title": "Chatballs update: voice messages", "body": "Operators can send voice messages to Telegram and MAX, and incoming ones are transcribed with a single button.", @@ -136,9 +136,9 @@ "connection": "tg", "externalChatId": "482900010", "pushTypes": [ - "DIALOG_WAITING", + "OPERATOR_REQUESTED", "DIALOG_NEW_MESSAGE", - "INTEGRATION_ERROR" + "AI_STOPPED" ] }, { @@ -146,7 +146,7 @@ "connection": "tg", "externalChatId": "482900011", "pushTypes": [ - "DIALOG_WAITING" + "OPERATOR_REQUESTED" ] } ], diff --git a/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json b/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json index 2ce580f..01351c0 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json +++ b/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json @@ -76,7 +76,7 @@ ], "notifications": [ { - "type": "DIALOG_WAITING", + "type": "OPERATOR_REQUESTED", "level": "WARNING", "title": "Дмитрий Орлов ждёт оператора", "body": "Web Chat: клиент попросил сотрудника. Ждёт 6 мин.", @@ -107,7 +107,7 @@ "minutesAgo": 4 }, { - "type": "INTEGRATION_ERROR", + "type": "AI_STOPPED", "level": "CRITICAL", "title": "Telegram · бот доставки: ошибка подключения", "body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.", @@ -116,7 +116,7 @@ "minutesAgo": 4200 }, { - "type": "RELEASE_PUBLISHED", + "type": "DIALOG_ASSIGNED", "level": "SUCCESS", "title": "Обновление Chatballs: голосовые сообщения", "body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.", @@ -136,9 +136,9 @@ "connection": "tg", "externalChatId": "482900010", "pushTypes": [ - "DIALOG_WAITING", + "OPERATOR_REQUESTED", "DIALOG_NEW_MESSAGE", - "INTEGRATION_ERROR" + "AI_STOPPED" ] }, { @@ -146,7 +146,7 @@ "connection": "tg", "externalChatId": "482900011", "pushTypes": [ - "DIALOG_WAITING" + "OPERATOR_REQUESTED" ] } ], diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py index 5d70d00..7847cfb 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py @@ -18,6 +18,7 @@ from chatballs.calls.models import ( ParticipantConnectionState, ParticipantSide, ) +from chatballs.conversations.queue_models import policy_for from chatballs.identity.demo_seed import manifest from chatballs.identity.demo_seed.loaders.common import backdate, moment, now from chatballs.identity.demo_seed.refs import DemoRefs @@ -28,7 +29,9 @@ from chatballs.notifications.models import ( NotificationAudience, NotificationLevel, NotificationRead, + NotificationTransport, ) +from chatballs.notifications.preferences import update_preference from chatballs.tenancy.context import TenantContext FINISHED = { @@ -44,6 +47,9 @@ FINISHED = { def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("operations", refs.language) current = now() + # Пороги очереди — данные организации, а не установки: демо-организация + # заводит их со значениями по умолчанию, как завела бы живая. + policy_for(refs.organization) for item in data.get("calls", []): _ensure_call(refs, item, current) for item in data.get("notifications", []): @@ -55,9 +61,15 @@ def load(context: TenantContext, refs: DemoRefs) -> None: defaults={ "organization": refs.organization, "external_chat_id": item["externalChatId"], - "push_types": item.get("pushTypes", []), }, ) + update_preference( + organization_id=refs.organization.id, + user_id=refs.users[item["user"]].id, + transport=NotificationTransport.MESSENGER, + enabled=True, + types=item.get("pushTypes", []), + ) for item in data.get("messengerBindingCodes", []): MessengerBindingCode.objects.get_or_create( code=item["code"], diff --git a/apps/backend/chatballs/identity/policy.py b/apps/backend/chatballs/identity/policy.py index 12087dc..6649a4c 100644 --- a/apps/backend/chatballs/identity/policy.py +++ b/apps/backend/chatballs/identity/policy.py @@ -15,6 +15,12 @@ from chatballs.identity.models import EmployeeRole, OrganizationMembership # сохраняется; scope-модель и отделы упразднены. +# Руководство организации: видит всё независимо от групп. Набор один и тот же +# в проверке доступа, в видимости диалогов и в подборе получателей уведомлений, +# поэтому записан здесь один раз. +MANAGEMENT_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN}) + + @dataclass(frozen=True, slots=True) class ResourceScope: organization_id: int @@ -47,6 +53,18 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool: return capability in _role_capabilities(profile.role) +def roles_with_capability(capability: str) -> frozenset[str]: + """Роли, которым право выдано. + + Нужна там, где получателей ищут пачкой: спрашивать authorize() по одному + сотруднику — это запрос в базу на каждого, то есть сотня запросов на сотню + рабочих мест. Право выводится из роли, значит и отбирать можно по роли. + """ + return frozenset( + role for role in EmployeeRole.values if capability in _role_capabilities(role) + ) + + def has_capability_any_scope(actor, capability: str) -> bool: profile = _active_membership(actor) if profile is None: @@ -56,7 +74,7 @@ def has_capability_any_scope(actor, capability: str) -> bool: def can_administer_access(actor) -> bool: profile = _active_membership(actor) - return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN} + return profile is not None and profile.role in MANAGEMENT_ROLES def conversation_visibility(actor) -> dict | None: @@ -69,7 +87,7 @@ def conversation_visibility(actor) -> dict | None: profile = _active_membership(actor) if profile is None: return {"none": True} - if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}: + if profile.role in MANAGEMENT_ROLES: return None from chatballs.identity.group_models import member_group_ids diff --git a/apps/backend/chatballs/identity/test_membership_migration.py b/apps/backend/chatballs/identity/test_membership_migration.py index ce19118..7affa83 100644 --- a/apps/backend/chatballs/identity/test_membership_migration.py +++ b/apps/backend/chatballs/identity/test_membership_migration.py @@ -4,6 +4,14 @@ from django.test import TransactionTestCase class MembershipMigrationTests(TransactionTestCase): + """Перенос сотрудников на членства (identity/0012) не теряет идентификаторы. + + Тест уводит базу к состоянию до переноса, поэтому обязан вернуть её обратно + за собой. Иначе всё, что запускается после него в той же сессии, работает + на схеме годичной давности: откат по identity утягивает и зависимые + миграции других приложений, и чем их больше, тем разрушительнее последствия. + """ + migrate_from = [("identity", "0011_enforce_capability_registry")] migrate_to = [("identity", "0012_membership_identity")] diff --git a/apps/backend/chatballs/notifications/binding.py b/apps/backend/chatballs/notifications/binding.py index 205204f..33029c1 100644 --- a/apps/backend/chatballs/notifications/binding.py +++ b/apps/backend/chatballs/notifications/binding.py @@ -19,7 +19,12 @@ from chatballs.conversations import transports from chatballs.conversations.transports.base import InboundMessage from chatballs.i18n import customer_language, first_chosen, normalize_language, t from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider -from chatballs.notifications.models import MessengerBinding, MessengerBindingCode +from chatballs.notifications.models import ( + MessengerBinding, + MessengerBindingCode, + NotificationTransport, +) +from chatballs.notifications.preferences import update_preference logger = logging.getLogger(__name__) @@ -110,6 +115,13 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) - integration=integration, defaults={"external_chat_id": inbound.chat_id or inbound.user_id}, ) + # Привязка — и есть согласие получать: до неё звать было просто некуда. + update_preference( + organization_id=integration.organization_id, + user_id=binding_code.user_id, + transport=NotificationTransport.MESSENGER, + enabled=True, + ) binding_code.delete() # Подтверждение читает конкретный сотрудник — язык берём из его профиля. done = t( diff --git a/apps/backend/chatballs/notifications/delivery.py b/apps/backend/chatballs/notifications/delivery.py index 8600511..1fff977 100644 --- a/apps/backend/chatballs/notifications/delivery.py +++ b/apps/backend/chatballs/notifications/delivery.py @@ -1,8 +1,10 @@ """Доставка уведомлений в мессенджеры через сервисных ботов. Событие notifications.notification_created кладётся в outbox из notify() и -обрабатывается worker'ом: аудитория разворачивается в получателей (зеркало -selectors.visible_for), их привязки получают сообщение через транспорт бота. +обрабатывается worker'ом: аудитория разворачивается в получателей +(chatballs.notifications.recipients), их привязки получают сообщение через +транспорт бота. + Отправка best-effort: сбой одной привязки логируется и не валит событие. """ @@ -12,15 +14,13 @@ import logging from chatballs.conversations import transports from chatballs.identity.instance_settings import public_base_url -from chatballs.identity.models import OrganizationMembership from chatballs.notifications.models import ( MessengerBinding, Notification, - NotificationAudience, NotificationLevel, ) -from chatballs.notifications.selectors import visible_for -from chatballs.tenancy.context import TenantContext +from chatballs.notifications.preferences import messenger_types +from chatballs.notifications.recipients import recipient_user_ids logger = logging.getLogger(__name__) @@ -34,21 +34,6 @@ _LEVEL_MARK = { } -def _recipient_user_ids(notification: Notification) -> list[int]: - if notification.audience == NotificationAudience.USER: - return [notification.recipient_user_id] if notification.recipient_user_id else [] - profiles = OrganizationMembership.objects.filter( - organization_id=notification.organization_id, - blocked_at__isnull=True, - user__is_active=True, - ).select_related("user") - return [ - profile.user_id - for profile in profiles - if visible_for(TenantContext.for_membership(profile)).filter(id=notification.id).exists() - ] - - def _message_text(notification: Notification) -> str: mark = _LEVEL_MARK.get(notification.level, "🔔") lines = [f"{mark} {notification.title}"] @@ -60,8 +45,14 @@ def _message_text(notification: Notification) -> str: return "\n".join(lines) -def deliver_notification(notification: Notification) -> int: - user_ids = _recipient_user_ids(notification) +def deliver_notification(notification: Notification, *, user_ids: list[int] | None = None) -> int: + """Рассылает уведомление в привязанные мессенджеры. + + Получателей можно передать готовыми: тот же список нужен и оповещению + открытого приложения, а считать его дважды на каждое уведомление незачем. + """ + if user_ids is None: + user_ids = recipient_user_ids(notification) if not user_ids: return 0 bindings = ( @@ -71,7 +62,10 @@ def deliver_notification(notification: Notification) -> int: text = _message_text(notification) sent = 0 for binding in bindings: - if notification.type not in (binding.push_types or []): + allowed = messenger_types( + organization_id=notification.organization_id, user_id=binding.user_id + ) + if notification.type not in allowed: continue try: if transports.send_reply(binding.integration, chat_id=binding.external_chat_id, user_id="", text=text): diff --git a/apps/backend/chatballs/notifications/event_handlers.py b/apps/backend/chatballs/notifications/event_handlers.py index 330d9cf..6874347 100644 --- a/apps/backend/chatballs/notifications/event_handlers.py +++ b/apps/backend/chatballs/notifications/event_handlers.py @@ -1,6 +1,8 @@ from chatballs.events.handlers import register from chatballs.notifications.delivery import NOTIFICATION_CREATED, deliver_notification from chatballs.notifications.models import Notification +from chatballs.notifications.realtime import notify_notifications_changed +from chatballs.notifications.recipients import recipient_user_ids from chatballs.tenancy.context import TenantContext @@ -11,5 +13,11 @@ def handle_notification_created(payload: dict, context: TenantContext | None) -> notification = Notification.objects.filter( pk=payload.get("notificationId"), organization=context.organization ).first() - if notification is not None: - deliver_notification(notification) + if notification is None: + return + # Получатели считаются один раз на оба пути доставки. + user_ids = recipient_user_ids(notification) + # Сначала те, кто сидит в приложении: это мгновенно и ничего не стоит. + # Мессенджер — следом, он медленнее и ходит наружу. + notify_notifications_changed(user_ids) + deliver_notification(notification, user_ids=user_ids) diff --git a/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py b/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py new file mode 100644 index 0000000..15cd832 --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0011_notification_audience_group.py @@ -0,0 +1,36 @@ +"""Граница видимости уведомления — та же, что у диалога. + +Раньше уведомления фильтровались только правом «видеть диалоги», а сами диалоги +— ещё и группой (ADR-CHATBALLS-0043 §4). Из-за расхождения оператор одной группы +получал оклик с именем клиента и куском переписки по диалогу другой группы и, +перейдя по нему, упирался в «диалог не найден». + +Старые записи остаются с NULL: группу диалога, из которого они родились, задним +числом восстанавливать нечем, а NULL здесь означает прежнее поведение — видно +всем, кого пропускает аудитория. +""" + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0020_employeegroup_employeegroupmember_and_more'), + ('notifications', '0010_i18n_events'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='audience_group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='notifications', + to='identity.employeegroup', + ), + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py b/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py new file mode 100644 index 0000000..3ccd44b --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0012_notification_preference.py @@ -0,0 +1,87 @@ +"""Настройка оклика уезжает из привязки к мессенджеру в отдельную сущность. + +Пока транспорт был один, список типов на привязке работал. Со вторым (браузер) в +профиле оказались бы два списка галочек про одно и то же, и человек, отключивший +«новое сообщение», продолжал бы получать его с другой стороны. + +Порядок операций здесь важен: сначала новая таблица, потом перенос, и только +потом удаление старого поля. Django сгенерировал обратный порядок — в нём +настройки всех, кто подключил бота, молча обнулились бы. +""" + +import chatballs.notifications.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +MESSENGER = "MESSENGER" + + +def carry_over_push_types(apps, schema_editor): + MessengerBinding = apps.get_model("notifications", "MessengerBinding") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + rows = [] + seen = set() + for binding in MessengerBinding.objects.all().iterator(): + # Мессенджер у сотрудника один на организацию, но привязок в базе могло + # остаться несколько: берём первую и не плодим дубли под уникальным ключом. + key = (binding.organization_id, binding.user_id) + if key in seen: + continue + seen.add(key) + rows.append( + NotificationPreference( + organization_id=binding.organization_id, + user_id=binding.user_id, + transport=MESSENGER, + # Привязка существует — значит, согласие получать уже дано. + enabled=True, + types=binding.push_types or [], + ) + ) + NotificationPreference.objects.bulk_create(rows, ignore_conflicts=True) + + +def restore_push_types(apps, schema_editor): + MessengerBinding = apps.get_model("notifications", "MessengerBinding") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + by_user = { + (preference.organization_id, preference.user_id): preference.types + for preference in NotificationPreference.objects.filter(transport=MESSENGER) + } + for binding in MessengerBinding.objects.all().iterator(): + types = by_user.get((binding.organization_id, binding.user_id)) + if types is not None: + binding.push_types = types + binding.save(update_fields=["push_types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('identity', '0039_remove_organization_currency'), + ('notifications', '0011_notification_audience_group'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transport', models.CharField(choices=[('BROWSER', 'Браузер'), ('MESSENGER', 'Мессенджер')], max_length=16)), + ('enabled', models.BooleanField(default=False)), + ('types', models.JSONField(blank=True, default=chatballs.notifications.models.default_push_types)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notification_preferences', to='identity.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('organization', 'user', 'transport'), name='uniq_notification_preference')], + }, + ), + migrations.RunPython(carry_over_push_types, restore_push_types), + migrations.RemoveField( + model_name='messengerbinding', + name='push_types', + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py b/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py new file mode 100644 index 0000000..9a6a656 --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0013_notification_types_from_baseline.py @@ -0,0 +1,89 @@ +"""Набор типов событий — из макета «Очередь и уведомления», фрейм Q1. + +Раньше «клиент запросил оператора» и «диалог долго ждёт» были одним типом +DIALOG_WAITING, и отписаться от второго, не потеряв первое, было нельзя. Теперь +это разные события, и к ним добавилось «диалог назначили на меня». + +Старые записи разделяются по dedup_key — единственному, что о них известно +задним числом: ключи эскалации начинаются с «waiting:», ключи назначения — с +«assign:». Всё остальное, что было DIALOG_WAITING, — это просьба о человеке. + +Выбор сотрудника в настройках переносится по тому же соответствию, иначе человек после +обновления молча перестал бы получать то, на что подписан. +""" + +from django.db import migrations, models + +# Старый код -> новый. RELEASE_PUBLISHED не переносится: он был заделом на +# будущее, никогда не отправлялся и в новом наборе ему места нет. +OLD_TO_NEW = { + "DIALOG_WAITING": "OPERATOR_REQUESTED", + "INTEGRATION_ERROR": "AI_STOPPED", +} + +SPLIT_BY_DEDUP_PREFIX = ( + ("waiting:", "DIALOG_WAITING_LONG"), + ("assign:", "DIALOG_ASSIGNED"), +) + + +def forward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + for old, new in OLD_TO_NEW.items(): + Notification.objects.filter(type=old).update(type=new) + for prefix, new in SPLIT_BY_DEDUP_PREFIX: + Notification.objects.filter( + type="OPERATOR_REQUESTED", dedup_key__startswith=prefix + ).update(type=new) + _remap_preferences(apps, {**OLD_TO_NEW}) + + +def backward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + reverse = { + "OPERATOR_REQUESTED": "DIALOG_WAITING", + "DIALOG_WAITING_LONG": "DIALOG_WAITING", + "DIALOG_ASSIGNED": "DIALOG_WAITING", + "AI_STOPPED": "INTEGRATION_ERROR", + } + for new, old in reverse.items(): + Notification.objects.filter(type=new).update(type=old) + _remap_preferences(apps, reverse) + + +def _remap_preferences(apps, mapping): + """Подписки сотрудников: коды внутри JSON-списка.""" + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + moved = [mapping.get(code, code) for code in types] + # Дубли возможны, когда два старых кода сходятся в один новый. + unique = list(dict.fromkeys(moved)) + if unique != types: + preference.types = unique + preference.save(update_fields=["types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0012_notification_preference'), + ] + + operations = [ + migrations.RunPython(forward, backward), + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField( + choices=[ + ('OPERATOR_REQUESTED', 'Клиент запросил оператора'), + ('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'), + ('DIALOG_ASSIGNED', 'Диалог назначили на меня'), + ('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'), + ('AI_STOPPED', 'AI остановлен ошибкой или лимитом'), + ], + max_length=32, + ), + ), + ] diff --git a/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py b/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py new file mode 100644 index 0000000..0ab56ce --- /dev/null +++ b/apps/backend/chatballs/notifications/migrations/0014_new_dialog_type.py @@ -0,0 +1,69 @@ +"""«Новый диалог» — отдельное событие (макет Q1, дополнение владельца). + +В присланном наборе такого типа не было, и оклик о новом диалоге попал в +«клиент запросил оператора». Но диалог начинается и на канале с работающим +агентом, где человека никто не звал: называть это просьбой о человеке — врать. + +Старые записи узнаются по dedup_key «dialog:» — так помечены именно оклики о +новом диалоге. Разделить их по тому, был ли тогда доступен агент, задним числом +нельзя, поэтому все они становятся «новым диалогом»: это верно для +подавляющего большинства (канал с агентом — обычный случай) и, в отличие от +обратного, ничего не приписывает клиенту. + +Подписки: кому приходило «клиент запросил оператора», тому добавляется и «новый +диалог». До этой миграции это было одно событие, и человек не должен молча +перестать получать половину того, на что был подписан. +""" + +from django.db import migrations, models + + +def forward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + Notification.objects.filter( + type="OPERATOR_REQUESTED", dedup_key__startswith="dialog:" + ).update(type="NEW_DIALOG") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + if "OPERATOR_REQUESTED" in types and "NEW_DIALOG" not in types: + preference.types = ["NEW_DIALOG", *types] + preference.save(update_fields=["types"]) + + +def backward(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + Notification.objects.filter(type="NEW_DIALOG").update(type="OPERATOR_REQUESTED") + NotificationPreference = apps.get_model("notifications", "NotificationPreference") + for preference in NotificationPreference.objects.all().iterator(): + types = preference.types or [] + if "NEW_DIALOG" in types: + kept = [code for code in types if code != "NEW_DIALOG"] + preference.types = kept if "OPERATOR_REQUESTED" in kept else ["OPERATOR_REQUESTED", *kept] + preference.save(update_fields=["types"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0013_notification_types_from_baseline'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField( + choices=[ + ('NEW_DIALOG', 'Новый диалог'), + ('OPERATOR_REQUESTED', 'Клиент запросил оператора'), + ('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'), + ('DIALOG_ASSIGNED', 'Диалог назначили на меня'), + ('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'), + ('AI_STOPPED', 'AI остановлен ошибкой или лимитом'), + ], + max_length=32, + ), + ), + migrations.RunPython(forward, backward), + ] diff --git a/apps/backend/chatballs/notifications/models.py b/apps/backend/chatballs/notifications/models.py index 193edeb..857be84 100644 --- a/apps/backend/chatballs/notifications/models.py +++ b/apps/backend/chatballs/notifications/models.py @@ -8,11 +8,24 @@ from chatballs.tenancy.models import TenantRelationModel class NotificationType(models.TextChoices): - DIALOG_WAITING = "DIALOG_WAITING", "Диалог ждёт оператора" - DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в диалоге" - # Задел на будущее (добавляются записью в реестр notifications.services.TYPE_META): - RELEASE_PUBLISHED = "RELEASE_PUBLISHED", "Опубликован релиз агента" - INTEGRATION_ERROR = "INTEGRATION_ERROR", "Ошибка интеграции" + """Что произошло. Набор — из макета «Очередь и уведомления», фрейм Q1. + + Это ровно тот список, который сотрудник видит галочками в профиле, поэтому + типы разделены по смыслу для человека, а не по месту в коде. «Клиент + запросил оператора» и «диалог долго ждёт» раньше были одним типом, и + отписаться от второго, не потеряв первое, было нельзя. + + «Новый диалог» стоит отдельно от «клиент запросил оператора» по той же + причине: диалог может начаться и на канале с работающим агентом, где + человека никто не звал, и называть такой оклик просьбой о человеке — врать. + """ + + NEW_DIALOG = "NEW_DIALOG", "Новый диалог" + OPERATOR_REQUESTED = "OPERATOR_REQUESTED", "Клиент запросил оператора" + DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в моём диалоге" + DIALOG_ASSIGNED = "DIALOG_ASSIGNED", "Диалог назначили на меня" + DIALOG_WAITING_LONG = "DIALOG_WAITING_LONG", "Диалог долго ждёт человека" + AI_STOPPED = "AI_STOPPED", "AI остановлен ошибкой или лимитом" class NotificationLevel(models.TextChoices): @@ -47,6 +60,17 @@ class Notification(models.Model): target_route = models.CharField(max_length=64, blank=True) target_id = models.CharField(max_length=64, blank=True) audience = models.CharField(max_length=16, choices=NotificationAudience.choices, default=NotificationAudience.ALL) + # Граница видимости уведомления (ADR-CHATBALLS-0043 §4). Уведомление о диалоге + # видно тем же, кому виден сам диалог: иначе оператор чужой группы получает + # оклик с именем клиента и куском переписки, а открыть диалог не может. + # NULL — видно всем, кого пропускает аудитория. + audience_group = models.ForeignKey( + "identity.EmployeeGroup", + on_delete=models.SET_NULL, + related_name="notifications", + null=True, + blank=True, + ) recipient_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="direct_notifications") source_type = models.CharField(max_length=64, blank=True) source_id = models.CharField(max_length=64, blank=True) @@ -72,8 +96,57 @@ class NotificationRead(TenantRelationModel): def default_push_types() -> list[str]: - # Дефолт: диалоговые события (новый диалог / ждёт оператора / новое сообщение). - return [NotificationType.DIALOG_WAITING, NotificationType.DIALOG_NEW_MESSAGE] + # Дефолт — то, что касается лично и требует действия: позвали человека, + # написали в мой диалог, назначили на меня. «Долго ждёт» и сбой AI — + # подписка по желанию, иначе оклик обесценивается. + # «Новый диалог» сюда не входит: диалог, которым занимается агент, лично + # никого не касается. Кому он нужен — включит галочкой. + return [ + NotificationType.OPERATOR_REQUESTED, + NotificationType.DIALOG_NEW_MESSAGE, + NotificationType.DIALOG_ASSIGNED, + ] + + +class NotificationTransport(models.TextChoices): + BROWSER = "BROWSER", "Браузер" + MESSENGER = "MESSENGER", "Мессенджер" + + +class NotificationPreference(models.Model): + """Куда и о чём окликать сотрудника. + + Одна настройка на все транспорты. Браузер и бот в мессенджере отвечают на + один и тот же вопрос — «о чём меня звать», — и второй такой же список + галочек в профиле означал бы два расходящихся ответа на него. Новый + транспорт (почта, мобильное приложение) — это строка здесь, а не ещё одна + таблица настроек. + + Разрешение самого браузера тут не хранится: оно живёт в браузере, своё на + каждом устройстве, и сервер его ни выдать, ни отозвать не может. Здесь + только намерение человека — хочет ли он, чтобы его окликали. + """ + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.PROTECT, related_name="notification_preferences" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_preferences" + ) + transport = models.CharField(max_length=16, choices=NotificationTransport.choices) + enabled = models.BooleanField(default=False) + # Подмножество NotificationType; пустой список — не звать вовсе. + types = models.JSONField(default=default_push_types, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["organization", "user", "transport"], name="uniq_notification_preference" + ) + ] + + def __str__(self) -> str: + return f"pref:{self.user_id}/{self.transport}" class MessengerBinding(TenantRelationModel): @@ -87,8 +160,8 @@ class MessengerBinding(TenantRelationModel): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="messenger_bindings") integration = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="messenger_bindings") external_chat_id = models.CharField(max_length=128) - # Типы уведомлений, которые доставляются в мессенджер (подмножество NotificationType). - push_types = models.JSONField(default=default_push_types, blank=True) + # Типы событий живут не здесь, а в NotificationPreference: вопрос «о чём + # звать» один на все транспорты, и ответ на него обязан быть один. created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/apps/backend/chatballs/notifications/preferences.py b/apps/backend/chatballs/notifications/preferences.py new file mode 100644 index 0000000..f708abf --- /dev/null +++ b/apps/backend/chatballs/notifications/preferences.py @@ -0,0 +1,94 @@ +"""Настройки оклика сотрудника: о чём звать и каким транспортом. + +Раньше список типов жил на привязке к мессенджеру. Пока транспорт был один, это +работало; со вторым (браузер) в профиле появились бы два списка галочек про одно +и то же, и человек, отключивший «новое сообщение», продолжал бы получать его с +другой стороны. Поэтому список вынесен из привязки и заведён на транспорт. + +Про `enabled` важно не ошибиться: это согласие получать, и у двух транспортов оно +выражено по-разному. У мессенджера согласие — сама привязка бота: она есть, и +звать есть куда; нет — и `enabled` ничего не спасёт. У браузера предъявить нечего, +кроме намерения человека, поэтому его и храним. Отсюда две разные функции ниже: +доставка в мессенджер спрашивает только типы (получателей она уже отобрала по +наличию привязки), а браузер — типы вместе с согласием. +""" + +from __future__ import annotations + +from chatballs.notifications.models import ( + NotificationPreference, + NotificationTransport, + NotificationType, + default_push_types, +) + + +def preference_for( + *, organization_id: int, user_id: int, transport: str +) -> NotificationPreference: + """Настройка транспорта; при первом обращении заводится с дефолтами.""" + preference, _ = NotificationPreference.objects.get_or_create( + organization_id=organization_id, + user_id=user_id, + transport=transport, + defaults={"enabled": False, "types": default_push_types()}, + ) + return preference + + +def preference_types(*, organization_id: int, user_id: int, transport: str) -> list[str]: + """Типы, выбранные для транспорта. Без строки — дефолтный набор. + + Согласие не проверяется: у мессенджера его подтверждает привязка, и + отсутствие строки настроек не повод замолчать. + """ + preference = NotificationPreference.objects.filter( + organization_id=organization_id, user_id=user_id, transport=transport + ).first() + if preference is None: + return default_push_types() + return list(preference.types or []) + + +def enabled_types(*, organization_id: int, user_id: int, transport: str) -> list[str]: + """Типы транспорта, который человек включил. Не включил — пустой список.""" + preference = NotificationPreference.objects.filter( + organization_id=organization_id, user_id=user_id, transport=transport + ).first() + if preference is None or not preference.enabled: + return [] + return list(preference.types or []) + + +def update_preference( + *, + organization_id: int, + user_id: int, + transport: str, + enabled: bool | None = None, + types: list[str] | None = None, +) -> NotificationPreference: + preference = preference_for( + organization_id=organization_id, user_id=user_id, transport=transport + ) + fields = [] + if enabled is not None and preference.enabled != enabled: + preference.enabled = enabled + fields.append("enabled") + if types is not None: + # Чужие коды в список не пускаем: он приходит из браузера. + allowed = [code for code in types if code in NotificationType.values] + if preference.types != allowed: + preference.types = allowed + fields.append("types") + if fields: + preference.save(update_fields=fields) + return preference + + +def messenger_types(*, organization_id: int, user_id: int) -> list[str]: + return preference_types( + organization_id=organization_id, + user_id=user_id, + transport=NotificationTransport.MESSENGER, + ) diff --git a/apps/backend/chatballs/notifications/realtime.py b/apps/backend/chatballs/notifications/realtime.py new file mode 100644 index 0000000..3304ab3 --- /dev/null +++ b/apps/backend/chatballs/notifications/realtime.py @@ -0,0 +1,27 @@ +"""Оповещение сотрудника о том, что у него появились уведомления. + +Событие адресное — в сокет каждого получателя отдельно. Группа организации, на +которой живут события инбокса, здесь не годится: уведомления сотрудник видит не +все, и рассылка на организацию означала бы, что о чужих он как минимум узнаёт. + +Как и у диалогов, событие несёт только повод перезапросить: ни текста, ни +идентификаторов. Содержимое клиент забирает обычным запросом, где и живёт +проверка видимости — канал о ней не знает и потому не может в ней ошибиться. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from chatballs.realtime import publish + +NOTIFICATIONS_EVENT = "notifications.changed" + + +def user_group(user_id: int) -> str: + return f"user.{user_id}" + + +def notify_notifications_changed(user_ids: Iterable[int]) -> None: + for user_id in set(user_ids): + publish(user_group(user_id), {"type": NOTIFICATIONS_EVENT}) diff --git a/apps/backend/chatballs/notifications/recipients.py b/apps/backend/chatballs/notifications/recipients.py new file mode 100644 index 0000000..643d221 --- /dev/null +++ b/apps/backend/chatballs/notifications/recipients.py @@ -0,0 +1,71 @@ +"""Кому адресовано уведомление. + +Ответ нужен двум транспортам сразу — сокету открытого приложения и боту в +мессенджере, — поэтому он считается здесь один раз, а не в каждом из них. + +Раньше получатели искались перебором: для каждого сотрудника организации +выполнялся отдельный запрос `visible_for(...).exists()`. На сотню рабочих мест +это сотня запросов на каждое входящее сообщение — терпимо, только пока +уведомления доставляются раз в пятнадцать секунд. Здесь два запроса независимо +от размера организации: право выводится из роли, членство в группе — одним +IN-условием. +""" + +from __future__ import annotations + +from django.db.models import Q + +from chatballs.identity.models import OrganizationMembership +from chatballs.identity.policy import MANAGEMENT_ROLES, roles_with_capability +from chatballs.notifications.models import Notification, NotificationAudience + +# Аудитория — это право её читать. Зеркало notifications.selectors.visible_for: +# расходиться этим двум таблицам нельзя, иначе уведомление придёт тому, кто его +# потом не увидит в списке, или наоборот. +AUDIENCE_CAPABILITY = { + NotificationAudience.ALL: "company.view", + NotificationAudience.OPERATORS: "conversations.view", + NotificationAudience.OWNER: "employees.manage_privileged", +} + + +def audience_user_ids( + *, + organization_id: int, + audience: str, + group_id: int | None = None, + recipient_user_id: int | None = None, +) -> list[int]: + """Кого охватывает такая аудитория. + + Отдельно от уведомления, потому что спросить это нужно и до него: очередь + выясняет, есть ли вообще кому заметить ждущий диалог, ещё не решив, звать ли. + """ + if audience == NotificationAudience.USER: + return [recipient_user_id] if recipient_user_id else [] + capability = AUDIENCE_CAPABILITY.get(audience) + if capability is None: + return [] + memberships = OrganizationMembership.objects.filter( + organization_id=organization_id, + role__in=roles_with_capability(capability), + blocked_at__isnull=True, + user__is_active=True, + ) + if group_id is not None: + # Та же граница, что у диалога: руководство видит всё, остальные — свою + # группу (chatballs.identity.policy.conversation_visibility). + memberships = memberships.filter( + Q(role__in=MANAGEMENT_ROLES) | Q(group_links__group_id=group_id) + ).distinct() + return list(memberships.values_list("user_id", flat=True)) + + +def recipient_user_ids(notification: Notification) -> list[int]: + """Идентификаторы сотрудников, которым это уведомление адресовано.""" + return audience_user_ids( + organization_id=notification.organization_id, + audience=notification.audience, + group_id=notification.audience_group_id, + recipient_user_id=notification.recipient_user_id, + ) diff --git a/apps/backend/chatballs/notifications/selectors.py b/apps/backend/chatballs/notifications/selectors.py index 8f2f8e9..f19c714 100644 --- a/apps/backend/chatballs/notifications/selectors.py +++ b/apps/backend/chatballs/notifications/selectors.py @@ -1,7 +1,12 @@ from django.db.models import Q, QuerySet -from chatballs.identity.policy import ResourceScope, authorize +from chatballs.identity.policy import ( + ResourceScope, + authorize, + conversation_visibility, +) from chatballs.notifications.models import Notification, NotificationAudience +from chatballs.notifications.recipients import AUDIENCE_CAPABILITY from chatballs.tenancy.context import TenantContext @@ -11,16 +16,25 @@ def visible_for(context: TenantContext) -> QuerySet[Notification]: return Notification.objects.none() user = context.actor_user organization_scope = ResourceScope(profile.organization_id) - audiences = [] - if authorize(profile, "company.view", organization_scope): - audiences.append(NotificationAudience.ALL) - if authorize(profile, "conversations.view", organization_scope): - audiences.append(NotificationAudience.OPERATORS) - if authorize(profile, "employees.manage_privileged", organization_scope): - audiences.append(NotificationAudience.OWNER) + audiences = [ + audience + for audience, capability in AUDIENCE_CAPABILITY.items() + if authorize(profile, capability, organization_scope) + ] + by_audience = Q(audience__in=audiences) + scope = conversation_visibility(profile) + if scope is not None: + # Уведомление о диалоге группы читает тот же, кому виден сам диалог. + # Предикат намеренно взят тот же (ADR-CHATBALLS-0043 §4): два разных + # ответа на вопрос «кому это видно» — дефект, а не гибкость. Раньше + # уведомления фильтровались только правом, и оператор чужой группы + # получал оклик с именем клиента и куском переписки по диалогу, который + # не может открыть. + by_audience &= Q(audience_group__isnull=True) | Q( + audience_group_id__in=scope.get("group_ids") or () + ) return Notification.objects.filter(organization_id=profile.organization_id).filter( - Q(audience__in=audiences) - | Q(audience=NotificationAudience.USER, recipient_user=user) + by_audience | Q(audience=NotificationAudience.USER, recipient_user=user) ) diff --git a/apps/backend/chatballs/notifications/services.py b/apps/backend/chatballs/notifications/services.py index 9da099f..04e3084 100644 --- a/apps/backend/chatballs/notifications/services.py +++ b/apps/backend/chatballs/notifications/services.py @@ -20,25 +20,35 @@ from chatballs.notifications.selectors import unread_for # каждый сотрудник читает его на своём языке. Ярлык TextChoices остаётся # английским/русским значением модели и до человека не доходит. TYPE_META: dict[str, dict] = { - NotificationType.DIALOG_WAITING: { + NotificationType.NEW_DIALOG: { + "level": NotificationLevel.INFO, + "route": "chat", + "label": "notifications.type_new_dialog", + }, + NotificationType.OPERATOR_REQUESTED: { "level": NotificationLevel.WARNING, "route": "chat", - "label": "notifications.type_dialog_waiting", + "label": "notifications.type_operator_requested", }, NotificationType.DIALOG_NEW_MESSAGE: { "level": NotificationLevel.INFO, "route": "chat", "label": "notifications.type_dialog_new_message", }, - NotificationType.RELEASE_PUBLISHED: { - "level": NotificationLevel.SUCCESS, - "route": "agents", - "label": "notifications.type_release_published", + NotificationType.DIALOG_ASSIGNED: { + "level": NotificationLevel.INFO, + "route": "chat", + "label": "notifications.type_dialog_assigned", }, - NotificationType.INTEGRATION_ERROR: { + NotificationType.DIALOG_WAITING_LONG: { + "level": NotificationLevel.WARNING, + "route": "chat", + "label": "notifications.type_dialog_waiting_long", + }, + NotificationType.AI_STOPPED: { "level": NotificationLevel.CRITICAL, - "route": "integrations", - "label": "notifications.type_integration_error", + "route": "agents", + "label": "notifications.type_ai_stopped", }, } @@ -48,6 +58,7 @@ def notify( context, type: str, audience: str, + audience_group=None, title: str, body: str = "", title_key: str = "", @@ -71,6 +82,7 @@ def notify( organization=organization, type=type, audience=audience, + audience_group=audience_group, title=title, body=body, title_key=title_key, diff --git a/apps/backend/chatballs/notifications/test_group_visibility.py b/apps/backend/chatballs/notifications/test_group_visibility.py new file mode 100644 index 0000000..3ee83d4 --- /dev/null +++ b/apps/backend/chatballs/notifications/test_group_visibility.py @@ -0,0 +1,86 @@ +"""Уведомление о диалоге видно тем же, кому виден сам диалог. + +Регрессия: уведомления фильтровались только правом «видеть диалоги», а диалоги — +ещё и группой (ADR-CHATBALLS-0043 §4). Оператор чужой группы получал оклик с +именем клиента и куском переписки, а открыть диалог не мог. +""" + +from django.test import TestCase + +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.recipients import recipient_user_ids +from chatballs.notifications.selectors import visible_for +from chatballs.notifications.services import notify +from chatballs.testing import tenant_context_for + + +class NotificationGroupVisibilityTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="notify-groups") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.sales = EmployeeGroup.objects.create(organization=self.organization, name="Продажи") + self.owner = self._employee("owner@notify.test", EmployeeRole.OWNER) + self.insider = self._employee("insider@notify.test", EmployeeRole.EMPLOYEE) + self.outsider = self._employee("outsider@notify.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.insider + ) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.sales, employee=self.outsider + ) + self.context = tenant_context_for(self.owner.user, self.organization) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _visible_ids(self, membership: OrganizationMembership) -> set[int]: + context = tenant_context_for(membership.user, self.organization) + return set(visible_for(context).values_list("id", flat=True)) + + def _waiting(self, group: EmployeeGroup | None): + return notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=group, + title="Нужен оператор", + ) + + def test_group_notification_reaches_only_that_group_and_management(self) -> None: + notification = self._waiting(self.support) + self.assertIn(notification.id, self._visible_ids(self.insider)) + self.assertNotIn(notification.id, self._visible_ids(self.outsider)) + self.assertIn(notification.id, self._visible_ids(self.owner)) + + def test_notification_without_group_stays_visible_to_everyone(self) -> None: + notification = self._waiting(None) + for membership in (self.insider, self.outsider, self.owner): + self.assertIn(notification.id, self._visible_ids(membership)) + + def test_recipients_match_what_the_list_shows(self) -> None: + """Доставка и список обязаны отвечать одинаково. + + Иначе уведомление уходит в мессенджер тому, кто потом не находит его в + приложении, или наоборот. + """ + for group in (self.support, None): + notification = self._waiting(group) + delivered = set(recipient_user_ids(notification)) + listed = { + membership.user_id + for membership in (self.owner, self.insider, self.outsider) + if notification.id in self._visible_ids(membership) + } + self.assertEqual(delivered, listed) diff --git a/apps/backend/chatballs/notifications/test_realtime_fanout.py b/apps/backend/chatballs/notifications/test_realtime_fanout.py new file mode 100644 index 0000000..c45e5d2 --- /dev/null +++ b/apps/backend/chatballs/notifications/test_realtime_fanout.py @@ -0,0 +1,93 @@ +"""Уведомление доезжает до открытого приложения событием, а не опросом. + +Событие адресное: в личную группу каждого получателя. Организация целиком не +годится — сотрудник видит не все уведомления, и рассылка на всех означала бы, +что о чужих он как минимум узнаёт. +""" + +from unittest import mock + +from django.test import TestCase + +from chatballs.events.handlers import dispatch +from chatballs.events.models import OutboxEvent +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.delivery import NOTIFICATION_CREATED +from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.realtime import user_group +from chatballs.notifications.services import notify +from chatballs.testing import tenant_context_for + + +class NotificationFanoutTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Example", slug="notify-fanout") + self.support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self.owner = self._employee("owner@fanout.test", EmployeeRole.OWNER) + self.insider = self._employee("insider@fanout.test", EmployeeRole.EMPLOYEE) + self.outsider = self._employee("outsider@fanout.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.support, employee=self.insider + ) + self.context = tenant_context_for(self.owner.user, self.organization) + + def _employee(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, organization=self.organization, role=role, position_title="Specialist" + ) + + def _dispatch_last_event(self) -> list[str]: + """Разбирает событие из outbox и возвращает группы, в которые ушёл оклик.""" + event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at") + with mock.patch("chatballs.notifications.realtime.publish") as publish: + dispatch(event) + return [call.args[0] for call in publish.call_args_list] + + def test_event_goes_to_personal_groups_of_recipients_only(self) -> None: + notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + audience_group=self.support, + title="Нужен оператор", + ) + groups = self._dispatch_last_event() + self.assertEqual( + set(groups), + {user_group(self.owner.user_id), user_group(self.insider.user_id)}, + ) + self.assertNotIn(user_group(self.outsider.user_id), groups) + + def test_event_carries_no_content(self) -> None: + """В канал уходит только повод перезапросить: ни текста, ни номера.""" + notify( + context=self.context, + type=NotificationType.OPERATOR_REQUESTED, + audience=NotificationAudience.OPERATORS, + title="Нужен оператор", + body="Клиент ждёт ответа", + ) + event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at") + with mock.patch("chatballs.notifications.realtime.publish") as publish: + dispatch(event) + for call in publish.call_args_list: + self.assertEqual(call.args[1], {"type": "notifications.changed"}) + + def test_personal_notification_reaches_only_its_recipient(self) -> None: + notify( + context=self.context, + type=NotificationType.DIALOG_NEW_MESSAGE, + audience=NotificationAudience.USER, + recipient_user=self.outsider.user, + title="Новое сообщение", + ) + self.assertEqual(self._dispatch_last_event(), [user_group(self.outsider.user_id)]) diff --git a/apps/backend/chatballs/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py index df65f6d..46d84fe 100644 --- a/apps/backend/chatballs/notifications/tests.py +++ b/apps/backend/chatballs/notifications/tests.py @@ -97,7 +97,7 @@ class DeliveryTests(NotifierTestBase): with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send: notify( context=self.context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, title="Новый диалог · Acme — сайт", body="Гость · TELEGRAM: Привет", @@ -108,11 +108,11 @@ class DeliveryTests(NotifierTestBase): self.assertIn("Новый диалог", send.call_args.kwargs["text"]) def test_type_not_in_push_types_is_skipped(self) -> None: - # INTEGRATION_ERROR не входит в дефолтные push_types привязки. + # AI_STOPPED не входит в дефолтный набор типов. with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send: notify( context=self.context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.OWNER, title="Ошибка интеграции", ) @@ -136,13 +136,13 @@ class DeliveryTests(NotifierTestBase): operator = HumanUser.objects.get(email="staff.member@example.org") operators_notification = notify( context=self.context, - type=NotificationType.DIALOG_WAITING, + type=NotificationType.OPERATOR_REQUESTED, audience=NotificationAudience.OPERATORS, title="Waiting dialog", ) notify( context=self.context, - type=NotificationType.INTEGRATION_ERROR, + type=NotificationType.AI_STOPPED, audience=NotificationAudience.OWNER, title="Owner-only notice", ) @@ -245,13 +245,13 @@ class BindingApiTests(NotifierTestBase): def test_push_types_patch_and_listing(self) -> None: MessengerBinding.objects.create(user=self.owner, integration=self.integration, external_chat_id="777") listed = self.client.get("/api/v1/notifications/messenger-bindings/").json() - self.assertIn({"code": NotificationType.DIALOG_WAITING, "label": "Диалог ждёт оператора"}, listed["availableTypes"]) - self.assertIn(NotificationType.DIALOG_WAITING, listed["items"][0]["pushTypes"]) + self.assertIn({"code": NotificationType.OPERATOR_REQUESTED, "label": "Клиент запросил оператора"}, listed["availableTypes"]) + self.assertIn(NotificationType.OPERATOR_REQUESTED, listed["items"][0]["pushTypes"]) patched = self.client.patch( f"/api/v1/notifications/messenger-bindings/{self.integration.id}/", - data={"pushTypes": [NotificationType.INTEGRATION_ERROR, "NOT_A_TYPE"]}, + data={"pushTypes": [NotificationType.AI_STOPPED, "NOT_A_TYPE"]}, format="json", ) self.assertEqual(patched.status_code, 200) - self.assertEqual(patched.json()["pushTypes"], [NotificationType.INTEGRATION_ERROR]) + self.assertEqual(patched.json()["pushTypes"], [NotificationType.AI_STOPPED]) diff --git a/apps/backend/chatballs/notifications/urls.py b/apps/backend/chatballs/notifications/urls.py index d9d6bce..f5dafb3 100644 --- a/apps/backend/chatballs/notifications/urls.py +++ b/apps/backend/chatballs/notifications/urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path("read/", views.NotificationReadView.as_view(), name="notification-read"), path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"), path("messenger-bindings//", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"), + path("preferences//", views.NotificationPreferenceView.as_view(), name="notification-preference"), ] diff --git a/apps/backend/chatballs/notifications/views.py b/apps/backend/chatballs/notifications/views.py index c821a76..6cb4b19 100644 --- a/apps/backend/chatballs/notifications/views.py +++ b/apps/backend/chatballs/notifications/views.py @@ -5,7 +5,16 @@ from rest_framework.views import APIView from chatballs.i18n import t from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations -from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType +from chatballs.notifications.models import ( + MessengerBinding, + NotificationRead, + NotificationTransport, +) +from chatballs.notifications.preferences import ( + preference_for, + preference_types, + update_preference, +) from chatballs.notifications.selectors import unread_for, visible_for from chatballs.notifications.serializers import notification_payload from chatballs.notifications.services import TYPE_META, mark_read @@ -42,6 +51,11 @@ class MessengerBindingListView(APIView): integration__organization=request.tenant_context.organization, ) } + messenger_types = preference_types( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + ) items = [] for integration in notifier_integrations(request.tenant_context): binding = bindings.get(integration.id) @@ -52,7 +66,7 @@ class MessengerBindingListView(APIView): "name": integration.name, "botUsername": integration.config.get("bot_username", ""), "bound": binding is not None, - "pushTypes": binding.push_types if binding else [], + "pushTypes": messenger_types if binding else [], } ) # Реестр типов для чекбоксов в профиле (порядок — как в TYPE_META). @@ -91,12 +105,23 @@ class MessengerBindingDetailView(APIView): types = request.data.get("pushTypes") if not isinstance(types, list): return Response({"detail": t("notifications.push_types_list")}, status=400) - binding.push_types = [code for code in types if code in NotificationType.values] - binding.save(update_fields=["push_types"]) - return Response({"pushTypes": binding.push_types}) + preference = update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + types=types, + ) + return Response({"pushTypes": preference.types}) def delete(self, request: Request, integration_id: int) -> Response: MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).delete() + # Отвязали бота — звать этим транспортом больше некуда. + update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=NotificationTransport.MESSENGER, + enabled=False, + ) return Response({"ok": True}) @@ -112,3 +137,54 @@ class NotificationReadView(APIView): return Response({"detail": t("notifications.ids_list_or_all")}, status=400) mark_read(context=request.tenant_context, ids=[i for i in ids if isinstance(i, int)]) return Response({"unreadCount": unread_for(request.tenant_context).count()}) + + +class NotificationPreferenceView(APIView): + """Настройка транспорта: включён ли он и о чём звать. + + Мессенджер настраивается через свою привязку (там же, где бот), браузеру + отдельной сущности не нужно — разрешение живёт в самом браузере, а сюда + попадает только намерение человека. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request: Request, transport: str) -> Response: + if transport not in NotificationTransport.values: + return Response({"detail": t("notifications.unknown_transport")}, status=404) + preference = preference_for( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=transport, + ) + return Response(_preference_payload(preference)) + + def patch(self, request: Request, transport: str) -> Response: + if transport not in NotificationTransport.values: + return Response({"detail": t("notifications.unknown_transport")}, status=404) + types = request.data.get("types") + if types is not None and not isinstance(types, list): + return Response({"detail": t("notifications.push_types_list")}, status=400) + enabled = request.data.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + return Response({"detail": t("notifications.enabled_bool")}, status=400) + preference = update_preference( + organization_id=request.tenant_context.organization_id, + user_id=request.user.id, + transport=transport, + enabled=enabled, + types=types, + ) + return Response(_preference_payload(preference)) + + +def _preference_payload(preference) -> dict: + return { + "transport": preference.transport, + "enabled": preference.enabled, + "types": list(preference.types or []), + # Реестр типов — тот же, что у мессенджера: вопрос один на все транспорты. + "availableTypes": [ + {"code": code, "label": t(meta["label"])} for code, meta in TYPE_META.items() + ], + } diff --git a/apps/backend/chatballs/presence.py b/apps/backend/chatballs/presence.py new file mode 100644 index 0000000..b67f39c --- /dev/null +++ b/apps/backend/chatballs/presence.py @@ -0,0 +1,67 @@ +"""Кто из сотрудников сейчас за рабочим местом. + +Присутствие берётся из открытого сокета, а не из отдельной подсистемы: сокет и +есть признак того, что приложение открыто. Отдельная «доступность» со статусами, +сменами и графиками — это другая, большая задача, и для того, ради чего +присутствие понадобилось, она не нужна. + +Хранится не флаг, а момент последней активности. Флага хватало очереди («есть ли +кому заметить»), но выбору ответственного нужно ещё и «не в приложении · 25 +минут»: разница между «вышел налить кофе» и «ушёл вчера» — это разница между +хорошим и плохим назначением. Отметка обновляется на подключении, на heartbeat +раз в минуту и на обрыве, поэтому оборванное соединение само перестаёт быть +присутствием, без уборщика. + +Это подсказка, а не право. Ошибиться она может в обе стороны: ноутбук уснул, а +отметка ещё свежая; сеть моргнула, и человек на секунду «исчез». Поэтому ни один +доступ и ни одна доставка на присутствии не стоят — оно только помогает решить, +сколько ещё ждать и кого предложить первым. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime + +from django.core.cache import cache +from django.utils import timezone + +# Клиент шлёт heartbeat раз в минуту; запас вдвое с небольшим покрывает +# пропущенный тик и не держит призраков дольше пары минут. +ONLINE_WITHIN_SECONDS = 150 +# Сколько помним ушедшего, чтобы сказать, как давно его нет. Дольше суток это +# уже не «недавно вышел», а «здесь не работает», и точность не нужна. +REMEMBER_SECONDS = 24 * 3600 + + +def _key(organization_id: int, user_id: int) -> str: + return f"presence:{organization_id}:{user_id}" + + +def touch(organization_id: int, user_id: int) -> None: + """Отметить, что человек сейчас в приложении.""" + cache.set(_key(organization_id, user_id), timezone.now().timestamp(), REMEMBER_SECONDS) + + +def last_seen(organization_id: int, user_ids: Iterable[int]) -> dict[int, datetime]: + """Когда каждого из перечисленных видели в последний раз.""" + wanted = list(user_ids) + if not wanted: + return {} + keys = {_key(organization_id, user_id): user_id for user_id in wanted} + found = cache.get_many(list(keys)) + return { + keys[key]: datetime.fromtimestamp(value, tz=timezone.get_current_timezone()) + for key, value in found.items() + if isinstance(value, (int, float)) + } + + +def online_user_ids(organization_id: int, user_ids: Iterable[int]) -> set[int]: + """Кто из перечисленных на месте прямо сейчас.""" + now = timezone.now() + return { + user_id + for user_id, seen in last_seen(organization_id, user_ids).items() + if (now - seen).total_seconds() <= ONLINE_WITHIN_SECONDS + } diff --git a/apps/backend/chatballs/realtime.py b/apps/backend/chatballs/realtime.py new file mode 100644 index 0000000..794ab9d --- /dev/null +++ b/apps/backend/chatballs/realtime.py @@ -0,0 +1,31 @@ +"""Публикация оповещений в канальный слой. + +Транспорт и только транспорт. Какие бывают группы и какие события в них летят, +знают доменные модули (conversations.realtime, notifications.realtime); здесь +единственное в проекте знание про channels — и единственное место, где сбой +канала гасится, чтобы не уронить уже сделанную запись. +""" + +from __future__ import annotations + +import logging + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +logger = logging.getLogger(__name__) + + +def publish(group: str, payload: dict[str, object]) -> None: + """Оповещение — вспомогательный путь: его сбой не должен ронять запись. + + Данные к моменту отправки уже сохранены; если канал недоступен, клиент + узнает об изменении следующим опросом — он остаётся как запасной путь. + """ + layer = get_channel_layer() + if layer is None: + return + try: + async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload}) + except Exception: # noqa: BLE001 — канал не должен ломать сохранение + logger.warning("realtime fanout failed for %s", group, exc_info=True) diff --git a/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py b/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py new file mode 100644 index 0000000..0e1d19c --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0036_notification_preference_guards.py @@ -0,0 +1,47 @@ +# Настройки оклика (notifications_notificationpreference): tenant-таблица с +# organization_id, RLS и гранты по образцу tenancy/0022. Настройка принадлежит +# сотруднику в организации, читает и пишет её runtime-роль app. +from django.db import migrations + +TABLES = ("notifications_notificationpreference",) + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO chatballs_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; +GRANT ALL ON {table} TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO chatballs_runtime_app, chatballs_schema; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}; +CREATE POLICY chatballs_tenant_isolation ON {table} + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +DROP POLICY IF EXISTS chatballs_schema_access ON {table}; +CREATE POLICY chatballs_schema_access ON {table} + FOR ALL TO chatballs_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0035_organization_creation_and_invitation_directory"), + ("notifications", "0012_notification_preference"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py b/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py new file mode 100644 index 0000000..b168c9d --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0037_queue_policy_guards.py @@ -0,0 +1,47 @@ +# Пороги очереди (conversations_queueescalationpolicy): tenant-таблица с +# organization_id, RLS и гранты по образцу tenancy/0022. Читает её воркер под +# ролью app, когда обходит ждущие диалоги организации. +from django.db import migrations + +TABLES = ("conversations_queueescalationpolicy",) + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO chatballs_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app; +GRANT ALL ON {table} TO chatballs_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO chatballs_runtime_app, chatballs_schema; +DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}; +CREATE POLICY chatballs_tenant_isolation ON {table} + FOR ALL TO chatballs_runtime_app + USING (organization_id = chatballs.current_organization_id()) + WITH CHECK (organization_id = chatballs.current_organization_id()); +DROP POLICY IF EXISTS chatballs_schema_access ON {table}; +CREATE POLICY chatballs_schema_access ON {table} + FOR ALL TO chatballs_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0036_notification_preference_guards"), + ("conversations", "0023_queue_escalation"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 9adae18..41bbd45 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -13,6 +13,7 @@ import { fetchAgentDirectory } from "./features/agents/model"; import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; +import { RealtimeProvider } from "./features/realtime/RealtimeProvider"; import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; @@ -246,7 +247,11 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} /> + // Ключ организации — на провайдере: при переключении сокет обязан + // переоткрыться на адрес новой, а не остаться на прежней. + + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} /> + )} ); diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index bd3fd8e..413ae72 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -59,7 +59,7 @@ export function ChatPage({ ? {t("admin.see_every_conversation_organization_operators")} : undefined} viewerId={user.id} - renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext }) => ( + renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => ( )} /> @@ -86,6 +87,7 @@ function ChatContextPanel({ startCall, closeContext, viewerId, + assignmentTimeoutMinutes, }: { rightTab: ChatRightTab; setRightTab: (tab: ChatRightTab) => void; @@ -96,6 +98,7 @@ function ChatContextPanel({ startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; viewerId: number; + assignmentTimeoutMinutes?: number; }) { return (
@@ -106,7 +109,7 @@ function ChatContextPanel({
{rightTab === "client" && ( - + )} {rightTab === "history" && }
diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 6560ecb..c177d79 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -97,9 +97,16 @@ function MessageRow({ message, dialog, viewerId }: { message: ApiMessage; dialog // возврат — акцент. Тон выбирается по коду события, а не по словам в // тексте: текст приходит на языке читателя и на английском не совпал бы // ни с одной русской регуляркой. - const tone = message.systemEvent === "ai_handed_over" || message.systemEvent === "ai_unavailable" + // «Не взял — вернулся в очередь» окрашено предупреждением намеренно (макет + // Q4): это не факт из истории, а сорванная договорённость. + const tone = message.systemEvent === "ai_handed_over" + || message.systemEvent === "ai_unavailable" + || message.systemEvent === "assignment_expired" ? "warning" - : message.systemEvent === "operator_took" || message.systemEvent === "returned_to_ai" || message.systemEvent === "returned_to_queue" + : message.systemEvent === "operator_took" + || message.systemEvent === "returned_to_ai" + || message.systemEvent === "returned_to_queue" + || message.systemEvent === "assigned_to" ? "claimed" : ""; return
{message.text}·{fmtTime(message.createdAt)}
; diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 7d25e19..99fe719 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -59,7 +59,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi isOwner?: boolean; listTitle?: string; searchPlaceholder?: string; - renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void }) => ReactNode; + renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode; viewerId?: number | null; mobileHeader?: (info: { total: number }) => ReactNode; hint?: ReactNode; @@ -92,7 +92,8 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const settledSearch = useDebounced(search.trim()); const query = useMemo(() => ({ ...scopeFilters(scope), - ...(listTab === "wait" ? { waiting: true } : {}), + ...(listTab === "queue" ? { queue: true } : {}), + ...(listTab === "onMe" ? { waitingOnMe: true } : {}), ...(listTab === "mine" ? { assigned: "me" as const } : {}), ...(settledSearch ? { q: settledSearch } : {}), sort, @@ -154,7 +155,10 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const callController = useConversationCall({ conversationId: selectedId, onConversationChanged }); useIncomingMessageSound(list.conversations, list.loaded); - const dialogs = useMemo(() => list.conversations.map(toConversationListItem), [list.conversations]); + const dialogs = useMemo( + () => list.conversations.map((item) => toConversationListItem(item, { viewerId, assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes })), + [list.conversations, viewerId, counters?.assignmentTimeoutMinutes], + ); useDialogKeyboardNav({ dialogs, selectedId, @@ -280,6 +284,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi startCall: detail?.lifecycle === "OPEN" && (detail.connection?.audioCalls || detail.connection?.videoCalls) ? (kind) => void callController.start(kind) : null, // Кадр S2: выдвижная панель закрывается крестиком в её шапке. closeContext: () => setCtxOpen(false), + assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes, })} ); diff --git a/apps/internal-ui/src/features/conversations/DialogControls.tsx b/apps/internal-ui/src/features/conversations/DialogControls.tsx index c106a71..bdcdc53 100644 --- a/apps/internal-ui/src/features/conversations/DialogControls.tsx +++ b/apps/internal-ui/src/features/conversations/DialogControls.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { Icon } from "../../shared/icons"; import { SearchInput } from "../../shared/ui-controls"; +import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions"; +import { WaitingBlock } from "./WaitingBlock"; import { PriorityBars } from "./DialogList"; import { useEmployeeDirectory } from "./useEmployeeDirectory"; import { statusFor } from "./data"; @@ -42,21 +44,19 @@ const PRIORITY_TEXT: Record = { NONE: "var(--n-4)", }; -function initials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); -} - export function DialogControls({ detail, groups, applyConversation, viewerId = null, + assignmentTimeoutMinutes, }: { detail: ApiConversation; groups: Array; applyConversation: (updated: ApiConversation) => void; viewerId?: number | null; + /** Срок личной очереди из счётчиков: по нему считается «вернётся через». */ + assignmentTimeoutMinutes?: number; }) { const directory = useEmployeeDirectory(); const [busy, setBusy] = useState(false); @@ -111,6 +111,7 @@ export function DialogControls({ const assignedIds = new Set(detail.labels.map((item) => item.id)); const availableLabels = labels.filter((item) => !assignedIds.has(item.id)); const assignee = detail.assignedOperator; + const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false; const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned"); const status = statusFor(controlModeOf(detail), assignee?.name); const priorityLabel = PRIORITY_OPTIONS.find(([value]) => value === detail.priority)?.[1] ?? t("conversations.not_set"); @@ -126,6 +127,7 @@ export function DialogControls({ {!collapsed && (
{errorText &&

{errorText}

} + - ), - }] - : []), - { key: "none", label: }, - ...directory.employees.map((employee) => ({ - key: employee.id, - label: , - })), - ], + items: assigneeMenuItems({ + directory, + viewerId, + assigneeId: assignee?.id, + onPick: (id) => void run(() => setConversationAssignee(detail.id, id)), + }), }} > @@ -272,7 +257,10 @@ export function DialogControls({ ))}
-
{t("conversations.started")}{startedLabel(detail.createdAt)}
+
+ {detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")} + {startedLabel(detail.waitingSince ?? detail.createdAt)} +
)}
@@ -341,7 +329,4 @@ export function archiveConversationAction( } // Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы. -function SmallAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string | null }) { - if (avatarUrl) return ; - return {initials(name)}; -} + diff --git a/apps/internal-ui/src/features/conversations/DialogList.tsx b/apps/internal-ui/src/features/conversations/DialogList.tsx index 10f9bf9..f0f0dc9 100644 --- a/apps/internal-ui/src/features/conversations/DialogList.tsx +++ b/apps/internal-ui/src/features/conversations/DialogList.tsx @@ -43,7 +43,8 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde setSelectedId: (id: number) => void; }) { // Счётчик ждущих — из счётчиков охвата, а не из загруженного окна. - const waitCount = counters?.waiting ?? 0; + const queueCount = counters?.queue ?? 0; + const onMeCount = counters?.waitingOnMe ?? 0; // Лента догружается прокруткой: следующее окно запрашивается на подходе к низу. const onScroll = useCallback((event: UIEvent) => { if (!hasMore) return; @@ -83,9 +84,14 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
setListTab("all")}>{t("common.all")} setListTab("mine")}>{t("conversations.mine")} - setListTab("wait")}>{t("conversations.waiting_for_operator")}{waitCount > 0 && {waitCount}} + setListTab("queue")}>{t("conversations.common_queue")}{queueCount > 0 && {queueCount}} + setListTab("onMe")}>{t("conversations.on_me")}{onMeCount > 0 && {onMeCount}}
{hint} + {/* Что значат две вкладки ожидания — прямо там, где их выбирают. */} + {(listTab === "queue" || listTab === "onMe") && ( +
{t("conversations.queue_tabs_note")}
+ )}
{errorText &&
{errorText}
} {/* Кадр S1: пустой список без призыва к действию. Под фильтром и @@ -131,9 +137,10 @@ function DialogListItem({ dialog, active, setSelectedId }: { dialog: Conversatio {dialog.preview} {dialog.unread > 0 && {dialog.unread}} - {(dialog.waitLabel || dialog.groupName || dialog.labels.length > 0) && ( + {(dialog.waitLabel || dialog.mineLabel || dialog.groupName || dialog.labels.length > 0) && ( {dialog.waitLabel && {dialog.waitLabel}} + {dialog.mineLabel && {dialog.mineLabel}} {dialog.groupName && {dialog.groupName}} {dialog.labels.map((label) => ( {label.name} diff --git a/apps/internal-ui/src/features/conversations/WaitingBlock.tsx b/apps/internal-ui/src/features/conversations/WaitingBlock.tsx new file mode 100644 index 0000000..29a9ce3 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/WaitingBlock.tsx @@ -0,0 +1,54 @@ +import { Icon } from "../../shared/icons"; +import { t, tn } from "../../i18n"; +import { SmallAvatar } from "./assigneeOptions"; +import { waitLabelOf, type ApiConversation } from "./model"; + +// Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4). +// +// Отвечает на два вопроса, которые иначе оператору взять негде: сколько клиент +// уже ждёт человека и почему диалог виден, но брать его не нужно. Второе — +// единственное объяснение назначенному, но не взятому диалогу: без него он +// выглядит как обычный ждущий, который почему-то не даёт себя взять. + +/** «через 3 минуты» — сколько осталось личной очереди. */ +function leftFor(assignedAt: string, timeoutMinutes: number, now: Date): string | null { + const deadline = new Date(assignedAt).getTime() + timeoutMinutes * 60000; + const minutes = Math.round((deadline - now.getTime()) / 60000); + return minutes > 0 ? tn("plural.minutes", minutes) : null; +} + +export function WaitingBlock({ + detail, + timeoutMinutes, + now = new Date(), +}: { + detail: ApiConversation; + timeoutMinutes?: number; + now?: Date; +}) { + if (detail.lifecycle !== "OPEN" || detail.controlMode !== "PAUSED" || !detail.waitingSince) { + return null; + } + const assignee = detail.assignedOperator; + const left = assignee && detail.assignedAt && timeoutMinutes + ? leftFor(detail.assignedAt, timeoutMinutes, now) + : null; + return ( +
+
+ + {t("conversations.waiting_for_person", { duration: waitLabelOf(detail.waitingSince, now) })} +
+ {assignee && ( + <> +
+ + {t("conversations.assigned_not_taken", { name: assignee.name })} +
+ {left &&

{t("conversations.returns_to_queue_in", { duration: left })}

} + + )} +
+ ); +} + diff --git a/apps/internal-ui/src/features/conversations/assigneeOptions.tsx b/apps/internal-ui/src/features/conversations/assigneeOptions.tsx new file mode 100644 index 0000000..13dc8ee --- /dev/null +++ b/apps/internal-ui/src/features/conversations/assigneeOptions.tsx @@ -0,0 +1,129 @@ +import type { ItemType } from "antd/es/menu/interface"; + +import { Icon } from "../../shared/icons"; +import { SearchInput } from "../../shared/ui-controls"; +import { t } from "../../i18n"; +import type { ChatDirectoryEmployee } from "./model"; +import type { EmployeeDirectory } from "./useEmployeeDirectory"; + +/** Инициалы: «Анна Ким» → «АК». */ +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); +} + +/** Маленький аватар строки выбора: используется и в списке, и в закрытом поле. */ +export function SmallAvatar({ name, avatarUrl, online }: { name: string; avatarUrl?: string | null; online?: boolean }) { + // Точка присутствия живёт внутри аватара: у него фиксированный размер, и + // угол, к которому её прижимают, всегда там, где ожидается. + const dot = online ? : null; + if (avatarUrl) { + return {dot}; + } + return {dot}{initials(name)}; +} + +// Выбор ответственного (макет «Очередь и уведомления», кадр Q5): сначала те, +// кто сейчас в приложении, потом остальные. Отсутствующих не скрываем и не +// блокируем — признак приблизительный, назначить можно любого; они просто не +// первыми попадаются на глаза. + +/** «не в приложении · 25 мин» — сколько человека уже нет. */ +export function awayFor(lastSeenAt: string | null | undefined, now = new Date()): string { + if (!lastSeenAt) return t("conversations.never_in_app"); + const seen = new Date(lastSeenAt); + if (Number.isNaN(seen.getTime())) return t("conversations.never_in_app"); + const minutes = Math.max(1, Math.round((now.getTime() - seen.getTime()) / 60000)); + if (minutes < 60) return t("conversations.away_minutes", { count: minutes }); + const hours = Math.round(minutes / 60); + if (hours < 24) return t("conversations.away_hours", { count: hours }); + return t("conversations.away_long"); +} + +function subtitleOf(employee: ChatDirectoryEmployee): string { + if (!employee.online) return awayFor(employee.lastSeenAt); + return employee.role === "OWNER" ? t("conversations.here_owner") : t("conversations.here"); +} + +function personItem( + employee: ChatDirectoryEmployee, + { viewerId, assigneeId, onPick }: { viewerId: number | null; assigneeId?: number; onPick: (id: number) => void }, +): ItemType { + const load = employee.openDialogs ?? 0; + return { + key: employee.id, + label: ( + + ), + }; +} + +export function assigneeMenuItems({ + directory, + viewerId, + assigneeId, + onPick, +}: { + directory: EmployeeDirectory; + viewerId: number | null; + assigneeId?: number; + onPick: (id: number | null) => void; +}): ItemType[] { + const here = directory.employees.filter((employee) => employee.online); + const away = directory.employees.filter((employee) => !employee.online); + const pick = { viewerId, assigneeId, onPick: (id: number) => onPick(id) }; + return [ + // Строка поиска появляется, когда коллег больше, чем помещается в выдачу + // справочника: маленькой команде она не нужна. + ...(directory.hasMore || directory.query + ? [{ + key: "search", + type: "group" as const, + label: ( + + ), + }] + : []), + { + key: "none", + label: ( + + ), + }, + ...(here.length > 0 + ? [{ key: "here-head", type: "group" as const, label: t("conversations.presence_here") }] + : []), + ...here.map((employee) => personItem(employee, pick)), + ...(away.length > 0 + ? [{ key: "away-head", type: "group" as const, label: t("conversations.presence_away") }] + : []), + ...away.map((employee) => personItem(employee, pick)), + { + key: "presence-note", + type: "group" as const, + className: "ctx-menu-note", + label: t("conversations.presence_note"), + }, + ]; +} diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 0601c66..2f8bf48 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -50,7 +50,13 @@ export type ConversationLabelRef = { id: number; name: string; color: string }; export type ConversationCounters = { all: number; waiting: number; + /** Ничей: взять может любой. */ + queue: number; + /** Назначен лично на меня и ждёт, пока я возьму. */ + waitingOnMe: number; mine: number; + /** Сколько минут держится личная очередь, прежде чем диалог вернётся всем. */ + assignmentTimeoutMinutes?: number; ungrouped: number; groups: Array<{ id: number; name: string; color?: string; count: number }>; agents: Array<{ id: number; code: string; name: string; count: number }>; @@ -73,6 +79,10 @@ export type ApiConversation = { assignedOperatorId: number | null; assignedOperator: { id: number; name: string; avatarUrl?: string | null } | null; isAssignedToViewer: boolean; + /** С какого момента диалог ждёт человека (макет Q3/Q4). */ + waitingSince?: string | null; + /** Когда назначили: от него считается срок личной очереди. */ + assignedAt?: string | null; group: { id: number; name: string; color?: string } | null; // Дизайн-базлайн v2: приоритет, метки, заметка, архив. priority: ConversationPriority; @@ -170,7 +180,23 @@ export function conversationName(conversation: ApiConversation): string { return t("conversations.guest"); } -export function toConversationListItem(conversation: ApiConversation): ConversationListItem { +/** «на вас · вернётся через 4 мин» — сколько осталось личной очереди. */ +export function assignedToMeLabel( + conversation: ApiConversation, + { viewerId, timeoutMinutes }: { viewerId: number | null; timeoutMinutes?: number }, + now = new Date(), +): string | null { + if (!conversation.isAssignedToViewer || conversation.controlMode !== "PAUSED") return null; + if (viewerId == null || !conversation.assignedAt || !timeoutMinutes) return t("conversations.on_you"); + const deadline = new Date(conversation.assignedAt).getTime() + timeoutMinutes * 60000; + const left = Math.round((deadline - now.getTime()) / 60000); + return left > 0 ? t("conversations.on_you_returns_in", { count: left }) : t("conversations.on_you"); +} + +export function toConversationListItem( + conversation: ApiConversation, + options: { viewerId?: number | null; assignmentTimeoutMinutes?: number } = {}, +): ConversationListItem { const name = conversationName(conversation); // avatarBg: стабильно из id контакта. const seed = conversation.contact?.id ?? conversation.id; @@ -198,10 +224,16 @@ export function toConversationListItem(conversation: ApiConversation): Conversat agentColor: agentColorOf(conversation.channel.id), groupName: conversation.group?.name ?? null, groupColor: conversation.group ? groupColorOf(conversation.group.id, conversation.group.color) : "var(--n-5)", + // Таймер считается с момента постановки в очередь: по последней активности + // клиент, напомнивший о себе, «ждал» бы заново с нуля. waitLabel: conversation.lifecycle === "OPEN" && conversation.controlMode === "PAUSED" - ? waitLabelOf(conversation.lastActivityAt) + ? waitLabelOf(conversation.waitingSince ?? conversation.lastActivityAt) : null, + mineLabel: assignedToMeLabel(conversation, { + viewerId: options.viewerId ?? null, + timeoutMinutes: options.assignmentTimeoutMinutes, + }), lastIsOurs: conversation.lastMessage?.author === "OPERATOR" || conversation.lastMessage?.author === "AI", lastIsVoice: conversation.lastMessage?.kind === "voice", }; @@ -214,6 +246,8 @@ export type ConversationListFilters = Partial<{ agent: number; // id канала-агента assigned: "me" | number; waiting: boolean; + queue: boolean; + waitingOnMe: boolean; lifecycle: "OPEN" | "CLOSED" | "SPAM"; archived: boolean; q: string; @@ -234,6 +268,8 @@ export const fetchConversations = (query: ConversationListQuery = {}) => { if (query.agent) params.set("agent", String(query.agent)); if (query.assigned) params.set("assigned", String(query.assigned)); if (query.waiting) params.set("waiting", "1"); + if (query.queue) params.set("queue", "1"); + if (query.waitingOnMe) params.set("waitingOnMe", "1"); if (query.lifecycle) params.set("lifecycle", query.lifecycle); if (query.archived) params.set("archived", "1"); if (query.q) params.set("q", query.q); @@ -274,7 +310,17 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers // Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для // назначения — доступен и сотруднику, у которого нет менеджерских списков. -export type ChatDirectoryEmployee = { id: number; name: string; avatarUrl?: string | null }; +export type ChatDirectoryEmployee = { + id: number; + name: string; + avatarUrl?: string | null; + role?: string; + /** Приложение открыто прямо сейчас. Признак приблизительный — см. Q5. */ + online?: boolean; + lastSeenAt?: string | null; + /** Сколько открытых диалогов уже на человеке: второй признак после присутствия. */ + openDialogs?: number; +}; export type ChatDirectory = { groups: Array<{ id: number; name: string; color?: string }>; diff --git a/apps/internal-ui/src/features/conversations/types.ts b/apps/internal-ui/src/features/conversations/types.ts index 4702f1d..0c4b0f1 100644 --- a/apps/internal-ui/src/features/conversations/types.ts +++ b/apps/internal-ui/src/features/conversations/types.ts @@ -3,7 +3,10 @@ export type DialogMode = "ai" | "closed" | "operator" | "wait"; export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting"; -export type ListTab = "all" | "mine" | "wait"; +// Два ожидания — разные списки (макет «Очередь и уведомления», кадр Q3): +// «queue» — диалоги без ответственного, взять может любой; «onMe» — +// назначенные лично и ждущие, пока их возьмут. +export type ListTab = "all" | "mine" | "queue" | "onMe"; // Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в // браузере было бы нечего. export type ListSort = "activity" | "waiting"; @@ -33,6 +36,8 @@ export type ConversationListItem = { groupName: string | null; groupColor: string; waitLabel: string | null; + /** «на вас · вернётся через 4 мин» — личная очередь (макет Q3). */ + mineLabel: string | null; lastIsOurs: boolean; // Последнее сообщение — голосовое: в превью иконка микрофона (макет). lastIsVoice: boolean; diff --git a/apps/internal-ui/src/features/conversations/useConversationEvents.ts b/apps/internal-ui/src/features/conversations/useConversationEvents.ts index 050139c..358391b 100644 --- a/apps/internal-ui/src/features/conversations/useConversationEvents.ts +++ b/apps/internal-ui/src/features/conversations/useConversationEvents.ts @@ -1,16 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; -import { resolveWebSocketUrl } from "../../api/client"; +import { useRealtime, useRealtimeEvent } from "../realtime/RealtimeProvider"; // Оповещения о диалогах: сервер сообщает, что изменилось, клиент забирает // данные обычным запросом. Поллинг остаётся запасным путём и замедляется, пока // сокет жив, — при обрыве всё работает ровно как раньше. - -const RECONNECT_MIN_MS = 1000; -const RECONNECT_MAX_MS = 30000; -// Поток событий в оживлённой организации плотнее прежнего опроса: несколько -// сообщений подряд должны приводить к одному обновлению, а не к пяти. -const COALESCE_MS = 700; +// +// Сам сокет живёт в оболочке (features/realtime): он общий с уведомлениями и +// обязан работать на любом экране, а не только там, где открыт чат. export type ConversationEvents = { /** Сокет открыт: поллинг можно замедлить. */ @@ -26,84 +23,18 @@ export function useConversationEvents({ onInboxChanged: () => void; onConversationChanged: (conversationId: number) => void; }): ConversationEvents { - const [connected, setConnected] = useState(false); - // Обработчики пересоздаются на каждый рендер — держим их в ref, чтобы сокет - // не переоткрывался вместе с ними. - const inboxRef = useRef(onInboxChanged); - inboxRef.current = onInboxChanged; - const conversationRef = useRef(onConversationChanged); - conversationRef.current = onConversationChanged; - const socketRef = useRef(null); - const watchedRef = useRef(null); - const pendingRef = useRef<{ inbox: boolean; conversation: number | null }>({ - inbox: false, - conversation: null, + const { connected, watch } = useRealtime(); + + useRealtimeEvent("inbox.changed", () => onInboxChanged()); + useRealtimeEvent("conversation.changed", (message) => { + if (typeof message.conversationId === "number") onConversationChanged(message.conversationId); }); - const flushTimerRef = useRef | null>(null); + // Открытый диалог сообщается сокету заново и после переподключения: подписка + // на него живёт в соединении, а не в реестре. useEffect(() => { - const url = resolveWebSocketUrl("/conversations/"); - if (!url) return; - let closed = false; - let retry = RECONNECT_MIN_MS; - let reconnectTimer: ReturnType | null = null; - - function open() { - if (closed) return; - const socket = new WebSocket(url as string); - socketRef.current = socket; - socket.onopen = () => { - retry = RECONNECT_MIN_MS; - setConnected(true); - // После обрыва подписка теряется вместе с сокетом — восстанавливаем её. - if (watchedRef.current != null) { - socket.send(JSON.stringify({ type: "watch", conversationId: watchedRef.current })); - } - }; - socket.onmessage = (event) => { - const payload = JSON.parse(String(event.data)) as { type?: string; conversationId?: number }; - if (payload.type === "inbox.changed") pendingRef.current.inbox = true; - if (payload.type === "conversation.changed" && typeof payload.conversationId === "number") { - pendingRef.current.conversation = payload.conversationId; - } - if (flushTimerRef.current !== null) return; - flushTimerRef.current = setTimeout(() => { - flushTimerRef.current = null; - const pending = pendingRef.current; - pendingRef.current = { inbox: false, conversation: null }; - if (pending.inbox) inboxRef.current(); - if (pending.conversation !== null) conversationRef.current(pending.conversation); - }, COALESCE_MS); - }; - socket.onclose = () => { - setConnected(false); - socketRef.current = null; - if (closed) return; - // Отступ растёт до полуминуты: сервер мог уйти на перезапуск. - reconnectTimer = setTimeout(open, retry); - retry = Math.min(retry * 2, RECONNECT_MAX_MS); - }; - socket.onerror = () => socket.close(); - } - - open(); - return () => { - closed = true; - if (reconnectTimer) clearTimeout(reconnectTimer); - if (flushTimerRef.current !== null) clearTimeout(flushTimerRef.current); - flushTimerRef.current = null; - socketRef.current?.close(); - socketRef.current = null; - }; - }, []); - - useEffect(() => { - watchedRef.current = conversationId; - const socket = socketRef.current; - if (socket && socket.readyState === WebSocket.OPEN && conversationId != null) { - socket.send(JSON.stringify({ type: "watch", conversationId })); - } - }, [conversationId, connected]); + watch(conversationId); + }, [watch, conversationId, connected]); return { connected }; } diff --git a/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts index fc0507a..a7d29be 100644 --- a/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts +++ b/apps/internal-ui/src/features/conversations/useEmployeeDirectory.ts @@ -29,7 +29,7 @@ export function useEmployeeDirectory(): EmployeeDirectory { fetchChatDirectory(settledQuery) .then((payload) => { if (!active) return; - setEmployees(payload.employees); + setEmployees(payload.employees ?? []); setHasMore(Boolean(payload.hasMoreEmployees)); }) .catch(() => { diff --git a/apps/internal-ui/src/features/notifications/browserNotifications.ts b/apps/internal-ui/src/features/notifications/browserNotifications.ts new file mode 100644 index 0000000..9060d12 --- /dev/null +++ b/apps/internal-ui/src/features/notifications/browserNotifications.ts @@ -0,0 +1,88 @@ +import type { AppNotification } from "./model"; + +// Системное уведомление браузера — то, что видно, когда вкладка свёрнута или +// закрыта другим окном. Работает без service worker и без Web Push: пока хоть +// одна вкладка приложения открыта, этого достаточно, а «браузер закрыт» в +// продукте закрывают боты в мессенджере. Web Push потребовал бы исходящего +// доступа установки к чужому push-сервису — на self-hosted это лишняя внешняя +// зависимость ради случая, который уже закрыт. + +export type BrowserNotificationState = "unsupported" | "default" | "granted" | "denied"; + +export function browserNotificationState(): BrowserNotificationState { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + return Notification.permission as BrowserNotificationState; +} + +/** Спрашивать разрешение можно только в ответ на действие человека: браузеры + * наказывают за запрос на загрузке, а отказ потом почти не отменить. */ +export async function requestBrowserNotifications(): Promise { + if (browserNotificationState() === "unsupported") return "unsupported"; + try { + return (await Notification.requestPermission()) as BrowserNotificationState; + } catch { + return browserNotificationState(); + } +} + +/** Человек смотрит на приложение: системное уведомление было бы лишним, ему + * хватит тоста в самом интерфейсе. */ +function userIsLooking(): boolean { + return document.visibilityState === "visible" && document.hasFocus(); +} + +export function showBrowserNotification( + notification: AppNotification, + onOpen: (notification: AppNotification) => void, +): void { + if (browserNotificationState() !== "granted" || userIsLooking()) return; + try { + const shown = new Notification(notification.title, { + body: notification.body, + // Одинаковый тег схлопывает уведомление во всех вкладках сразу: у + // оператора их обычно несколько, и три одинаковых оклика об одном + // диалоге — повод выключить уведомления совсем. + tag: `chatballs-notification-${notification.id}`, + renotify: false, + } as NotificationOptions); + shown.onclick = () => { + window.focus(); + onOpen(notification); + shown.close(); + }; + } catch { + /* уведомление — вспомогательный путь: его сбой ничего не ломает */ + } +} + +/** «Chrome · macOS» — чтобы человек понимал, о каком именно устройстве речь: + * разрешение своё в каждом браузере и на каждой машине. */ +export function browserAndOs(): string { + if (typeof navigator === "undefined") return ""; + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) + ? "Edge" + : /OPR\//.test(ua) + ? "Opera" + : /YaBrowser/.test(ua) + ? "Yandex" + : /Chrome\//.test(ua) + ? "Chrome" + : /Firefox\//.test(ua) + ? "Firefox" + : /Safari\//.test(ua) + ? "Safari" + : ""; + const os = /Windows/.test(ua) + ? "Windows" + : /Mac OS X/.test(ua) + ? "macOS" + : /Android/.test(ua) + ? "Android" + : /iPhone|iPad/.test(ua) + ? "iOS" + : /Linux/.test(ua) + ? "Linux" + : ""; + return [browser, os].filter(Boolean).join(" · "); +} diff --git a/apps/internal-ui/src/features/notifications/model.ts b/apps/internal-ui/src/features/notifications/model.ts index 72c855d..d917858 100644 --- a/apps/internal-ui/src/features/notifications/model.ts +++ b/apps/internal-ui/src/features/notifications/model.ts @@ -24,3 +24,21 @@ export const LEVEL_META: Record api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/"); export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) }); export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) }); + +export type NotificationTransport = "BROWSER" | "MESSENGER"; + +export type NotificationPreference = { + transport: NotificationTransport; + enabled: boolean; + types: string[]; + availableTypes: Array<{ code: string; label: string }>; +}; + +export const fetchPreference = (transport: NotificationTransport) => + api(`/api/v1/notifications/preferences/${transport}/`); + +export const savePreference = (transport: NotificationTransport, patch: { enabled?: boolean; types?: string[] }) => + api(`/api/v1/notifications/preferences/${transport}/`, { + method: "PATCH", + body: JSON.stringify(patch), + }); diff --git a/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts b/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts new file mode 100644 index 0000000..fc83c00 --- /dev/null +++ b/apps/internal-ui/src/features/notifications/useNotificationAlerts.ts @@ -0,0 +1,65 @@ +import { notification as antToast } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchPreference, type AppNotification, type NotificationPreference } from "./model"; +import { showBrowserNotification } from "./browserNotifications"; + +// Оклик о новом уведомлении: тост внутри вкладки и системное уведомление, если +// человек смотрит в другое место. +// +// Раньше тост показывался по росту счётчика непрочитанных. Счётчик — плохой +// признак события: три уведомления между опросами давали один тост, а +// прочтение в соседней вкладке роняло счётчик и глушило следующий. Считаем по +// идентификаторам: что человеку ещё не показывали, то и показываем. + +export function useNotificationAlerts({ + items, + onOpen, +}: { + items: AppNotification[]; + onOpen: (notification: AppNotification) => void; +}) { + const [preference, setPreference] = useState(null); + const shown = useRef | null>(null); + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + const preferenceRef = useRef(preference); + preferenceRef.current = preference; + + const reloadPreference = useCallback(async () => { + try { + setPreference(await fetchPreference("BROWSER")); + } catch { + /* без настройки системных уведомлений просто не будет */ + } + }, []); + + useEffect(() => { + void reloadPreference(); + }, [reloadPreference]); + + useEffect(() => { + // Первая загрузка только запоминает: накопленное за ночь не вываливают + // стопкой тостов в лицо тому, кто открыл приложение утром. + if (shown.current === null) { + shown.current = new Set(items.map((item) => item.id)); + return; + } + const seen = shown.current; + const fresh = items.filter((item) => item.unread && !seen.has(item.id)); + for (const item of items) seen.add(item.id); + if (fresh.length === 0) return; + + const newest = fresh[0]; + antToast.open({ message: newest.title, description: newest.body, placement: "bottomRight" }); + + const allowed = preferenceRef.current; + if (!allowed?.enabled) return; + for (const item of fresh) { + if (!allowed.types?.includes(item.type)) continue; + showBrowserNotification(item, (opened) => onOpenRef.current(opened)); + } + }, [items]); + + return { preference, reloadPreference }; +} diff --git a/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx b/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx new file mode 100644 index 0000000..663410c --- /dev/null +++ b/apps/internal-ui/src/features/profile/NotificationTypeChecks.tsx @@ -0,0 +1,37 @@ +import { Icon } from "../../shared/icons"; + +// Галочки типов событий. Один и тот же список у каждого транспорта: вопрос +// «о чём меня звать» один, и выглядеть он обязан одинаково — что у бота в +// мессенджере, что у браузера. + +export type NotificationTypeOption = { code: string; label: string }; + +export function NotificationTypeChecks({ + options, + selected, + onToggle, +}: { + options: NotificationTypeOption[]; + selected: string[]; + onToggle: (code: string) => void; +}) { + // Список приходит из ответа сервера: недостающее поле не повод уронить + // экран, на котором эта строка — лишь часть карточки. + if (!options || options.length === 0) return null; + return ( +
+ {options.map((option) => { + const on = selected.includes(option.code); + return ( + + ); + })} +
+ ); +} diff --git a/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx b/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx new file mode 100644 index 0000000..9917615 --- /dev/null +++ b/apps/internal-ui/src/features/profile/ProfileBrowserNotificationsRow.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + browserAndOs, + browserNotificationState, + requestBrowserNotifications, + type BrowserNotificationState, +} from "../notifications/browserNotifications"; +import { fetchPreference, savePreference, type NotificationPreference } from "../notifications/model"; +import { Icon, LogoSpinner } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { t } from "../../i18n"; +import { NotificationTypeChecks } from "./NotificationTypeChecks"; + +// Строка «Этот браузер» в карточке уведомлений (макет «Очередь и уведомления», +// кадры Q1 и Q1b) — того же вида, что строки ботов, и первой в списке: она +// включается одним нажатием и без внешнего аккаунта. +// +// Состояний три, и они не сводятся к «включено/выключено»: разрешение живёт в +// браузере и своё на каждом устройстве, а желание получать — на сервере. +// Отозвать выданное разрешение сайт не может, поэтому «Отключить» выключает +// именно желание; отказ снимается только руками в настройках браузера. + +export function ProfileBrowserNotificationsRow() { + // Состояние разрешения известно сразу: если узнавать его в эффекте, строка + // на первый кадр возвращает null и мигает на каждой перерисовке страницы. + const [permission, setPermission] = useState(browserNotificationState); + const [preference, setPreference] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + fetchPreference("BROWSER").then(setPreference).catch(() => undefined); + }, []); + + const apply = useCallback(async (patch: { enabled?: boolean; types?: string[] }) => { + setBusy(true); + try { + setPreference(await savePreference("BROWSER", patch)); + } catch { + /* ignore */ + } finally { + setBusy(false); + } + }, []); + + async function enable() { + // Разрешение спрашиваем ровно здесь — в ответ на клик: на загрузке страницы + // браузеры такой запрос наказывают, а отказ потом почти не отменить. + const granted = await requestBrowserNotifications(); + setPermission(granted); + if (granted === "granted") await apply({ enabled: true }); + } + + function toggleType(code: string) { + if (!preference) return; + const next = preference.types.includes(code) + ? preference.types.filter((item) => item !== code) + : [...preference.types, code]; + setPreference({ ...preference, types: next }); + void apply({ types: next }); + } + + if (permission === "unsupported") return null; + + const denied = permission === "denied"; + const on = permission === "granted" && Boolean(preference?.enabled); + const device = browserAndOs(); + const tile = denied + ? "integration-tile--blocked" + : on + ? "integration-tile--web" + : "integration-tile--muted"; + return ( +
+
+ + + +
+ {t("profile.this_browser")} + + {denied + ? device + : on + ? t("profile.browser_visible_when_minimized", { device }) + : t("profile.browser_not_enabled_yet")} + +
+ {denied ? ( + + + {t("profile.browser_blocked")} + + ) : !preference ? ( + + ) : on ? ( + <> + + + {t("profile.browser_enabled")} + + + + ) : ( + + )} +
+ {on && preference && ( + <> + +

{t("profile.browser_disable_note")}

+ + )} + {denied && ( +
+ +
+ {t("profile.browser_blocked_title")} +

{t("profile.browser_blocked_steps")}

+ {/* Разрешение меняют в браузере; приложению остаётся перечитать его. */} + +
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx b/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx index 9d45dd2..0c17507 100644 --- a/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx +++ b/apps/internal-ui/src/features/profile/ProfileNotificationsCard.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../../api/client"; import { Icon, LogoSpinner, MaxLogo, TelegramLogo } from "../../shared/icons"; +import { NotificationTypeChecks } from "./NotificationTypeChecks"; +import { ProfileBrowserNotificationsRow } from "./ProfileBrowserNotificationsRow"; import { Button } from "../../shared/ui-controls"; import { t } from "../../i18n"; @@ -31,8 +33,8 @@ export function ProfileNotificationsCard() { const reload = useCallback(async () => { try { const data = await fetchBindings(); - setItems(data.items); - setTypes(data.availableTypes); + setItems(data.items ?? []); + setTypes(data.availableTypes ?? []); for (const item of data.items) { if (item.bound || issuedFor.current.has(item.integrationId)) continue; issuedFor.current.add(item.integrationId); @@ -56,7 +58,7 @@ export function ProfileNotificationsCard() { useEffect(() => { if (!hasUnbound) return; const timer = window.setInterval(() => { - fetchBindings().then((data) => { setItems(data.items); setTypes(data.availableTypes); }).catch(() => undefined); + fetchBindings().then((data) => { setItems(data.items ?? []); setTypes(data.availableTypes ?? []); }).catch(() => undefined); }, 3000); return () => window.clearInterval(timer); }, [hasUnbound]); @@ -82,8 +84,9 @@ export function ProfileNotificationsCard() { return (
-

{t("profile.messenger_notifications")}

-

{t("profile.work_events_reach_even_with")}

+

{t("profile.work_event_notifications")}

+

{t("profile.choose_where_to_call_you")}

+ {loaded && items.length === 0 && (

{t("profile.no_notification_bots_configured_owner")}

)} @@ -109,19 +112,12 @@ export function ProfileNotificationsCard() { {loaded ? t("profile.preparing_link") : } )}
- {item.bound && types.length > 0 && ( -
- {types.map((type) => { - const on = item.pushTypes.includes(type.code); - return ( - - ); - })} -
+ {item.bound && ( + void toggleType(item, code)} + /> )} ))} diff --git a/apps/internal-ui/src/features/profile/ProfilePage.tsx b/apps/internal-ui/src/features/profile/ProfilePage.tsx index a16e93d..88691c5 100644 --- a/apps/internal-ui/src/features/profile/ProfilePage.tsx +++ b/apps/internal-ui/src/features/profile/ProfilePage.tsx @@ -23,7 +23,7 @@ import { t } from "../../i18n"; type MobileSection = "notifications" | "password" | "totp" | "sessions"; const MOBILE_TITLE: Record = { - notifications: t("profile.messenger_notifications"), + notifications: t("profile.work_event_notifications"), password: t("profile.change_password"), totp: t("profile.two_factor_authentication"), sessions: t("common.active_sessions"), diff --git a/apps/internal-ui/src/features/profile/model.ts b/apps/internal-ui/src/features/profile/model.ts index 5beebd4..562c17b 100644 --- a/apps/internal-ui/src/features/profile/model.ts +++ b/apps/internal-ui/src/features/profile/model.ts @@ -14,8 +14,11 @@ export type ProfileSession = { current: boolean; }; +// Ответ без items — это сломанный ответ, а не пустой список, но карточка не то +// место, где об этом узнают: undefined вместо массива роняет её на items.length +// и уносит вместе с ней всю страницу профиля — пароль, двухфакторку и сессии. export const fetchProfileSessions = () => - api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items); + api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items ?? []); // «сейчас» · «2 ч назад» · «3 дня назад» — подпись справа в строке сессии. export function lastSeenLabel(value: string | null, now = new Date()): string { diff --git a/apps/internal-ui/src/features/profile/styles.css b/apps/internal-ui/src/features/profile/styles.css index d0f5285..6719c65 100644 --- a/apps/internal-ui/src/features/profile/styles.css +++ b/apps/internal-ui/src/features/profile/styles.css @@ -920,3 +920,65 @@ margin-left: 0; } } + +/* Строка транспорта по состоянию (макет «Очередь и уведомления», кадр Q1b): + плитка и статус меняют цвет вместе со смыслом — серое «не включено», + акцентное «работает», красное «браузер запретил». */ +.integration-tile--muted { + color: var(--n-4); + background: var(--n-9); +} + +.integration-tile--blocked { + color: var(--error-text); + background: var(--error-bg); +} + +.profile-notifications-status.is-blocked { + background: var(--error-bg); + color: var(--error-text); +} + +/* Отказ браузера снимается только руками, поэтому рядом с ним не кнопка + «включить», а шаги: где нажать и что искать. */ +.profile-notifications-hint { + display: flex; + gap: 10px; + margin: 12px 0 0 46px; + padding: 10px 12px; + border-radius: 9px; + background: var(--n-10, var(--surface-card)); + border: 1px solid var(--n-9); +} + +.profile-notifications-hint > span { + flex: none; + margin-top: 1px; + color: var(--n-4); +} + +.profile-notifications-hint strong { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--n-2); +} + +.profile-notifications-hint p { + margin: 4px 0 0; + font-size: 12px; + color: var(--n-4); + line-height: 1.5; +} + +.profile-notifications-hint button { + margin-top: 9px; +} + +/* Примечание под включённой строкой: «Отключить» гасит показ, а не разрешение. */ +.profile-notifications-aside { + margin: 10px 0 0 46px; + font-size: 11.5px; + color: var(--n-4); + line-height: 1.45; +} diff --git a/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx b/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx new file mode 100644 index 0000000..ac7eb78 --- /dev/null +++ b/apps/internal-ui/src/features/realtime/RealtimeProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { resolveWebSocketUrl } from "../../api/client"; +import { RealtimeConnection } from "./connection"; +import { RealtimeSubscribers, type RealtimeHandler } from "./subscribers"; + +// Сокет живёт на уровне оболочки, а не страницы чата. Раньше он принадлежал +// рабочей области диалогов, и вне чата живых событий не было вовсе: на любом +// другом экране оператор узнавал о новом уведомлении только следующим опросом. + +type Realtime = { + connected: boolean; + subscribe: (type: string, handler: RealtimeHandler) => () => void; + watch: (conversationId: number | null) => void; +}; + +const RealtimeContext = createContext(null); + +export function RealtimeProvider({ children }: { children: ReactNode }) { + const [connected, setConnected] = useState(false); + // Реестр создаётся раз и навсегда: подписки оформляются раньше, чем откроется + // сокет, и переживают его переподключения. + const subscribersRef = useRef(null); + if (subscribersRef.current === null) subscribersRef.current = new RealtimeSubscribers(); + const subscribers = subscribersRef.current; + const connectionRef = useRef(null); + + useEffect(() => { + const url = resolveWebSocketUrl("/conversations/"); + if (!url) return; + const connection = new RealtimeConnection(url, (message) => subscribers.dispatch(message), setConnected); + connectionRef.current = connection; + connection.open(); + return () => { + connection.close(); + connectionRef.current = null; + setConnected(false); + }; + }, [subscribers]); + + // subscribe и watch не зависят от состояния соединения: иначе каждый обрыв + // переподписывал бы всех подписчиков заново. + const subscribe = useCallback( + (type: string, handler: RealtimeHandler) => subscribers.subscribe(type, handler), + [subscribers], + ); + const watch = useCallback((conversationId: number | null) => connectionRef.current?.watch(conversationId), []); + const value = useMemo( + () => ({ connected, subscribe, watch }), + [connected, subscribe, watch], + ); + + return {children}; +} + +export function useRealtime(): Realtime { + const value = useContext(RealtimeContext); + if (value === null) throw new Error("useRealtime is only available inside RealtimeProvider"); + return value; +} + +/** Подписка на один тип события. Обработчик держится в ref: пересоздание + * функции на каждом рендере не должно переподписывать сокет. */ +export function useRealtimeEvent(type: string, handler: RealtimeHandler): void { + const { subscribe } = useRealtime(); + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => subscribe(type, (message) => handlerRef.current(message)), [subscribe, type]); +} diff --git a/apps/internal-ui/src/features/realtime/connection.ts b/apps/internal-ui/src/features/realtime/connection.ts new file mode 100644 index 0000000..7182298 --- /dev/null +++ b/apps/internal-ui/src/features/realtime/connection.ts @@ -0,0 +1,104 @@ +// Сокет рабочего места: один на сессию, по нему идут события и диалогов, и +// уведомлений. Второй сокет ради второго источника означал бы второе +// переподключение и вторую точку отказа на ровном месте. +// +// Здесь только провод: подключение, переподключение с отступом и склейка частых +// событий. Кто на что подписан — знает реестр (subscribers.ts), и знает +// независимо от того, открыт сокет или нет. + +const RECONNECT_MIN_MS = 1000; +const RECONNECT_MAX_MS = 30000; +// Поток событий в оживлённой организации плотнее прежнего опроса: несколько +// сообщений подряд должны приводить к одному обновлению, а не к пяти. +const COALESCE_MS = 700; +// Раз в минуту сервер узнаёт, что рабочее место живо: по этому признаку +// очередь решает, есть ли кому заметить ждущий диалог. Без heartbeat +// оборванное соединение считалось бы присутствием до истечения ключа. +const HEARTBEAT_MS = 60000; + +export type RealtimeMessage = { type: string; conversationId?: number }; + +export class RealtimeConnection { + private socket: WebSocket | null = null; + private closed = false; + private retry = RECONNECT_MIN_MS; + private reconnectTimer: ReturnType | null = null; + private flushTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + /** Последнее событие каждого типа: за окно склейки важно только оно. */ + private pending = new Map(); + private watched: number | null = null; + + constructor( + private readonly url: string, + private readonly onMessage: (message: RealtimeMessage) => void, + private readonly onConnectedChange: (connected: boolean) => void, + ) {} + + open(): void { + if (this.closed) return; + const socket = new WebSocket(this.url); + this.socket = socket; + socket.onopen = () => { + this.retry = RECONNECT_MIN_MS; + this.onConnectedChange(true); + this.startHeartbeat(); + // После обрыва подписка теряется вместе с сокетом — восстанавливаем её. + if (this.watched !== null) this.send({ type: "watch", conversationId: this.watched }); + }; + socket.onmessage = (event) => this.receive(String(event.data)); + socket.onclose = () => { + this.onConnectedChange(false); + this.stopHeartbeat(); + this.socket = null; + if (this.closed) return; + // Отступ растёт до полуминуты: сервер мог уйти на перезапуск. + this.reconnectTimer = setTimeout(() => this.open(), this.retry); + this.retry = Math.min(this.retry * 2, RECONNECT_MAX_MS); + }; + socket.onerror = () => socket.close(); + } + + close(): void { + this.closed = true; + this.stopHeartbeat(); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.flushTimer !== null) clearTimeout(this.flushTimer); + this.flushTimer = null; + this.socket?.close(); + this.socket = null; + } + + /** Какой диалог открыт: событий по нему клиент и ждёт. */ + watch(conversationId: number | null): void { + this.watched = conversationId; + if (conversationId !== null) this.send({ type: "watch", conversationId }); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + private send(payload: object): void { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(payload)); + } + + private receive(data: string): void { + const message = JSON.parse(data) as RealtimeMessage; + if (!message.type) return; + this.pending.set(message.type, message); + if (this.flushTimer !== null) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + const flushed = [...this.pending.values()]; + this.pending.clear(); + for (const item of flushed) this.onMessage(item); + }, COALESCE_MS); + } +} diff --git a/apps/internal-ui/src/features/realtime/subscribers.ts b/apps/internal-ui/src/features/realtime/subscribers.ts new file mode 100644 index 0000000..bbbd9ea --- /dev/null +++ b/apps/internal-ui/src/features/realtime/subscribers.ts @@ -0,0 +1,25 @@ +import type { RealtimeMessage } from "./connection"; + +// Реестр подписчиков живёт отдельно от сокета и переживает его обрывы: подписка +// оформляется в момент монтирования компонента, а сокет к этому моменту может +// быть ещё не открыт или уже переподключаться. Связывать одно с другим — значит +// терять события на каждом обрыве. + +export type RealtimeHandler = (message: RealtimeMessage) => void; + +export class RealtimeSubscribers { + private handlers = new Map>(); + + subscribe(type: string, handler: RealtimeHandler): () => void { + const forType = this.handlers.get(type) ?? new Set(); + forType.add(handler); + this.handlers.set(type, forType); + return () => { + forType.delete(handler); + }; + } + + dispatch(message: RealtimeMessage): void { + for (const handler of this.handlers.get(message.type) ?? []) handler(message); + } +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context.css b/apps/internal-ui/src/features/sales/dialogs/context.css index eed9361..7e56155 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context.css +++ b/apps/internal-ui/src/features/sales/dialogs/context.css @@ -1367,3 +1367,114 @@ width: 100%; margin: 2px 0 6px; } + +/* Выбор ответственного с присутствием (макет «Очередь и уведомления», кадр Q5). + Заголовки групп и сноска — существующие элементы меню; здесь только то, чего + в нём ещё не было: точка присутствия, подпись под именем и загрузка справа. + Точка — подсказка, а не запрет: отсутствующий выбирается так же, как все. */ +.ctx-avatar-small { + position: relative; + /* Точка висит на краю кружка, поэтому обрезать содержимое нельзя. */ + overflow: visible; +} + +.ctx-avatar-small.has-photo img { + border-radius: 50%; +} + +.assignee-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #52c41a; + border: 2px solid var(--surface-card); +} + +.ctx-select .assignee-dot { + position: static; + flex: none; + border: none; + width: 7px; + height: 7px; +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option { + align-items: center; +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-who { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.25; + text-align: left; + overflow: hidden; +} + +.assignee-who > span, +.assignee-who small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assignee-who small { + font-size: 11px; + color: var(--n-4); +} + +.assignee-who small.is-here { + color: var(--success-text); +} + +.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-load { + flex: none; + margin-left: 6px; + font-size: 11px; + color: var(--n-5); + white-space: nowrap; +} + +/* Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4): + сколько клиент ждёт и почему диалог виден, но брать его не нужно. */ +.ctx-waiting { + padding: 12px; + margin-bottom: 14px; + border-radius: 9px; + background: var(--warning-bg); + border: 1px solid var(--warning-border); +} + +.ctx-waiting-head { + display: flex; + align-items: center; + gap: 8px; + color: var(--warning-text); +} + +.ctx-waiting-head strong { + font-size: 12.5px; + font-weight: 700; +} + +.ctx-waiting-assignee { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--warning-border); + font-size: 12.5px; + color: var(--n-2); +} + +.ctx-waiting p { + margin: 6px 0 0; + font-size: 11.5px; + color: var(--warning-text); + line-height: 1.45; +} diff --git a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx index c3355fa..9f670a7 100644 --- a/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx +++ b/apps/internal-ui/src/features/sales/dialogs/context/ClientContext.tsx @@ -22,6 +22,7 @@ export function ClientContext({ applyConversation, startCall, viewerId = null, + assignmentTimeoutMinutes, }: { dialog: ConversationListItem | null; detail: ApiConversation | null; @@ -29,6 +30,7 @@ export function ClientContext({ applyConversation?: (updated: ApiConversation) => void; startCall?: ((kind: "AUDIO" | "VIDEO") => void) | null; viewerId?: number | null; + assignmentTimeoutMinutes?: number; }) { const [requesting, setRequesting] = useState(false); const [justRequested, setJustRequested] = useState(false); @@ -130,7 +132,7 @@ export function ClientContext({ {detail && applyConversation && ( - + )} ); diff --git a/apps/internal-ui/src/features/sales/dialogs/list.css b/apps/internal-ui/src/features/sales/dialogs/list.css index d5aab5d..4d2c71a 100644 --- a/apps/internal-ui/src/features/sales/dialogs/list.css +++ b/apps/internal-ui/src/features/sales/dialogs/list.css @@ -515,3 +515,12 @@ img.sales-dialog-avatar { .hub-hint button:hover { background: rgba(0, 0, 0, 0.06); } + +/* «на вас · вернётся через N мин» (макет «Очередь и уведомления», кадр Q3): + личная очередь — не то же, что общая, и цвет у неё акцентный, а не тревожный: + диалог не брошен, он ждёт конкретного человека. */ +.sales-dialog-badge.is-mine { + background: var(--primary-bg); + border-color: var(--primary-border, var(--primary-bg)); + color: var(--primary-text); +} diff --git a/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx b/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx new file mode 100644 index 0000000..bf11ec4 --- /dev/null +++ b/apps/internal-ui/src/features/settings/QueuePolicyCard.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; + +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { fmt, t } from "../../i18n"; +import { + QUEUE_POLICY_FIELDS, + fetchQueuePolicy, + saveQueuePolicy, + type QueuePolicy, + type QueuePolicyField, +} from "./queuePolicy"; + +// Раздел «Когда звать на помощь» (макет «Очередь и уведомления», кадр Q2). +// Сроки действуют на всю организацию и считаются с момента, когда диалог встал +// в очередь. Правит их тот, кто управляет настройками; остальным сервер +// отвечает отказом, поэтому карточка просто не покажется. + +type Draft = Record; + +const draftOf = (policy: QueuePolicy): Draft => + Object.fromEntries(QUEUE_POLICY_FIELDS.map(({ key }) => [key, String(policy[key])])) as Draft; + +export function QueuePolicyCard({ canManage }: { canManage: boolean }) { + const [policy, setPolicy] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetchQueuePolicy() + .then((loaded) => { + setPolicy(loaded); + setDraft(draftOf(loaded)); + }) + .catch(() => setError(t("settings.queue_policy_unavailable"))); + }, []); + + async function save(values: Partial>) { + setBusy(true); + setError(""); + try { + const saved = await saveQueuePolicy(values); + setPolicy(saved); + setDraft(draftOf(saved)); + } catch (problem) { + setError(problem instanceof Error ? problem.message : t("settings.queue_policy_unavailable")); + } finally { + setBusy(false); + } + } + + if (error && !policy) return

{error}

; + if (!policy || !draft) return null; + + const changed = QUEUE_POLICY_FIELDS.some(({ key }) => draft[key] !== String(policy[key])); + const isDefault = QUEUE_POLICY_FIELDS.every(({ key }) => policy[key] === policy.defaults[key]); + + return ( + <> +
+ {QUEUE_POLICY_FIELDS.map(({ key, title, hint }) => ( +
+
+ {title} + {hint} +
+ +
+ ))} +
+ + {policy.updatedAt + ? t("settings.queue_saved_by", { + when: `${fmt.shortDate(new Date(policy.updatedAt))}, ${fmt.time(new Date(policy.updatedAt))}`, + who: policy.updatedBy, + }) + : t("settings.queue_never_changed")} + + {canManage && ( +
+ + +
+ )} +
+
+ {error &&

{error}

} +
+ +

{t("settings.queue_delivery_warning")}

+
+ + ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index 5344ce1..90f5493 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard"; import { PlatformSettingsCard } from "./PlatformSettingsCard"; import { StorageSettingsCard } from "./StorageSettingsCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; +import { QueuePolicyCard } from "./QueuePolicyCard"; import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections"; import { useIntegrations } from "./useIntegrations"; import { useOnboarding } from "../onboarding/useOnboarding"; @@ -179,6 +180,7 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration }): ReactNode { if (section === "organization") return ; if (section === "groups") return ; + if (section === "queue") return ; if (section === "communication") return ; if (section === "platform") return ; if (section === "storage") return ; diff --git a/apps/internal-ui/src/features/settings/queuePolicy.ts b/apps/internal-ui/src/features/settings/queuePolicy.ts new file mode 100644 index 0000000..9ead83c --- /dev/null +++ b/apps/internal-ui/src/features/settings/queuePolicy.ts @@ -0,0 +1,50 @@ +import { api } from "../../api/client"; +import { t } from "../../i18n"; + +// Сроки очереди (макет «Очередь и уведомления», кадр Q2). Четыре числа в +// минутах: через сколько напомнить группе, позвать всех, сообщить руководителю +// и сколько ждём назначенного сотрудника. + +export type QueuePolicyField = + | "remind_after_minutes" + | "widen_after_minutes" + | "escalate_after_minutes" + | "assignment_timeout_minutes"; + +export type QueuePolicy = Record & { + defaults: Record; + updatedAt: string | null; + updatedBy: string; +}; + +/** Порядок — как в макете: от первого напоминания к последнему средству. */ +export const QUEUE_POLICY_FIELDS: Array<{ key: QueuePolicyField; title: string; hint: string }> = [ + { + key: "remind_after_minutes", + title: t("settings.queue_remind_group"), + hint: t("settings.queue_remind_group_hint"), + }, + { + key: "widen_after_minutes", + title: t("settings.queue_call_everyone"), + hint: t("settings.queue_call_everyone_hint"), + }, + { + key: "escalate_after_minutes", + title: t("settings.queue_tell_manager"), + hint: t("settings.queue_tell_manager_hint"), + }, + { + key: "assignment_timeout_minutes", + title: t("settings.queue_wait_for_assignee"), + hint: t("settings.queue_wait_for_assignee_hint"), + }, +]; + +export const fetchQueuePolicy = () => api("/api/v1/conversations/queue-policy/"); + +export const saveQueuePolicy = (patch: Partial>) => + api("/api/v1/conversations/queue-policy/", { + method: "PATCH", + body: JSON.stringify(patch), + }); diff --git a/apps/internal-ui/src/features/settings/sections.ts b/apps/internal-ui/src/features/settings/sections.ts index c7b7a59..a8b26a8 100644 --- a/apps/internal-ui/src/features/settings/sections.ts +++ b/apps/internal-ui/src/features/settings/sections.ts @@ -9,6 +9,7 @@ import { t } from "../../i18n"; export type SettingsSectionKey = | "organization" | "groups" + | "queue" | "ai" | "integrations" | "communication" @@ -43,6 +44,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ heading: t("common.groups"), lead: t("settings.they_split_conversations_between_operators"), }, + { + key: "queue", + label: t("settings.when_to_call_for_help"), + icon: "clock", + heading: t("settings.when_to_call_for_help"), + lead: t("settings.queue_section_lead"), + }, { key: "ai", label: t("settings.ai_provider"), diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index b25c9f2..4b009cc 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -937,3 +937,110 @@ grid-template-columns: minmax(0, 1fr) 72px 72px 72px; } } + +/* Сроки очереди (макет «Очередь и уведомления», кадр Q2): строка — правило, + поле — число в минутах, единица вынесена из поля. */ +.queue-policy-card { + background: var(--surface-card); + border: 1px solid var(--n-8); + border-radius: 12px; + padding: 6px 24px 20px; +} + +.queue-policy-row { + display: flex; + align-items: center; + gap: 20px; + padding: 16px 0; + border-bottom: 1px solid var(--n-9); +} + +.queue-policy-row > div { + flex: 1; + min-width: 0; +} + +.queue-policy-row strong { + display: block; + font-size: 13.5px; + font-weight: 600; + color: var(--n-1); +} + +.queue-policy-row small { + display: block; + margin-top: 3px; + font-size: 12px; + color: var(--n-4); + line-height: 1.5; +} + +.queue-policy-value { + flex: none; + display: flex; + align-items: center; + gap: 8px; +} + +.queue-policy-value input { + height: 36px; + width: 86px; + padding: 0 12px; + border: 1px solid var(--n-7); + border-radius: 8px; + background: var(--n-10, var(--surface-card)); + color: var(--n-1); + font: inherit; + font-size: 13.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.queue-policy-value span { + width: 40px; + font-size: 12.5px; + color: var(--n-4); +} + +.queue-policy-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 18px; +} + +.queue-policy-footer small { + font-size: 12px; + color: var(--n-4); +} + +.queue-policy-actions { + display: flex; + gap: 10px; +} + +/* Сроки бесполезны, если сотруднику некуда прийти уведомлению. */ +.queue-policy-warning { + display: flex; + gap: 11px; + margin-top: 18px; + padding: 14px 16px; + border-radius: 10px; + background: var(--warning-bg); + border: 1px solid var(--warning-border, var(--warning-bg)); +} + +.queue-policy-warning span { + flex: none; + margin-top: 1px; + color: var(--warning-text); +} + +.queue-policy-warning p { + margin: 0; + font-size: 12.5px; + color: var(--warning-text); + line-height: 1.55; +} diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index bd58696..8c394bc 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -529,6 +529,7 @@ export const en: Record = { "common.connected": "Connected", "common.connections": "Connections", "common.contact": "Contact", + "common.enabled": "Enabled", "common.contacts": "Contacts", "common.contains_digit": "Contains a digit", "common.conversations": "Conversations", @@ -747,6 +748,25 @@ export const en: Record = { "conversations.transcribe": "Transcribe", "conversations.transcribing": "Transcribing…", "conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template", + "conversations.common_queue": "Common queue", + "conversations.on_me": "On me", + "conversations.queue_tabs_note": "“Common queue” holds conversations with no assignee: anyone can take them. “On me” are assigned to you personally and wait until you take them.", + "conversations.on_you_returns_in": "on you · returns in {count} min", + "conversations.on_you": "on you", + "conversations.waiting_for_person": "Waiting for a person {duration}", + "conversations.assigned_not_taken": "Assigned to {name}, not taken", + "conversations.returns_to_queue_in": "If they do not take it, the conversation returns to the common queue in {duration}.", + "conversations.in_queue_since": "In the queue since", + "conversations.presence_here": "Here now", + "conversations.presence_away": "Away", + "conversations.presence_note": "“Here now” means the app is open. The sign is approximate: anyone can be assigned.", + "conversations.here": "here now", + "conversations.here_owner": "here now · owner", + "conversations.away_minutes": "away · {count} min", + "conversations.away_hours": "away · {count} h", + "conversations.away_long": "away for over a day", + "conversations.never_in_app": "has not signed in yet", + "conversations.open_dialogs": "{count} conv.", "conversations.unassigned": "Unassigned", "conversations.update_browser_or_open_link": "Update your browser or open the link in Chrome, Safari or Edge.", "conversations.video_call": "video call", @@ -763,6 +783,7 @@ export const en: Record = { "plural.categories": { one: "{count} category", other: "{count} categories" }, "plural.chunks": { one: "{count} chunk", other: "{count} chunks" }, "plural.conversations": { one: "{count} conversation", other: "{count} conversations" }, + "plural.minutes": { one: "{count} minute", other: "{count} minutes" }, "plural.devices": { one: "{count} device", other: "{count} devices" }, "plural.documents": { one: "{count} document", other: "{count} documents" }, "plural.events": { one: "{count} event", other: "{count} events" }, @@ -877,8 +898,8 @@ export const en: Record = { "portals.import_2": "Import", "portals.import_articles_from_yaml": "Import articles from YAML", "portals.importing": "Importing…", - "portals.insert_image": "Insert an image", "portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.", + "portals.insert_image": "Insert an image", "portals.insert_into_text": "Insert into the text", "portals.inserted_into_text": "· inserted into the text", "portals.invalid_yaml": "Invalid YAML", @@ -1228,6 +1249,15 @@ export const en: Record = { "profile.current": "current", "profile.current_password": "Current password", "profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #0f9b8e", + "profile.browser_blocked": "Blocked by the browser", + "profile.browser_blocked_steps": "Click the lock icon left of the address → “Notifications” → “Allow”, then reload the page. The site cannot lift the block itself.", + "profile.browser_blocked_title": "Only the browser can lift this block", + "profile.browser_disable_note": "“Turn off” stops notifications in this browser. The permission itself stays granted — only your browser settings can revoke it.", + "profile.browser_enabled": "On", + "profile.browser_not_enabled_yet": "Off. The browser will ask for permission when you click.", + "profile.browser_visible_when_minimized": "{device} — visible while the tab is minimised", + "profile.check_again": "Check again", + "profile.enable": "Enable", "profile.dark": "Dark", "profile.disconnect": "Disconnect", "profile.drag_resize_double_click_reset": "Drag to resize, double-click to reset", @@ -1285,7 +1315,8 @@ export const en: Record = { "profile.link_bot": "Link a bot", "profile.match_system": "Match the system", "profile.menu": "Menu", - "profile.messenger_notifications": "Messenger notifications", + "profile.this_browser": "This browser", + "profile.work_event_notifications": "Work event notifications", "profile.min_10_characters": "min. 10 characters", "profile.no_notification_bots_configured_owner": "No notification bots are configured. The owner can add one under Settings → Integrations (the “Notification bot for operators” checkbox).", "profile.off_strongly_recommended_owner_administrators": "Off. Strongly recommended for the owner and administrators.", @@ -1314,7 +1345,7 @@ export const en: Record = { "profile.updating": "Updating", "profile.user_menu": "User menu", "profile.without_second_factor_access_organization": "Without a second factor, access to the organization rests on the password alone", - "profile.work_events_reach_even_with": "Work events reach you even with the browser closed. One messenger at a time: linking a new one replaces the current.", + "profile.choose_where_to_call_you": "Choose where to reach you. The browser — on this device, while the app is open or minimised. A messenger — anywhere, including with the browser closed; one messenger at a time: linking a new one replaces the current.", "sales.all_agents": "All agents", "sales.all_channels": "All channels", "sales.anonymous_session": "{channel} · {connection}, anonymous session", @@ -1407,6 +1438,22 @@ export const en: Record = { "settings.address_hint": "Until you have a domain, this holds the server address — the one you ran the first-start wizard on. Once you have a domain and put TLS in front of the installation, enter it here and switch to https. Links will look like", "settings.address_placeholder": "address", "settings.addressing": "Addressing", + "settings.when_to_call_for_help": "When to call for help", + "settings.queue_section_lead": "When a customer is waiting for a person, we remind about the conversation — the group first, then everyone, then a manager. The delays count from the moment the conversation joined the queue and apply to the whole organisation.", + "settings.queue_remind_group": "Remind the group the conversation is waiting", + "settings.queue_remind_group_hint": "The notification goes to the group the conversation belongs to.", + "settings.queue_call_everyone": "Call everyone if it is still waiting", + "settings.queue_call_everyone_hint": "The notification goes to every employee, not just the group.", + "settings.queue_tell_manager": "Tell a manager", + "settings.queue_tell_manager_hint": "The owner and admins — it means the usual people did not pick it up.", + "settings.queue_wait_for_assignee": "How long we wait for the assigned employee", + "settings.queue_wait_for_assignee_hint": "If they do not take it in time, the conversation goes back to the common queue and is visible to everyone again.", + "settings.queue_minutes_unit": "min", + "settings.queue_saved_by": "Saved {when} · {who}", + "settings.queue_never_changed": "The delays have never been changed", + "settings.queue_restore_defaults": "Restore the usual delays", + "settings.queue_policy_unavailable": "Could not load the delays", + "settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.", "settings.ai_provider": "AI provider", "settings.allowed_domains": "Allowed domains", "settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index f14b6b1..6c7b60a 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -530,6 +530,7 @@ export const ru = { "common.connected": "Подключено", "common.connections": "Подключения", "common.contact": "Контакт", + "common.enabled": "Включено", "common.contacts": "Контакты", "common.contains_digit": "Содержит цифру", "common.conversations": "Диалоги", @@ -748,6 +749,25 @@ export const ru = { "conversations.transcribe": "Расшифровать", "conversations.transcribing": "Расшифровываем…", "conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа", + "conversations.common_queue": "Общая очередь", + "conversations.on_me": "На мне", + "conversations.queue_tabs_note": "«Общая очередь» — диалоги без ответственного: взять может любой. «На мне» — назначены лично вам и ждут, пока вы их возьмёте.", + "conversations.on_you_returns_in": "на вас · вернётся через {count} мин", + "conversations.on_you": "на вас", + "conversations.waiting_for_person": "Ждёт человека {duration}", + "conversations.assigned_not_taken": "Назначен на {name}, не взят", + "conversations.returns_to_queue_in": "Если не возьмёт — через {duration} диалог вернётся в общую очередь.", + "conversations.in_queue_since": "В очереди с", + "conversations.presence_here": "На месте", + "conversations.presence_away": "Сейчас нет", + "conversations.presence_note": "«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.", + "conversations.here": "на месте", + "conversations.here_owner": "на месте · владелец", + "conversations.away_minutes": "не в приложении · {count} мин", + "conversations.away_hours": "не в приложении · {count} ч", + "conversations.away_long": "не в приложении больше суток", + "conversations.never_in_app": "ещё не заходил", + "conversations.open_dialogs": "{count} диал.", "conversations.unassigned": "Не назначен", "conversations.update_browser_or_open_link": "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.", "conversations.video_call": "видеозвонок", @@ -764,6 +784,7 @@ export const ru = { "plural.categories": { one: "{count} категория", few: "{count} категории", many: "{count} категорий", other: "{count} категорий" }, "plural.chunks": { one: "{count} фрагмент", few: "{count} фрагмента", many: "{count} фрагментов", other: "{count} фрагментов" }, "plural.conversations": { one: "{count} диалог", few: "{count} диалога", many: "{count} диалогов", other: "{count} диалогов" }, + "plural.minutes": { one: "{count} минуту", few: "{count} минуты", many: "{count} минут", other: "{count} минут" }, "plural.devices": { one: "{count} устройство", few: "{count} устройства", many: "{count} устройств", other: "{count} устройств" }, "plural.documents": { one: "{count} документ", few: "{count} документа", many: "{count} документов", other: "{count} документов" }, "plural.events": { one: "{count} событие", few: "{count} события", many: "{count} событий", other: "{count} событий" }, @@ -878,8 +899,8 @@ export const ru = { "portals.import_2": "Импорт", "portals.import_articles_from_yaml": "Импорт статей из YAML", "portals.importing": "Импорт…", - "portals.insert_image": "Вставить изображение", "portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.", + "portals.insert_image": "Вставить изображение", "portals.insert_into_text": "Вставить в текст", "portals.inserted_into_text": "· вставлен в текст", "portals.invalid_yaml": "Невалидный YAML", @@ -1229,6 +1250,15 @@ export const ru = { "profile.current": "текущая", "profile.current_password": "Текущий пароль", "profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #0f9b8e", + "profile.browser_blocked": "Браузер запретил", + "profile.browser_blocked_steps": "Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.", + "profile.browser_blocked_title": "Снять запрет можно только в браузере", + "profile.browser_disable_note": "«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.", + "profile.browser_enabled": "Включены", + "profile.browser_not_enabled_yet": "Не включены. Браузер спросит разрешение по нажатию.", + "profile.browser_visible_when_minimized": "{device} — видно, когда вкладка свёрнута", + "profile.check_again": "Проверить снова", + "profile.enable": "Включить", "profile.dark": "Тёмная", "profile.disconnect": "Отключить", "profile.drag_resize_double_click_reset": "Потяните, двойной клик — сбросить", @@ -1286,7 +1316,8 @@ export const ru = { "profile.link_bot": "Привязать бота", "profile.match_system": "Как в системе", "profile.menu": "Меню", - "profile.messenger_notifications": "Уведомления в мессенджер", + "profile.this_browser": "Этот браузер", + "profile.work_event_notifications": "Уведомления о рабочих событиях", "profile.min_10_characters": "мин. 10 символов", "profile.no_notification_bots_configured_owner": "Боты уведомлений не настроены. Владелец может добавить бота в «Настройках» → «Интеграции» (флажок «Бот уведомлений для сотрудников»).", "profile.off_strongly_recommended_owner_administrators": "Отключена. Для владельца и администраторов настоятельно рекомендуется включить.", @@ -1315,7 +1346,7 @@ export const ru = { "profile.updating": "Обновление", "profile.user_menu": "Меню пользователя", "profile.without_second_factor_access_organization": "Без второго фактора доступ к организации защищён только паролем", - "profile.work_events_reach_even_with": "Рабочие события приходят и при закрытом браузере. Один мессенджер: привязка нового заменяет текущий.", + "profile.choose_where_to_call_you": "Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.", "sales.all_agents": "Все агенты", "sales.all_channels": "Все каналы", "sales.anonymous_session": "{channel} · {connection}, анонимная сессия", @@ -1408,6 +1439,22 @@ export const ru = { "settings.address_hint": "Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. Ссылки будут вида", "settings.address_placeholder": "адрес", "settings.addressing": "Адресация", + "settings.when_to_call_for_help": "Когда звать на помощь", + "settings.queue_section_lead": "Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.", + "settings.queue_remind_group": "Напомнить группе, что диалог ждёт", + "settings.queue_remind_group_hint": "Уведомление получат сотрудники группы, за которой закреплён диалог.", + "settings.queue_call_everyone": "Позвать всех, если диалог всё ещё ждёт", + "settings.queue_call_everyone_hint": "Уведомление уйдёт всем сотрудникам, а не только группе.", + "settings.queue_tell_manager": "Сообщить руководителю", + "settings.queue_tell_manager_hint": "Владельцу и администраторам — значит, обычными силами диалог не разобрали.", + "settings.queue_wait_for_assignee": "Сколько ждём сотрудника, на которого назначен диалог", + "settings.queue_wait_for_assignee_hint": "Не взял за это время — диалог возвращается в общую очередь и снова виден всем.", + "settings.queue_minutes_unit": "мин", + "settings.queue_saved_by": "Сохранено {when} · {who}", + "settings.queue_never_changed": "Сроки ни разу не меняли", + "settings.queue_restore_defaults": "Вернуть обычные сроки", + "settings.queue_policy_unavailable": "Не удалось загрузить сроки", + "settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.", "settings.ai_provider": "AI-провайдер", "settings.allowed_domains": "Разрешённые домены", "settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.", diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 3623718..f55324f 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -1,12 +1,13 @@ -import { notification as antToast } from "antd"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types"; import type { SettingsSectionKey } from "../features/settings/sections"; import type { PortalSettingsSectionKey } from "../features/support-portals/sections"; import { NotificationDrawer } from "../features/notifications/NotificationDrawer"; import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model"; +import { useNotificationAlerts } from "../features/notifications/useNotificationAlerts"; import { fetchWaitingCount } from "../features/conversations/model"; +import { useRealtime, useRealtimeEvent } from "../features/realtime/RealtimeProvider"; import { useChatScope } from "../features/chat/useChatScope"; import { isManager } from "../auth/access"; import { DemoInstallBanner } from "../features/settings/DemoInstallBanner"; @@ -26,7 +27,6 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel const [waitingCount, setWaitingCount] = useState(0); // Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента. const [sidebarExpanded, setSidebarExpanded] = useState(false); - const prevUnread = useRef(null); const manager = isManager(user); // Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно // состояние (дизайн-базлайн v2 §4.1). @@ -47,28 +47,41 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel const loadNotifications = useCallback(async () => { try { const payload = await fetchNotifications(); - setNotifications(payload.items); - setUnreadCount(payload.unreadCount); - // Тост при появлении новых непрочитанных (после первой загрузки). - if (prevUnread.current !== null && payload.unreadCount > prevUnread.current) { - const latest = payload.items.find((item) => item.unread); - if (latest) antToast.open({ message: latest.title, description: latest.body, placement: "bottomRight" }); - } - prevUnread.current = payload.unreadCount; + setNotifications(payload.items ?? []); + setUnreadCount(payload.unreadCount ?? 0); } catch { /* ignore transient errors */ } }, []); + // Уведомление доезжает событием, а не следующим опросом: раньше оператор + // узнавал о ждущем диалоге в среднем через восемь секунд после того, как + // клиент написал, и только если вкладка была открыта на чате. + const realtime = useRealtime(); + useRealtimeEvent("notifications.changed", () => void loadNotifications()); + useRealtimeEvent("inbox.changed", () => void loadWaitingCount()); + useEffect(() => { + void loadNotifications(); + void loadWaitingCount(); + }, [loadNotifications, loadWaitingCount]); + + useEffect(() => { + // Опрос остаётся страховкой на случай обрыва сокета и потому разрежается, + // пока тот жив. Смена состояния соединения меняет только интервал: если + // перезапускать вместе с ним и загрузку, недоступный сокет с его + // переподключениями превращается в поток лишних запросов. const tick = () => { void loadNotifications(); void loadWaitingCount(); }; - tick(); - const timer = setInterval(tick, 15000); + const timer = setInterval(tick, realtime.connected ? 60000 : 15000); return () => clearInterval(timer); - }, [loadNotifications, loadWaitingCount]); + }, [loadNotifications, loadWaitingCount, realtime.connected]); + + // Тост и системное уведомление — по идентификаторам пришедшего, а не по + // росту счётчика непрочитанных. + useNotificationAlerts({ items: notifications, onOpen: (item) => void onNotificationClick(item) }); async function onNotificationClick(notification: AppNotification) { setNotifOpen(false); diff --git a/apps/internal-ui/src/shared/icons.tsx b/apps/internal-ui/src/shared/icons.tsx index 9757e3e..a96e7be 100644 --- a/apps/internal-ui/src/shared/icons.tsx +++ b/apps/internal-ui/src/shared/icons.tsx @@ -57,7 +57,7 @@ export function MaxLogo({ size = 24 }: { size?: number }) { ); } -export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) { +export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) { const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const; const paths: Record = { thumbUp: <>, @@ -74,6 +74,8 @@ export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "b plug: , settings: <>, bell: <>, + // Перечёркнутый колокол: браузер запретил уведомления (макет Q1b). + bellOff: <>, message: , cart: <>, globe: , diff --git a/design/baseline/Очередь и уведомления/README.md b/design/baseline/Очередь и уведомления/README.md new file mode 100644 index 0000000..1150b38 --- /dev/null +++ b/design/baseline/Очередь и уведомления/README.md @@ -0,0 +1,54 @@ +# Очередь, ожидание и уведомления — решения по макету + +Файл: `Очередь и уведомления Baseline.dc.html` (тема/акцент — в Tweaks, как у остальных baseline-файлов). Фреймы Q1–Q5 соответствуют пяти пунктам задачи. Тексты — без слов «эскалация», «порог», «SLA». + +## Q1 · Профиль → карточка уведомлений + +- **Заголовок:** «Уведомления о рабочих событиях». +- **Подводка:** «Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.» Первая фраза честна для обоих способов, ограничение «один мессенджер» отнесено к мессенджеру. +- **Порядок:** браузер **выше** ботов. Он включается одним нажатием и без внешнего аккаунта — это первый разумный шаг; боты ниже как «доставка наружу». +- **Три состояния строки (Q1b):** + 1. *не запрашивали* — серая плитка, текст «Не включены. Браузер спросит разрешение по нажатию», primary «Включить»; + 2. *выдано и включено* — зелёная плашка «Включены», «Отключить», галочки типов событий; + 3. *браузер отказал* — красная плашка «Браузер запретил» + подсказка «Снять запрет можно только в браузере» с шагами (замок в адресной строке → «Уведомления» → «Разрешать» → обновить) и кнопкой «Проверить снова». Кнопки «Включить» в этом состоянии нет — она бы ничего не сделала. +- Под включённой строкой — примечание, что «Отключить» выключает показ, а не разрешение. +- В типах событий добавлен пункт «Диалог назначили на меня» — иначе новый вид ожидания никак не доходит до сотрудника. +- **Дополнено после хендоффа (решение владельца):** первым пунктом вернулся «Новый диалог». В присланном наборе такого события не было, а в продукте оклик о новом диалоге уходит и тогда, когда диалог ведёт агент; называть его «клиент запросил оператора» — неправда. По умолчанию пункт выключен: новый диалог, которым занимается робот, лично никого не касается. Тем, у кого он был включён раньше, он сохранён при обновлении. + +## Q2 · Настройки → «Когда звать на помощь» + +- **Новый раздел субменю** сразу после «Групп» (сроки касаются групп), не внутри «Организации»: там реквизиты организации, а здесь правила работы очереди — смешивать их значит спрятать сроки. +- Название раздела — по сути: «Когда звать на помощь». +- Четыре поля, каждое с пояснением: напомнить группе (5), позвать всех (15), сообщить руководителю (30), сколько ждём назначенного сотрудника (10). Единица «мин» вынесена из поля. +- Футер: дата и автор сохранения, «Вернуть обычные сроки», primary «Сохранить». +- Предупреждение внизу: если у группы не настроен ни один способ доставки, останется только счётчик в списке. + +## Q3 · Чат → два вида ожидания + +Показаны два варианта, **рекомендуем А**: + +- **А · две вкладки.** «Ждут оператора» разделена на «Общая очередь» и «На мне». Разные действия (взять чужое / не потерять своё) — разные списки; счётчик «На мне» становится личной нормой, а не частью общего числа. +- **Б · одна вкладка с метками.** Меньше вкладок, но два вида ожидания перемешаны, и своё приходится выцеплять глазами. + +В обоих вариантах строка получила синюю метку «на вас · вернётся через N мин». + +## Q4 · Строка и карточка диалога + +- **Время ожидания — и там и там.** В строке это уже существующий тёплый бейдж с таймером (без слова «ждёт»), в карточке — новый блок «Ожидание» сверху: «Ждёт человека 7 минут». +- **«Назначен, но не взят» показываем явно** — с аватаром и оставшимся временем: «Назначен на Анну Ким, не взят» + «Если не возьмёт — через 3 минуты диалог вернётся в общую очередь». Это единственное, что объясняет, почему диалог виден, но брать его не нужно. +- Поле «Начат» в карточке заменено на «В очереди с» — момент постановки в очередь информативнее для оператора. +- **Системные события** — обычный вид. Единственное исключение: «не взял — вернулся в очередь» окрашен как предупреждение (тёплая плашка): это не факт из истории, а сорванная договорённость. + +## Q5 · Ответственный → присутствие + +- В списке две группы: «На месте» и «Сейчас нет», внутри — сотрудники с точкой присутствия (зелёная / пустая обводка) и подписью «на месте» / «не в приложении · 25 мин». +- Отсутствующие **не блокируются и не скрываются** — только уходят в конец списка; в футере списка честная оговорка: «„На месте“ — приложение открыто. Признак приблизительный: назначить можно любого». +- Справа у строки — текущая загрузка («4 диалога»): при выборе ответственного это второй по важности признак после присутствия. +- В закрытом поле «Ответственный» присутствие показано той же точкой справа от имени. + +## Что требует бэкенда + +- Разрешение браузера и «желание получать» — два разных флага: `browser_push_enabled` на устройстве/сессии + типы событий, как у ботов. +- Четыре срока организации — в API настроек (сейчас только служебная админка). +- В списке диалогов нужны `assignee`, `assigned_at` и момент постановки в очередь, чтобы считать «вернётся через N мин». +- Признак присутствия сотрудника (последняя активность) в выборе ответственного. diff --git a/design/baseline/Очередь и уведомления/support.js b/design/baseline/Очередь и уведомления/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/design/baseline/Очередь и уведомления/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + +
+
+

Chatballs · Очередь, ожидание и уведомления

+ дополнение к design baseline · пять мест +
+

Дорисованы места, которых не было в макете: уведомления браузера в профиле, сроки ожидания в настройках, разделение двух видов ожидания в списке диалогов, время ожидания в строке и карточке, присутствие сотрудника в выборе ответственного. Служебные слова («эскалация», «порог», «SLA») в текстах не используются.

+ +
+ + +
+
+ Q1 + Профиль · карточка уведомлений + новый заголовок и подводка; строка браузера выше ботов +
+ +
+

Уведомления о рабочих событиях

+

Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.

+ + +
+
+ +
Этот браузерChrome · macOS — видно, когда вкладка свёрнута
+ Включены + +
+
+ +
+

«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.

+
+ + +
+
+ +
Telegram@nord_notify_bot · сервисный бот уведомлений
+ Подключено + +
+
+ +
+
+ + +
+
+ +
MAXПривязка заменит Telegram
+ Привязать бота +
+
+

Нажмите «Привязать бота» и в открывшемся чате — «Начать»: привязка подтвердится сама.

+
+
+ + +
+
+ Q1b + Строка браузера · три состояния +
+ +
+ +
+
1 · РАЗРЕШЕНИЕ НЕ ЗАПРАШИВАЛИ
+
+ +
Этот браузерНе включены. Браузер спросит разрешение по нажатию.
+ +
+
+ + +
+
2 · РАЗРЕШЕНИЕ ВЫДАНО, ВКЛЮЧЕНО
+
+ +
Этот браузерChrome · macOS — видно, когда вкладка свёрнута
+ Включены + +
+
+ + +
+
3 · БРАУЗЕР ОТКАЗАЛ
+
+ +
Этот браузерChrome · macOS
+ Браузер запретил +
+
+ +
+ Снять запрет можно только в браузере +

Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.

+ +
+
+
+
+
+ + +
+
+ Q2 + Настройки · новый раздел «Когда звать на помощь» + отдельный пункт субменю сразу после «Групп» +
+ +
+ + +
+
+

Когда звать на помощь

+

Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.

+ +
+ +
+
+ {{ d.title }} + {{ d.hint }} +
+
+ {{ d.value }} + мин +
+
+
+
+ Сохранено 12 сентября, 14:20 · Елена Кузнецова +
+ + +
+
+
+ +
+ +

Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.

+
+
+
+
+
+ + +
+
+ Q3 + Чат · два вида ожидания в списке + вариант А — две вкладки (рекомендуем) · вариант Б — одна вкладка с метками +
+ +
+ +
+
{{ v.title }}{{ v.note }}
+
+
+
Все диалоги{{ v.total }}
+
+
+ + + +
+
+ + + +
{{ v.footer }}
+
+
+
+
+
+
+ + +
+
+ Q4 + Карточка диалога · блок «Ожидание» и системные события +
+ +
+ +
+

Диалог

фрагмент контекст-панели
+ +
+
Ждёт человека 7 минут
+
+ АК + Назначен на Анну Ким, не взят +
+

Если не возьмёт — через 3 минуты диалог вернётся в общую очередь.

+
+ + + + + + +
В очереди с14:12
+
+ + +
+
Системные события в истории диалога — тот же вид, что у остальных; возврат в очередь окрашен как предупреждение.
+ +
{{ m.text }} · {{ m.time }}
+
+
+
+
+ + +
+
+ Q5 + Ответственный · кто сейчас на месте + подсказка, а не запрет: выбрать отсутствующего можно +
+ +
+ + + +
+
Поиск сотрудника
+ + +
{{ a.head }}
+
+ + + +
+
«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.
+
+
+
+ +
+
+
+ + + diff --git a/tests/e2e/profile.spec.ts b/tests/e2e/profile.spec.ts new file mode 100644 index 0000000..5d71d37 --- /dev/null +++ b/tests/e2e/profile.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Страница профиля: карточка уведомлений со строкой «Браузер» и устойчивость +// страницы к неполному ответу соседней карточки. +// +// Разрешение браузера в тестах подменяется: выдать его сайт не может, а +// состояние иначе зависело бы от того, что headless-Chromium ответит сегодня. + +const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000"; + +const OWNER = { + id: 1, + email: "owner@example.com", + fullName: "Елена Кузнецова", + avatarUrl: null, + mustChangePassword: false, + totpEnabled: false, + totpLastUsedAt: null, + deliveryMode: "SELF_HOSTED", + isInstanceAdmin: true, + language: "ru", + uiLanguage: "", + uiTheme: "LIGHT", + uiAccent: "#1677ff", + memberships: [ + { + id: 1, + organizationPublicId: ORGANIZATION_PUBLIC_ID, + organization: "atelier-nord", + organizationName: "Ателье Норд", + organizationLogoUrl: null, + role: "OWNER", + positionTitle: "Владелец", + totpRequired: false, + capabilities: ["company.view", "conversations.view", "employees.manage_privileged", "settings.view"], + groups: [], + joinedAt: "2026-02-02T10:00:00Z", + }, + ], +}; + +const TYPES = [ + { code: "DIALOG_WAITING", label: "Диалог ждёт оператора" }, + { code: "DIALOG_NEW_MESSAGE", label: "Новое сообщение в диалоге" }, +]; + +// Сценарий дважды загружает SPA (вход формой, затем адрес профиля) — штатных +// тридцати секунд на это впритык. +test.describe.configure({ timeout: 60_000 }); + +test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "internal-ui", "internal-ui only"); +}); + +/** Разрешение браузера задаём сами: выдать его сайт не может, а тест обязан + * быть воспроизводимым. */ +async function pretendPermission(page: Page, value: "default" | "granted" | "denied") { + await page.addInitScript((permission) => { + class FakeNotification { + static permission = permission; + static requestPermission = async () => permission; + onclick: (() => void) | null = null; + close() {} + } + Object.defineProperty(window, "Notification", { value: FakeNotification, configurable: true }); + }, value); +} + +async function openProfile( + page: Page, + preference: { enabled: boolean; types: string[] }, + sessions: object = { items: [] }, +) { + // Порядок важен: в Playwright выигрывает маршрут, зарегистрированный + // последним, поэтому общие заглушки идут первыми, частные — за ними. + await page.route("**/api/v1/**", (route) => route.fulfill({ json: {} })); + await page.route("**/api/v1/organizations/*/conversations/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/conversations/counters/")) { + return route.fulfill({ + json: { all: 0, waiting: 0, mine: 0, ungrouped: 0, groups: [], agents: [], assignees: [] }, + }); + } + if (path.endsWith("/conversations/directory/")) return route.fulfill({ json: { groups: [], employees: [] } }); + if (path.endsWith("/conversations/stats/")) return route.fulfill({ json: { waiting: 0 } }); + return route.fulfill({ json: { items: [] } }); + }); + await page.route("**/api/v1/organizations/*/company/**", (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith("/company/onboarding/")) { + return route.fulfill({ + json: { steps: {}, dismissedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:00:00Z" }, + }); + } + return route.fulfill({ json: { items: [] } }); + }); + await page.route("**/api/v1/organizations/*/notifications/**", (route) => + route.fulfill({ json: { items: [], unreadCount: 0 } }), + ); + await page.route("**/api/v1/organizations/*/notifications/messenger-bindings/", (route) => + route.fulfill({ + json: { + items: [ + { + integrationId: 1, + provider: "TELEGRAM", + name: "notify", + botUsername: "chatballs_notify_bot", + bound: true, + pushTypes: ["DIALOG_WAITING"], + }, + ], + availableTypes: TYPES, + }, + }), + ); + await page.route("**/api/v1/organizations/*/notifications/preferences/BROWSER/", (route) => + route.fulfill({ json: { transport: "BROWSER", ...preference, availableTypes: TYPES } }), + ); + // Соседние карточки профиля тоже ходят за данными: без их ответов страница + // падает целиком и проверять было бы нечего. + await page.route("**/api/v1/auth/profile/sessions/", (route) => route.fulfill({ json: sessions })); + await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } })); + + // Вход формой: после него сессия обязана быть живой, иначе переход на адрес + // профиля выкинет обратно на логин. + let signedIn = false; + await page.route("**/api/v1/auth/session/", (route) => + route.fulfill({ + json: signedIn ? { authenticated: true, user: OWNER } : { authenticated: false, language: "ru" }, + }), + ); + await page.route("**/api/v1/auth/login/", (route) => { + signedIn = true; + return route.fulfill({ json: { authenticated: true, user: OWNER } }); + }); + + await page.goto("/"); + await page.getByPlaceholder("you@domain.ru").fill("owner@example.com"); + await page.getByPlaceholder("Пароль").fill("Password-123"); + await page.getByRole("button", { name: "Войти" }).click(); + await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/profile`); + + const card = page.locator(".profile-card", { hasText: "Уведомления о рабочих событиях" }); + return { card, browserRow: card.locator(".profile-notifications-block").first() }; +} + +test("разрешение не запрошено — строку браузера можно включить", async ({ page }) => { + await pretendPermission(page, "default"); + const { card, browserRow } = await openProfile(page, { enabled: false, types: [] }); + + await expect(browserRow).toContainText("Этот браузер"); + await expect(browserRow).toContainText("Браузер спросит разрешение по нажатию"); + await expect(browserRow.getByRole("button", { name: "Включить" })).toBeVisible(); + // Галочки типов — только у включённого транспорта. + await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(0); + // Строка бота осталась рядом и того же вида: шаблон у них один. + await expect(card).toContainText("Telegram"); +}); + +test("разрешение выдано и включено — видны те же галочки типов", async ({ page }) => { + await pretendPermission(page, "granted"); + const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] }); + + await expect(browserRow).toContainText("Включены"); + await expect(browserRow.getByRole("button", { name: "Отключить" })).toBeVisible(); + await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(TYPES.length); + // Под включённой строкой — примечание, что «Отключить» гасит показ, а не разрешение. + await expect(browserRow).toContainText("Само разрешение остаётся выданным"); +}); + +test("браузер отказал — вместо кнопки подсказка, как это снять", async ({ page }) => { + await pretendPermission(page, "denied"); + const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] }); + + await expect(browserRow).toContainText("Браузер запретил"); + // Кнопки «Включить» нет — она ничего не сделала бы; есть «Проверить снова». + await expect(browserRow.getByRole("button", { name: "Включить" })).toHaveCount(0); + await expect(browserRow).toContainText("Снять запрет можно только в браузере"); + await expect(browserRow.getByRole("button", { name: "Проверить снова" })).toBeVisible(); +}); + +test("неполный ответ одной карточки не гасит всю страницу профиля", async ({ page }) => { + // Регрессия: список сессий приходил без items, карточка падала на + // items.length, и вместе с ней исчезала вся страница — пароль, двухфакторка + // и уведомления в том числе. + await pretendPermission(page, "default"); + const { card } = await openProfile(page, { enabled: false, types: [] }, {}); + + await expect(card).toBeVisible(); + await expect(page.locator(".profile-card")).toHaveCount(7); + await expect(page.locator(".sessions-card")).toContainText("Активные сессии"); +});