Compare commits

...
11 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 4ab26a1bcd 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

Обе команды теперь подменяют входную точку. Плюс страховка в цикле: если
помощник исчез, не дописав результат, установка помечается неудачной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:16:19 +03:00
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 8580b480e7 💄 fix(organization): язык интерфейса селектом, одна вёрстка у обеих форм
После удаления валюты в ряду полей осталась пустая половина. Теперь ряд
занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину
карточки, стал таким же селектом, как соседнее поле. Подпись под ним
убрана вместе с ключом словаря.

Форма создания организации и «Настройки → Организация» — одна форма по
смыслу, поэтому у них теперь одна ширина колонки и одни отступы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:12:22 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»
Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 d8420e4379 🔖 release: 1.6.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:30:31 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
139 changed files with 7416 additions and 4210 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ Production deployment / миграция:
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
UI / дизайн:
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
+1 -1
View File
@@ -1 +1 @@
1.5.0
1.6.1
-6
View File
@@ -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)),
+1 -28
View File
@@ -2,7 +2,6 @@ import time
from django.conf import settings
from chatballs.ai import limits, pricing
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.pii import redact
from chatballs.ai.provider import routing
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
_breaker = CircuitBreaker()
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
purpose=purpose,
operation="chat",
model=model,
status=LlmInvocationStatus.BLOCKED,
error=str(error),
)
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
# организации с fallback на модель агента. Без интеграции модель остаётся
@@ -52,18 +39,7 @@ def invoke_chat(
params: dict | None = None,
used_fragment_ids: list | None = None,
) -> ChatResult:
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)
except limits.LimitExceeded as error:
_record_blocked(
channel=channel,
purpose=purpose,
model=fallback_model,
error=error,
)
raise
provider, model = _prepare_invocation(channel=channel, requested_model=model)
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
started = time.monotonic()
@@ -96,8 +72,6 @@ def invoke_chat(
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
total_tokens=result.total_tokens,
cost_micros=result.cost_micros
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
latency_ms=int((time.monotonic() - started) * 1000),
status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
@@ -128,7 +102,6 @@ def embed_texts(
model=model,
prompt_tokens=tokens,
total_tokens=tokens,
cost_micros=pricing.cost_micros(model, tokens, 0),
status=LlmInvocationStatus.SUCCESS,
)
return results
-32
View File
@@ -1,32 +0,0 @@
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
class LimitExceeded(Exception):
pass
def _day_start():
now = timezone.localtime()
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def daily_cost_micros(channel=None) -> int:
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
if channel is not None:
queryset = queryset.filter(channel=channel)
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> 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")
@@ -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",
),
]
@@ -0,0 +1,34 @@
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
# цифра расхода в продукте не показывалась.
#
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
from django.db import migrations, models
def drop_blocked_rows(apps, schema_editor):
"""Строк со снятым статусом в журнале остаться не должно."""
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
class Migration(migrations.Migration):
dependencies = [
("ai", "0018_remove_agent_limits"),
]
operations = [
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
migrations.RemoveField(model_name="llminvocation", name="currency"),
migrations.AlterField(
model_name="llminvocation",
name="status",
field=models.CharField(
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
default="SUCCESS",
max_length=16,
),
),
]
-10
View File
@@ -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)
@@ -463,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -489,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
total_tokens = models.PositiveIntegerField(default=0)
cost_micros = models.PositiveBigIntegerField(default=0)
currency = models.CharField(max_length=3, default="USD")
latency_ms = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
-17
View File
@@ -1,17 +0,0 @@
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
@@ -18,9 +18,6 @@ class ChatResult:
model: str
prompt_tokens: int
completion_tokens: int
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
cost_micros: int = 0
@property
def total_tokens(self) -> int:
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has
choices[0].message.content and usage (optionally usage.cost in USD).
choices[0].message.content and usage (prompt/completion tokens).
- POST /embeddings with {model, input}; response has data[].embedding and usage.
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
(name, catalog). Stdlib only — no third-party HTTP client.
"""
@@ -116,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
"""POST /chat/completions and parse the OpenAI-shaped response."""
payload: dict = {
@@ -142,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
@@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
@@ -172,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
self.proxy_url = proxy_url or ""
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
return openai_http.chat_completions(
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
)
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
-17
View File
@@ -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",
-27
View File
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0)
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
from chatballs.ai import pricing
self.assertEqual(
invocation.cost_micros,
pricing.cost_micros(
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
),
)
def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
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")
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation
@@ -13,7 +13,6 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
except (ProviderError, LimitExceeded) as error:
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
# отвечаем клиенту понятным fallback.
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
+1 -15
View File
@@ -8,11 +8,10 @@ from __future__ import annotations
from datetime import datetime, timedelta
from django.db.models import Count, Sum
from django.db.models import Count
from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
from chatballs.ai.models import LlmInvocation
from chatballs.channels.selectors import channels_in_organization
from chatballs.conversations.models import (
ControlMode,
@@ -64,17 +63,6 @@ def _chart(
return {"values": values, "labels": labels}
def _ai_cost(
org_id: int,
start: datetime,
end: datetime | None = None,
) -> int:
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
if end is not None:
qs = qs.filter(created_at__lt=end)
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
def sales_overview_stats(context, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
@@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
conversation__organization_id=organization_id,
created_at__gte=start,
).count(),
"aiCostMicros": _ai_cost(organization_id, start),
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
}
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertFalse(ReplyTemplate.objects.exists())
class LaunchChecklistTests(TestCase):
class OnboardingTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="New", slug="launch-org")
owner = HumanUser.objects.create_user(
self.owner = HumanUser.objects.create_user(
email="owner@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=owner,
self.membership = OrganizationMembership.objects.create(
user=self.owner,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.client = APIClient()
self.client.force_authenticate(owner)
self.client.force_authenticate(self.owner)
def test_checklist_marks_steps_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/launch-checklist/").json()
def test_steps_are_marked_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/onboarding/").json()
self.assertEqual(
initial,
initial["steps"],
{
"agentCreated": False,
"providerConnected": False,
"agentActive": False,
"knowledgeFilled": False,
"connectionBound": False,
"widgetPublished": False,
"employeeInvited": False,
"done": False,
"platformConfigured": False,
"firstConversation": False,
},
)
@@ -393,5 +397,56 @@ class LaunchChecklistTests(TestCase):
position_title="Operator",
)
final = self.client.get("/api/v1/company/launch-checklist/").json()
self.assertTrue(final["done"])
final = self.client.get("/api/v1/company/onboarding/").json()
self.assertTrue(final["steps"]["connectionBound"])
self.assertTrue(final["steps"]["employeeInvited"])
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
"""Признак пустой у всех, кто заведён до появления онбординга."""
payload = self.client.get("/api/v1/company/onboarding/").json()
self.assertIsNone(payload["dismissedAt"])
self.assertIsNone(payload["completedAt"])
def test_dismiss_and_complete_and_restart(self) -> None:
dismissed = self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
).json()
self.assertIsNotNone(dismissed["dismissedAt"])
self.assertIsNone(dismissed["completedAt"])
completed = self.client.post(
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
).json()
self.assertIsNotNone(completed["completedAt"])
restarted = self.client.post(
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
).json()
self.assertIsNone(restarted["dismissedAt"])
self.assertIsNone(restarted["completedAt"])
def test_dismissal_is_personal(self) -> None:
"""Закрытие одним администратором не прячет визард у второго."""
self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
)
colleague = HumanUser.objects.create_user(
email="admin@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=colleague,
organization=self.organization,
role=EmployeeRole.ADMIN,
position_title="Admin",
)
other = APIClient()
other.force_authenticate(colleague)
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
def test_unknown_action_is_rejected(self) -> None:
response = self.client.post(
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -3,7 +3,6 @@ from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
)
class IngestLimitHandlingTests(TestCase):
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
провайдера). См. ingest.ingest_inbound.
class IngestProviderFailureTests(TestCase):
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
fallback-ответом клиенту. См. ingest.ingest_inbound.
"""
def setUp(self) -> None:
@@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase):
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
)
def test_limit_exceeded_hands_off_to_operator(self) -> None:
def test_provider_failure_hands_off_to_operator(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
side_effect=ProviderError("provider is down"),
),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
):
+5 -2
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file",
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
"admin.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found",
@@ -98,6 +97,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "New message · {contact}",
"notifications.operator_needed": "An operator is needed · {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",
"portals.article_not_found": "Article not found",
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
"portals.file_not_found": "File not found",
@@ -111,7 +112,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Invalid section",
"portals.not_found": "Portal not found",
"portals.section_not_found": "Section not found",
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
"profile.choose_photo_file": "Choose a photo file",
"profile.link_not_found": "Link not found",
"profile.notification_bot_not_found": "Notification bot not found",
@@ -247,6 +248,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
@@ -332,6 +334,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Invalid credentials",
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
+5 -2
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена",
@@ -102,6 +101,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "Новое сообщение · {contact}",
"notifications.operator_needed": "Нужен оператор · {contact}",
"notifications.voice_without_transcript": "Голосовое без расшифровки",
"onboarding.membership_required": "Онбординг доступен участнику организации",
"onboarding.unknown_action": "Неизвестное действие онбординга",
"portals.article_not_found": "Статья не найдена",
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
"portals.file_not_found": "Файл не найден",
@@ -115,7 +116,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Некорректный раздел",
"portals.not_found": "Портал не найден",
"portals.section_not_found": "Раздел не найден",
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
"profile.choose_photo_file": "Выберите файл фото",
"profile.link_not_found": "Привязка не найдена",
"profile.notification_bot_not_found": "Бот уведомлений не найден",
@@ -251,6 +252,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
@@ -336,6 +338,7 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Неверный email или пароль",
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone"]
list_display = ["slug", "name", "timezone"]
search_fields = ["slug", "name"]
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
return {
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения.
"language": organization.language,
@@ -28,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
class OrganizationSettingsInput:
name: str
timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили.
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language)
if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")})
@@ -52,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
raise ValidationError(
{"timezone": t("admin.invalid_timezone")}
) from error
if currency != "RUB":
raise ValidationError(
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
@transaction.atomic
@@ -67,15 +62,14 @@ def update_organization_settings(
context: TenantContext,
data: OrganizationSettingsInput,
) -> Organization:
clean = _validate_input(data)
clean = validate_organization_settings(data)
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
)
organization.name = clean.name
organization.timezone = clean.timezone
organization.currency = clean.currency
organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"])
organization.save(update_fields=["name", "timezone", "language"])
return organization
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
data=OrganizationSettingsInput(
name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)),
),
)
@@ -293,37 +292,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
return actors
class LaunchChecklistView(APIView):
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
class OnboardingView(APIView):
"""Состояние онбординга «Начало работы» для текущего человека.
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
в организации: у каждого человека свои, и один администратор не прячет
визард команде.
"""
permission_classes = [HasCapability]
required_capability = "settings.view"
def get(self, request: Request) -> Response:
from chatballs.channels.models import Channel
from chatballs.identity.models import OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
membership = request.tenant_context.membership
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
}
)
organization_id = request.tenant_context.organization_id
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
connection_bound = Integration.objects.filter(
def post(self, request: Request) -> Response:
"""Закрыть визард или отметить его пройденным.
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
снимает оба признака: визард снова открывается по ссылке и пилюле.
"""
membership = request.tenant_context.membership
if membership is None:
return Response({"detail": t("onboarding.membership_required")}, status=403)
action = str(request.data.get("action") or "dismiss")
now = django_timezone.now()
if action == "dismiss":
membership.onboarding_dismissed_at = now
elif action == "complete":
membership.onboarding_dismissed_at = now
membership.onboarding_completed_at = now
elif action == "restart":
membership.onboarding_dismissed_at = None
membership.onboarding_completed_at = None
else:
return Response({"detail": t("onboarding.unknown_action")}, status=400)
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
"completedAt": _isoformat(membership.onboarding_completed_at),
}
)
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def onboarding_steps(organization_id: int) -> dict[str, bool]:
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
from chatballs.conversations.models import Conversation
from chatballs.identity import instance_settings
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
organization = Organization.objects.filter(pk=organization_id).first()
return {
"providerConnected": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.LLM_PROVIDER,
).exists(),
"agentActive": AIAgent.objects.filter(
organization_id=organization_id,
status=AIAgentStatus.ACTIVE,
).exists(),
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
"connectionBound": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.MESSENGER,
channel__isnull=False,
).exists()
employee_invited = (
OrganizationMembership.objects.filter(
organization_id=organization_id
).count()
> 1
or request.tenant_context.organization.invitations.exists()
)
return Response(
{
"agentCreated": agent_created,
"connectionBound": connection_bound,
"employeeInvited": employee_invited,
"done": agent_created and connection_bound and employee_invited,
}
)
).exists(),
"widgetPublished": WebChatWidget.objects.filter(
organization_id=organization_id,
status=WebChatWidgetStatus.PUBLISHED,
).exists(),
"employeeInvited": (
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
or (organization is not None and organization.invitations.exists())
),
# Шаг про саму установку, а не про организацию: домен и исходящая почта
# общие для всех организаций на сервере.
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
}
@@ -111,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "audit.action_organization_provisioned",
"organization.created": "audit.action_organization_created",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
class ProfileUpdateView(APIView):
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
# Членства роли app без контекста не видны: сначала каталог входа, затем
# каждое членство читается в контексте своей организации — как в
# identity.auth.common._user_payload.
oldest: tuple[object, int, str] | None = None
for route in membership_routes_for_user(request.user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
.first()
)
if membership is None:
continue
key = (membership.created_at, membership.id, membership.organization.language or "")
if oldest is None or key[:2] < oldest[:2]:
oldest = key
return oldest[2] if oldest is not None else ""
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
defaults={
"name": "Demo",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
)
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
@@ -23,9 +23,9 @@ urlpatterns = [
name="organization-logo",
),
path(
"launch-checklist/",
administration_views.LaunchChecklistView.as_view(),
name="launch-checklist",
"onboarding/",
administration_views.OnboardingView.as_view(),
name="onboarding",
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
@@ -30,9 +30,6 @@
"how-to-measure",
"lead-times"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"returns-and-exchanges",
"your-account"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"your-account",
"returns-and-exchanges"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -30,9 +30,6 @@
"kak-snyat-merki",
"sroki-dostavki"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"vozvrat-i-obmen",
"lichnyj-kabinet"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"lichnyj-kabinet",
"vozvrat-i-obmen"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -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")
@@ -256,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=prompt,
completion_tokens=0 if failed else completion,
total_tokens=prompt + (0 if failed else completion),
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
latency_ms=random.randint(900, 4200),
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
error="Provider timeout after 30s" if failed else "",
@@ -274,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=tokens,
completion_tokens=0,
total_tokens=tokens,
cost_micros=int(tokens * 0.02),
latency_ms=random.randint(200, 900),
status=LlmInvocationStatus.SUCCESS,
)
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
return _invitation_for_token(token, accepted=False)
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
"""Приглашение по токену из письма — без tenant-контекста на входе.
Ссылка /join приходит до входа в организацию, а таблица приглашений и
строка организации роли app без контекста не видны (tenancy/0003, 0033).
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
и приглашение читается уже в её контексте — вместе с организацией, чтобы
вызывающий код мог обращаться к ней и после выхода из контекста.
"""
if not token:
return None
return OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=True,
token_hash = _token_hash(token)
route = invitation_route(token_hash)
if route is None:
return None
query = OrganizationInvitation.objects.select_related("organization").filter(
id=route.resource_id,
organization_id=route.organization_id,
token_hash=token_hash,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
)
if accepted:
query = query.filter(accepted_at__isnull=False)
else:
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
with tenant_atomic(route.organization_id):
return query.first()
@transaction.atomic
@@ -314,16 +338,13 @@ def _already_accepted_for(
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
invitation = OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=False,
revoked_at__isnull=True,
).first()
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
with tenant_atomic(invitation.organization_id):
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
if membership is None:
return None
return AcceptedInvitation(
@@ -0,0 +1,25 @@
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
# в организации: у каждого он свой, и закрытие одним администратором не прячет
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
# «не закрывал» и увидят визард при следующем входе.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0037_invitation_membership_fields"),
]
operations = [
migrations.AddField(
model_name="organizationmembership",
name="onboarding_dismissed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="organizationmembership",
name="onboarding_completed_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
# снят вместе с лимитами.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("identity", "0038_membership_onboarding_dismissed_at"),
]
operations = [
migrations.RemoveField(
model_name="organization",
name="currency",
),
]
+9 -2
View File
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
# без этой отметки принимался бы второй раз ещё полторы минуты.
totp_last_counter = models.BigIntegerField(default=0)
# Внешний вид — глобальная настройка пользователя (не membership):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
@@ -125,7 +125,6 @@ class Organization(models.Model):
default=OrganizationStatus.ACTIVE,
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
@@ -184,6 +183,14 @@ class OrganizationMembership(models.Model):
phone = models.CharField(max_length=32, blank=True)
totp_required = models.BooleanField(default=False)
blocked_at = models.DateTimeField(null=True, blank=True)
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
# членстве, а не на организации: иначе первый же закрывший спрятал бы
# визард всей команде. NULL — не закрывал, значит увидит при следующем
# входе, включая тех, кто работает в системе давно.
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
# шаге, и тогда возвращаться к настройке ещё есть зачем.
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -0,0 +1,142 @@
"""Создание организации человеком из интерфейса.
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
на страницу с полями организации; тот, кто её заполнил, становится владельцем
новой организации и сразу в неё переключается. Это второй путь появления
организации рядом с платформенным провижинингом (platform.provisioning_service):
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
заводит её себе.
Кто может: администратор установки и любой, у кого есть роль владельца или
администратора хотя бы в одной организации. Сотрудник, работающий только в
чате, чужую установку организациями не засевает.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.administration_services import (
OrganizationSettingsInput,
validate_organization_settings,
)
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class CreatedOrganization:
organization: Organization
membership: OrganizationMembership
def can_create_organization(user: HumanUser) -> bool:
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
if not user.is_active:
return False
if user.is_instance_admin:
return True
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
role = (
OrganizationMembership.objects.filter(
id=route.resource_id, user=user, blocked_at__isnull=True
)
.values_list("role", flat=True)
.first()
)
if role in MANAGER_ROLES:
return True
return False
def unique_organization_slug(name: str) -> str:
"""Слаг из имени, уникальный среди организаций установки.
Проверка идёт через каталог организаций: роль app без контекста строк
организаций не видит (tenancy/0033).
"""
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def create_organization(
*, data: OrganizationSettingsInput, owner: HumanUser
) -> CreatedOrganization:
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
Порядок тот же, что у мастера первого запуска (identity.setup): id
выделяется заранее, строка вставляется уже в контексте этого id — иначе
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
"""
clean = validate_organization_settings(data)
with transaction.atomic():
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
organization = Organization(
id=organization_id,
name=clean.name,
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
language=clean.language,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
membership = OrganizationMembership.objects.create(
user=owner,
organization=organization,
role=EmployeeRole.OWNER,
# Должность — текстом на языке организации, как в провижининге.
position_title=t("setup.owner_position", language=customer_language(organization)),
totp_required=False,
)
record_audit_event(
action="organization.created",
actor=owner,
organization=organization,
object_type="Organization",
object_id=str(organization.public_id),
payload={"organizationName": organization.name},
)
enqueue_event(
DomainEvent(
aggregate_type="Organization",
aggregate_id=str(organization.public_id),
event_type="organization.provisioned",
payload={},
tenant_context=TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
actor_user=owner,
),
)
)
return CreatedOrganization(organization=organization, membership=membership)
@@ -0,0 +1,12 @@
from django.urls import path
from chatballs.identity import organization_views
urlpatterns = [
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
path(
"options/",
organization_views.OrganizationCreateOptionsView.as_view(),
name="organization-create-options",
),
]
@@ -0,0 +1,75 @@
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
(auth.invitations): обновлённая учётная запись со списком членств и публичный
id организации, в которую интерфейсу переключиться.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
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.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
)
from chatballs.identity.administration_services import OrganizationSettingsInput
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.organization_creation import (
can_create_organization,
create_organization,
)
def _forbidden() -> Response:
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
class OrganizationCreateOptionsView(APIView):
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
return Response(
{
"timezones": administration_timezones(),
"languages": administration_languages(),
"currencies": ["RUB"],
}
)
class OrganizationCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
body = request.data if isinstance(request.data, dict) else {}
try:
created = create_organization(
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
language=str(body.get("language", "")),
),
owner=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response(
{
"user": _user_payload(request.user),
"organizationPublicId": str(created.organization.public_id),
},
status=201,
)
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
name="Example",
slug="administration",
timezone="Europe/Moscow",
currency="RUB",
)
self.owner = HumanUser.objects.create_user(
email="owner@administration.test",
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Новая компания",
"timezone": "Asia/Yekaterinburg",
"currency": "rub",
},
format="json",
)
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
self.organization.refresh_from_db()
self.assertEqual(self.organization.name, "Новая компания")
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
self.assertEqual(self.organization.currency, "RUB")
def test_invalid_timezone_is_rejected(self) -> None:
response = self.client.patch(
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("timezone", response.json()["errors"])
def test_only_ruble_currency_is_accepted(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/",
{"currency": "USD"},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("российский рубль", response.json()["errors"]["currency"])
def test_logo_upload_download_and_delete(self) -> None:
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
uploaded = self.client.post(
@@ -0,0 +1,122 @@
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
from __future__ import annotations
import json
from django.test import TestCase, override_settings
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
URL = "/api/v1/organizations/"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OrganizationCreationTests(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
self.owner = HumanUser.objects.create_user(
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.owner,
role=EmployeeRole.OWNER,
position_title="Владелец",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _post(self, user: HumanUser, **body):
client = TenantAPIClient()
client.force_login(user)
return client.post(
URL,
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
content_type="application/json",
)
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
response = self._post(self.owner, language="en")
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
created = Organization.objects.get(public_id=payload["organizationPublicId"])
self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
# Список членств в ответе уже содержит новую организацию: интерфейсу
# есть куда переключиться без повторного запроса сессии.
self.assertEqual(
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
{str(self.first.public_id), str(created.public_id)},
)
self.assertTrue(
AuditEvent.objects.filter(
organization=created, action="organization.created", actor=self.owner
).exists()
)
def test_same_name_gets_a_distinct_slug(self) -> None:
first = self._post(self.owner).json()["organizationPublicId"]
second = self._post(self.owner).json()["organizationPublicId"]
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
self.assertEqual(len(slugs), 2)
def test_employee_cannot_create_organizations(self) -> None:
response = self._post(self.employee)
self.assertEqual(response.status_code, 403)
self.assertEqual(Organization.objects.count(), 1)
def test_instance_admin_without_memberships_can_create(self) -> None:
admin = HumanUser.objects.create_user(
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
)
response = self._post(admin)
self.assertEqual(response.status_code, 201, response.content)
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
def test_empty_name_is_a_field_error(self) -> None:
response = self._post(self.owner, name=" ")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.json()["errors"])
self.assertEqual(Organization.objects.count(), 1)
def test_anonymous_is_rejected(self) -> None:
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
self.assertIn(response.status_code, {401, 403})
def test_options_list_timezones_and_languages(self) -> None:
client = TenantAPIClient()
client.force_login(self.owner)
response = client.get(f"{URL}options/")
self.assertEqual(response.status_code, 200)
self.assertIn("Europe/Moscow", response.json()["timezones"])
self.assertTrue(response.json()["languages"])
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
("updates", "updatestate"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
@@ -24,7 +24,6 @@ def provisioning_result_payload(
"slug": organization.slug,
"status": organization.status,
"timezone": organization.timezone,
"currency": organization.currency,
},
"provisioning": {
"status": provisioning.status,
@@ -21,7 +21,6 @@ class ProvisioningCommand:
source: str
idempotency_key: str
timezone: str = "Europe/Moscow"
currency: str = "RUB"
locale: str = ""
legal_name: str = ""
tax_profile: dict[str, Any] | None = None
@@ -158,7 +158,6 @@ def _create_organization(
slug=command.organization_slug.strip(),
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
timezone=command.timezone,
currency=command.currency.upper(),
)
except ValidationError as error:
raise ProvisioningValidation(str(error), code="organization_invalid") from error
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-1",
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-2",
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
"slug": slug,
"owner_email": owner_email,
"timezone": "Europe/Moscow",
"currency": "RUB",
},
},
format="json",
HTTP_IDEMPOTENCY_KEY=key,
)
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
"slug",
"owner_email",
"timezone",
"currency",
)
@@ -32,7 +31,6 @@ def parse_provisioning_body(
source=source,
idempotency_key=idempotency_key,
timezone=str(body.get("timezone", "Europe/Moscow")),
currency=str(body.get("currency", "RUB")),
locale=str(body.get("locale", "")),
legal_name=str(body.get("legal_name", "")),
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
+10
View File
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
return _unique_route("call_invite_directory", token_hash)
def invitation_route(token_hash: str) -> IngressRoute | None:
"""Приглашение в организацию по хэшу токена из письма (/join).
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
"""
return _unique_route("invitation_directory", token_hash)
def call_session_route(call_session_id: str) -> IngressRoute | None:
return _unique_route("call_session_directory", call_session_id)
@@ -0,0 +1,55 @@
# Две дыры мультиорганизационности, обе на роли app.
#
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
# организации. Теперь строка вставляется в контексте своего же id: сервис
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
#
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
# без контекста не находила приглашение вовсе. Security-barrier каталог
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
# по образцу call_invite_directory (0004).
from django.db import migrations
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
CREATE_POLICY = "chatballs_organization_app_create"
FORWARD_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
CREATE POLICY {CREATE_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (id = chatballs.current_organization_id());
CREATE OR REPLACE VIEW chatballs.invitation_directory
WITH (security_barrier = true) AS
SELECT invitation.id AS resource_id,
invitation.organization_id,
invitation.token_hash AS lookup_key
FROM identity_organizationinvitation invitation;
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
GRANT SELECT ON chatballs.invitation_directory
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVERSE_SQL = f"""
DROP VIEW IF EXISTS chatballs.invitation_directory;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (NOT chatballs.instance_has_organizations());
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0034_update_state_grants"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
+48 -3
View File
@@ -1,11 +1,20 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from django.utils import timezone
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
invitation_preview,
issue_invitation,
pending_invitation_for_token,
)
from chatballs.identity.models import (
AuditEvent,
AuditResult,
@@ -14,7 +23,8 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
from chatballs.tenancy.lookup import reserve_organization_id
from chatballs.tenancy.models import StorageReservation
from chatballs.testing import TenantAPIClient
@@ -429,7 +439,7 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
# Каталоги входа доступны роли app (tenancy/0032): backend-app
# обходится без platform-соединения.
with transaction.atomic():
@@ -441,10 +451,45 @@ class RowLevelSecurityTests(TransactionTestCase):
[self.user.id],
)
self.assertEqual(cursor.fetchone()[0], self.first.id)
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
# Без контекста INSERT организации для app закрыт (tenancy/0035).
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
# В контексте заранее выделенного id — открыт: так работает кнопка
# «Добавить организацию» (identity.organization_creation).
with transaction.atomic():
self._set_role("chatballs_runtime_app")
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
with tenant_atomic(self.first.id):
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
# Ссылка /join открывается без контекста: приглашение находит каталог
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
issued = issue_invitation(
organization=self.second,
email="invited@example.test",
role=EmployeeRole.EMPLOYEE,
expires_at=timezone.now() + timedelta(days=1),
created_by=None,
)
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(OrganizationInvitation.objects.count(), 0)
invitation = pending_invitation_for_token(issued.token)
self.assertIsNotNone(invitation)
self.assertEqual(invitation.id, issued.invitation.id)
self.assertEqual(invitation.organization.slug, "rls-second")
self.assertIsNone(pending_invitation_for_token("wrong-token"))
preview = invitation_preview(issued.token)
self.assertEqual(preview["organizationName"], "Second")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
@@ -42,7 +42,7 @@ CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(6 * 3600))
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(15 * 60))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
@@ -196,10 +196,6 @@ if TESTING and not CHATBALLS_AI_PROVIDER:
CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int(
os.environ.get("CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0")
) # 0 = без лимита
CHATBALLS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
@@ -14,6 +14,9 @@ urlpatterns = [
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
# Создание организации из интерфейса: адрес без uuid, контекст открывает
# сам сервис. Маршруты с uuid ниже его не перехватывают.
path("api/v1/organizations/", include("chatballs.identity.organization_urls")),
path(
"api/v1/demo-media/avatars/<str:name>",
DemoMediaView.as_view(),
+31 -2
View File
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
@@ -40,6 +40,9 @@ export function App() {
const [user, setUser] = useState<SessionUser | null>(null);
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
// Учётная запись с несколькими организациями после входа выбирает, с какой
// начать. Ссылка на конкретную организацию экран выбора минует.
const [organizationChoice, setOrganizationChoice] = useState<AuthenticatedUser | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
@@ -132,6 +135,11 @@ export function App() {
}, [loadData, user]);
const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => {
if (!initialRoute.organizationPublicId && nextIdentity.memberships.length > 1) {
acceptServerLanguage(nextIdentity.language);
setOrganizationChoice(nextIdentity);
return;
}
const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
@@ -163,6 +171,24 @@ export function App() {
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const chooseOrganization = useCallback((organizationId: string) => {
if (!organizationChoice) return;
setOrganizationChoice(null);
const activeUser = useIdentity(organizationChoice, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, organizationChoice, useIdentity]);
// Новая организация создана: сервер вернул учётную запись с обновлённым
// списком членств — переключаемся в неё сразу, как после приглашения.
const finishOrganizationCreate = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
@@ -170,6 +196,7 @@ export function App() {
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setOrganizationChoice(null);
setIdentity(null);
setUser(null);
setOrganizationPublicId(null);
@@ -194,6 +221,8 @@ export function App() {
<ConfigProvider theme={antdTheme} locale={antdLocale}>
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : organizationChoice ? (
<AuthChooseOrganization identity={organizationChoice} onChoose={chooseOrganization} onLogout={logout} />
) : !identity ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
@@ -217,7 +246,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => 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} />
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => 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} />
)}
</ConfigProvider>
);
+8
View File
@@ -17,10 +17,18 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
return user.capabilities.includes(capability);
}
/** Кто заводит новые организации: администратор установки и тот, кто где-либо
* владелец или администратор. Роль в текущей организации не решает —
* сотрудник здесь может быть владельцем в другой. Сервер проверяет то же. */
export function canCreateOrganization(user: SessionUser): boolean {
return user.isInstanceAdmin || user.memberships.some((membership) => membership.role === "OWNER" || membership.role === "ADMIN");
}
export function canAccess(user: SessionUser, route: RouteKey): boolean {
// «Настройки» — настройки организации: только владелец и админ. Личные
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
if (route === "profile") return true;
if (route === "organizationCreate") return canCreateOrganization(user);
if (isManager(user)) return true;
return EMPLOYEE_ROUTES.has(route);
}
@@ -72,43 +72,13 @@ export function OrganizationSettingsForm({
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
label={t("settings.language")}
value={organization.language}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
onChange={(language) => onChange({ ...organization, language })}
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
/>
</div>
{/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов
три, и выбор лучше видеть целиком, чем разворачивать список. В сетку
полей он не встаёт — она выравнивает поля по нижнему краю, и строка с
подписью ломала бы ряд. */}
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={organization.language === "" ? "active" : ""}
disabled={!canManage}
type="button"
onClick={() => onChange({ ...organization, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={organization.language === item.code ? "active" : ""}
disabled={!canManage}
key={item.code}
lang={item.code}
type="button"
onClick={() => onChange({ ...organization, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
{canManage && (
<div className="administration-actions">
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
body: JSON.stringify({
name: settings.name,
timezone: settings.timezone,
currency: settings.currency,
language: settings.language,
}),
});
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
export type OrganizationSettings = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string;
logoUrl: string | null;
@@ -40,7 +40,7 @@
overflow: hidden;
color: var(--n-1);
background: var(--n-9);
border: 1px dashed var(--n-6);
border: 1px solid var(--n-7);
border-radius: var(--radius-4xl);
font-size: 22px;
font-weight: 700;
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -21,8 +22,6 @@ import {
connectionStatusMeta,
connectionSubtitle,
createdLabel,
dailyCostCents,
dailyCostInput,
deleteAgent,
fetchAgent,
knowledgeLine,
@@ -509,25 +508,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
<select
disabled={busy}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
disabled={busy}
label={t("common.group")}
readOnly={!canManage}
readOnlyText={card.groupName ?? t("common.no_group")}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
/>
</div>
</section>
);
@@ -542,40 +532,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
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 (
<section className="agent-card is-side">
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
disabled={busy}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
disabled={busy}
invalid={missingProvider}
label={t("ai.provider")}
readOnly={!canManage}
readOnlyText={providerName || t("ai.not_selected")}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
@@ -583,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
: <span className="agent-limit-value">{limit || "0.00"}</span>}
<small>USD</small>
</span>
</div>
</div>
</section>
);
@@ -37,7 +37,6 @@ export type AgentCard = {
model: string;
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
limits: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
persona: string;
@@ -63,7 +62,6 @@ export type AgentPatch = Partial<{
tone: string;
instructions: string;
knowledgeIds: number[];
limits: Record<string, unknown>;
}>;
// Страница списка агентов (кадр 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, unknown>): 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);
}
@@ -352,23 +352,6 @@
gap: 14px;
}
.agent-create-modal .readonly-field span,
.agent-knowledge-modal .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.agent-create-modal .readonly-field input,
.agent-create-modal .select-like select {
height: 36px;
background: var(--n-10);
font-size: 13.5px;
line-height: normal;
}
.agent-create-actions {
display: flex;
justify-content: flex-end;
@@ -800,7 +783,6 @@
.agent-field textarea,
.agent-field input,
.agent-field select,
.agent-field .agent-field-static {
width: 100%;
padding: 0 12px;
@@ -814,7 +796,6 @@
}
.agent-field input,
.agent-field select,
.agent-field > .agent-field-static {
height: 36px;
line-height: 34px;
@@ -859,15 +840,15 @@
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
.agent-field textarea:focus,
.agent-field input:focus,
.agent-field select: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-field-control: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);
@@ -1028,23 +1009,6 @@
font-size: 13.5px;
}
.agent-field.is-invalid .agent-field-control {
border-color: var(--error-border);
color: var(--error-text);
}
.agent-field-control select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.agent-field-control .agent-field-static {
flex: 1;
height: auto;
@@ -1080,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 {
@@ -7,6 +7,7 @@ import {
type MarkdownTool,
} from "../../../shared/markdown/markdownTools";
import { EmptyState, LoadingState, Segmented } from "../../../shared/ui";
import { SelectField } from "../../../shared/form-controls";
import { Button } from "../../../shared/ui-controls";
import type { RouteKey } from "../../../types";
import type { AgentRef } from "../../agents/model";
@@ -432,19 +433,12 @@ export function KnowledgeEditorPage({
<aside className="knowledge-editor-rail">
<div className="knowledge-rail-fields">
<label>
<span>{t("ai.category")}</span>
<span className="knowledge-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{catalog.categories.map((item) => (
<option key={item.id} value={item.id}>
{knowledgeCategoryPath(catalog.categories, item.id) || item.name}
</option>
))}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<SelectField
label={t("ai.category")}
value={categoryId}
onChange={setCategoryId}
options={catalog.categories.map((item) => [String(item.id), knowledgeCategoryPath(catalog.categories, item.id) || item.name])}
/>
<label>
<span>{t("ai.short_description")}<small>{t("ai.helps_search_agent")}</small></span>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
@@ -518,36 +518,6 @@
font-weight: 500;
}
.knowledge-select {
position: relative;
display: flex;
height: 34px;
align-items: center;
justify-content: space-between;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 8px;
color: var(--n-1);
font-size: 13px;
}
.knowledge-select select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--n-1);
font-family: inherit;
font-size: 13px;
appearance: none;
cursor: pointer;
}
.knowledge-select svg { position: relative; margin-left: auto; color: var(--n-4); }
.knowledge-rail-fields textarea {
width: 100%;
min-height: 58px;
@@ -0,0 +1,57 @@
import { Icon } from "../../shared/icons";
import { roleLabel } from "../../shared/ui";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
// в той же матовой рамке, что вход и приглашение: список организаций
// строками «логотип · название · роль». Ссылка на конкретную организацию
// этот экран минует: адрес уже сказал, куда идти.
function initials(name: string): string {
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
}
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
identity: AuthenticatedUser;
onChoose: (organizationPublicId: string) => void;
onLogout: () => void;
}) {
const logoutLink = (
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
);
return (
<AuthFrame
title={t("auth.choose_organization_title")}
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
logo="pulse"
width={420}
note={logoutLink}
>
<ul className="auth-card auth-org-list">
{identity.memberships.map((membership) => (
<li key={membership.organizationPublicId}>
<button
type="button"
className="auth-org-item"
onClick={() => onChoose(membership.organizationPublicId)}
>
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
{membership.organizationLogoUrl
? <img src={membership.organizationLogoUrl} alt="" />
: initials(membership.organizationName)}
</span>
<span className="auth-org-copy">
<strong>{membership.organizationName}</strong>
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
</span>
<Icon name="chevronRight" size={16} />
</button>
</li>
))}
</ul>
</AuthFrame>
);
}
@@ -1,4 +1,5 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthChooseOrganization } from "./AuthChooseOrganization";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
@@ -603,6 +603,102 @@
font-weight: 600;
}
/* Выбор организации после входа: строки «логотип · название · роль» в той же
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
только крупнее; строка целиком — кнопка. */
.auth-org-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.auth-org-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px 10px 10px;
color: var(--n-1);
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.auth-org-item:hover,
.auth-org-item:focus-visible {
background: var(--primary-bg);
border-color: var(--primary-border);
outline: none;
}
.auth-org-item > svg {
flex: none;
color: var(--n-5);
}
.auth-org-item:hover > svg {
color: var(--primary-text);
}
.auth-org-mark {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
overflow: hidden;
color: var(--primary-text);
background: var(--primary-bg);
border-radius: 9px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.auth-org-mark.has-logo {
background: var(--surface-card);
border: 1px solid var(--n-7);
}
.auth-org-mark img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.auth-org-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.auth-org-copy strong {
overflow: hidden;
color: var(--n-1);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-org-copy small {
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-back-login {
display: inline-flex;
align-items: center;
@@ -4,6 +4,7 @@ import { CallOverlay } from "./CallOverlay";
import { Composer } from "./Composer";
import { ConversationThread } from "./ConversationThread";
import { DialogList } from "./DialogList";
import { IconButton } from "../../shared/ui-controls";
import {
claimConversation,
closeConversation,
@@ -227,7 +228,16 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
hint={hint}
/>
{!selectedDialog && (
<section className="sales-conversation"><div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div></section>
<section className="sales-conversation">
{/* Свёрнутый список без выбранного диалога: вернуть его больше неоткуда —
шапки переписки, где живёт та же кнопка, здесь нет. */}
{listCollapsed && (
<div className="sales-conversation-head">
<IconButton bare icon="collapseLeft" iconSize={17} label={t("conversations.show_list")} className="list-expand" onClick={() => setListCollapsed(false)} />
</div>
)}
<div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div>
</section>
)}
{selectedDialog && (
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
@@ -53,7 +53,7 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
// Ширина списка: тянется за правый край (280–520px), запоминается в браузере.
const listWidth = useResizableWidth("dialogList", { fallback: 323, min: 280, max: 520 });
return (
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} data-onboarding-target="chat-list" style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
<div className="pane-resizer" role="separator" aria-orientation="vertical" aria-label={t("conversations.conversation_list_width")} title={t("profile.drag_resize_double_click_reset")} onPointerDown={listWidth.onPointerDown} onDoubleClick={listWidth.reset} />
{mobileHeader?.({ total })}
<div className="sales-dialog-list-head">
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { SelectField } from "../../shared/form-controls";
import { Avatar } from "../../shared/ui";
import { SearchInput } from "../../shared/ui-controls";
import { useDebounced } from "../../shared/useDebounced";
@@ -100,20 +101,18 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) {
<p className="employee-create-note">{t("admin.there_no_active_administrators_transfer")}</p>
) : (
<>
<label className="employee-field is-single">
<span>{t("admin.new_owner_administrators_only")}</span>
<span className="employee-transfer-select">
<select value={targetId === null ? "" : String(targetId)} onChange={(event) => setTargetId(Number(event.target.value))}>
{candidates.map((employee) => (
<option value={employee.id} key={employee.id}>
{employee.fullName || employee.email} · {roleBadge(employee.role).text} · {employee.positionTitle}
</option>
))}
</select>
<Icon name="chevron" size={14} strokeWidth={1.8} />
</span>
<div className="employee-field is-single">
<SelectField
label={t("admin.new_owner_administrators_only")}
value={targetId === null ? "" : String(targetId)}
onChange={(value) => setTargetId(Number(value))}
options={candidates.map((employee) => [
String(employee.id),
`${employee.fullName || employee.email} · ${roleBadge(employee.role).text} · ${employee.positionTitle}`,
])}
/>
<small className="employee-transfer-note">{t("admin.list_holds_active_administrators_only")}</small>
</label>
</div>
<div className="employee-transfer-pair">
<div>
@@ -440,39 +440,6 @@
margin-bottom: 0;
}
.employee-transfer-select {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--n-10);
color: var(--n-1);
font-size: 13.5px;
}
.employee-transfer-select select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.employee-transfer-select > svg {
flex: none;
color: var(--n-4);
}
.employee-transfer-note {
color: var(--n-4);
font-size: 12px;
@@ -0,0 +1,19 @@
import { Icon } from "../../shared/icons";
import { t } from "../../i18n";
import { useOnboarding } from "./useOnboarding";
// Пилюля возврата в правом нижнем углу рабочей области. Появляется, когда
// окно закрыто, тур не идёт, а настроено ещё не всё: закрыть онбординг можно
// одним кликом, но дорогу назад стоит оставить на виду.
export function OnboardingLauncher() {
const onboarding = useOnboarding();
if (!onboarding?.showLauncher) return null;
return (
<button className="ob-launcher" type="button" onClick={onboarding.open}>
<Icon name="sparkles" size={17} strokeWidth={1.9} />
{t("onboarding.title")}
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
</button>
);
}
@@ -0,0 +1,256 @@
import { useEffect, useRef } from "react";
import { Icon, LogoIcon } from "../../shared/icons";
import { t } from "../../i18n";
import type { OnboardingStep } from "./steps";
import { useOnboarding, type OnboardingApi } from "./useOnboarding";
// Окно онбординга: приветствие, визард шагов и финальный экран.
//
// Размеры, зазоры и толщины обводок иконок взяты из дизайн-референса
// `design/baseline/Онбординг` — сверяться нужно с ним, а не с соседними
// экранами рабочего места.
//
// Окно живёт внутри `.hub-shell`, а не в портале на body: тур подсвечивает
// элементы приложения теми же координатами от корня, и держать оба слоя в
// одной системе отсчёта проще, чем сводить две.
const FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
/** Ловушка фокуса и клавиши: Tab по кругу, Esc закрывает, ←/→ листают шаги. */
function useModalKeys(active: boolean, onboarding: OnboardingApi | null, cardRef: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
if (!active || !onboarding) return;
cardRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
}, [active, onboarding, cardRef]);
useEffect(() => {
if (!active || !onboarding) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onboarding.dismiss();
return;
}
if (onboarding.view === "steps" && (event.key === "ArrowRight" || event.key === "ArrowLeft")) {
const target = event.target as HTMLElement | null;
// Стрелки внутри поля ввода принадлежат полю, а не визарду.
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) return;
event.preventDefault();
onboarding.goTo(onboarding.index + (event.key === "ArrowRight" ? 1 : -1));
return;
}
if (event.key !== "Tab") return;
const items = [...(cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [])].filter((item) => item.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, onboarding, cardRef]);
}
export function OnboardingOverlay() {
const onboarding = useOnboarding();
const cardRef = useRef<HTMLDivElement>(null);
const visible = Boolean(onboarding && onboarding.view !== null && onboarding.tourIndex === null);
useModalKeys(visible, onboarding, cardRef);
if (!onboarding || !visible) return null;
if (onboarding.view === "welcome") return <WelcomeView onboarding={onboarding} cardRef={cardRef} />;
if (onboarding.view === "done") return <DoneView onboarding={onboarding} cardRef={cardRef} />;
return <StepsView onboarding={onboarding} cardRef={cardRef} />;
}
/** В приветствии крестик лежит поверх градиентной шапки, в визарде стоит в
* строке рядом с чипом шага. */
function CloseButton({ floating = false, onClick }: { floating?: boolean; onClick: () => void }) {
return (
<button className={`ob-close ${floating ? "is-floating" : ""}`} type="button" aria-label={t("common.close")} title={t("common.close")} onClick={onClick}>
<Icon name="close" size={17} strokeWidth={1.8} />
</button>
);
}
function WelcomeView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="ob-overlay is-welcome">
<div className="ob-card ob-welcome" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-welcome-title">
<div className="ob-welcome-head">
<CloseButton floating onClick={onboarding.dismiss} />
<span className="ob-logo-tile"><LogoIcon size={28} /></span>
<p className="ob-eyebrow">{t("onboarding.eyebrow")}</p>
<h2 id="ob-welcome-title">{onboarding.welcomeTitle}</h2>
<p>{t("onboarding.welcome_lead")}</p>
</div>
<div className="ob-facts">
<div><strong>{t("onboarding.fact_time")}</strong><small>{t("onboarding.fact_time_note")}</small></div>
<div><strong>{t("onboarding.fact_key")}</strong><small>{t("onboarding.fact_key_note")}</small></div>
<div><strong>{t("onboarding.fact_data")}</strong><small>{t("onboarding.fact_data_note")}</small></div>
</div>
<div className="ob-welcome-foot">
<button className="ob-text-button" type="button" onClick={onboarding.dismiss}>{t("onboarding.later")}</button>
<button className="ob-primary is-welcome" type="button" onClick={onboarding.startSteps}>
{t("onboarding.start")}
<Icon name="arrow" size={17} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
);
}
function StepsView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
const { steps, index, doneCount } = onboarding;
const step = steps[index];
if (!step) return null;
const total = steps.length;
const percent = Math.round((doneCount / total) * 100);
const last = index === total - 1;
return (
<div className="ob-overlay is-steps">
<div className="ob-card ob-steps" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-step-title">
<aside className="ob-rail">
<div className="ob-rail-head">
<div className="ob-rail-brand"><span><LogoIcon size={22} /></span><strong>{t("onboarding.title")}</strong></div>
<div className="ob-rail-progress-row">
<span>{t("onboarding.progress", { done: doneCount, total })}</span>
<small>{percent}%</small>
</div>
<div className="ob-rail-bar"><i style={{ width: `${percent}%` }} /></div>
</div>
<ol className="ob-rail-list" aria-label={t("onboarding.steps_list")}>
{steps.map((item, position) => (
<li key={item.key}>
<button
className={`ob-rail-step ${position === index ? "is-current" : ""} ${onboarding.state.steps[item.key] ? "is-done" : ""}`}
type="button"
aria-current={position === index ? "step" : undefined}
onClick={() => onboarding.goTo(position)}
>
<span className="ob-rail-mark">
{onboarding.state.steps[item.key] ? <Icon name="check" size={11} strokeWidth={2.6} /> : position + 1}
</span>
<span className="ob-rail-name">{item.name}</span>
<small>{item.time}</small>
</button>
</li>
))}
</ol>
<div className="ob-rail-foot">
<button className="ob-text-button" type="button" onClick={onboarding.dismiss}>{t("onboarding.close_and_self")}</button>
</div>
</aside>
<div className="ob-body">
<header className="ob-body-head">
<span className="ob-chip is-accent">{t("onboarding.step_of", { index: index + 1, total, time: step.time })}</span>
<CloseButton onClick={onboarding.dismiss} />
</header>
{/* На узком экране рейка скрыта — полоса прогресса остаётся здесь. */}
<div className="ob-rail-bar ob-narrow-bar"><i style={{ width: `${percent}%` }} /></div>
<div className="ob-body-scroll">
<h2 id="ob-step-title">{step.title}</h2>
<p className="ob-lead">{step.lead}</p>
<div className="ob-path">
{step.path.map((crumb, position) => (
<span className="ob-crumb" key={crumb}>
{crumb}
{position < step.path.length - 1 && <Icon name="chevronRight" size={12} strokeWidth={2} />}
</span>
))}
</div>
<ol className="ob-instructions">
{step.instructions.map((line, position) => (
<li key={line}><span>{position + 1}</span><span>{line}</span></li>
))}
</ol>
<div className="ob-warning">
<Icon name="alert" size={16} strokeWidth={1.9} />
<p>{step.warning}</p>
</div>
<StepPreview step={step} />
</div>
<footer className="ob-body-foot">
<button className="ob-ghost" type="button" onClick={onboarding.back}>
<Icon name="chevronLeft" size={16} strokeWidth={1.9} />
{t("onboarding.back")}
</button>
<div className="ob-body-actions">
<button className="ob-secondary" type="button" onClick={onboarding.showTour}>
<Icon name="eye" size={16} strokeWidth={1.9} />
{t("onboarding.show_where")}
</button>
<button className="ob-secondary" type="button" onClick={onboarding.openSection}>{t("onboarding.open_section")}</button>
<button className="ob-primary" type="button" onClick={onboarding.next}>
{last ? t("onboarding.finish") : t("onboarding.next")}
<Icon name="arrow" size={16} strokeWidth={1.9} />
</button>
</div>
</footer>
</div>
</div>
</div>
);
}
function StepPreview({ step }: { step: OnboardingStep }) {
const { preview } = step;
return (
<div className="ob-preview">
<div className="ob-preview-head">
<span className="ob-preview-label"><i />{preview.label}</span>
<span className={`ob-chip ${preview.tone === "success" ? "is-success" : "is-waiting"}`}>
{preview.tone === "success" && <Icon name="check" size={11} strokeWidth={2.6} />}
{preview.chip}
</span>
</div>
<div className="ob-preview-rows">
{preview.rows.map((row) => (
<div className="ob-preview-row" key={row.label}>
<span>{row.label}</span>
<b>{row.value}</b>
</div>
))}
</div>
{preview.code && <div className="ob-preview-code"><code>{preview.code}</code></div>}
</div>
);
}
function DoneView({ onboarding, cardRef }: { onboarding: OnboardingApi; cardRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="ob-overlay is-done">
<div className="ob-card ob-done" ref={cardRef} role="dialog" aria-modal="true" aria-labelledby="ob-done-title">
<div className="ob-done-head">
<span className="ob-logo-tile is-success"><Icon name="check" size={24} strokeWidth={2.4} /></span>
<h2 id="ob-done-title">{t("onboarding.done_title")}</h2>
<p>{t("onboarding.done_lead")}</p>
</div>
<div className="ob-summary">
{onboarding.steps.map((step) => (
<div className={`ob-summary-item ${onboarding.state.steps[step.key] ? "is-done" : ""}`} key={step.key}>
<span>{onboarding.state.steps[step.key] && <Icon name="check" size={10} strokeWidth={2.8} />}</span>
<span>{step.name}</span>
</div>
))}
</div>
<div className="ob-done-foot">
<button className="ob-text-button" type="button" onClick={onboarding.restart}>{t("onboarding.restart")}</button>
<button className="ob-primary is-done" type="button" onClick={onboarding.completeToChat}>
{t("onboarding.go_chat")}
<Icon name="arrow" size={17} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { useEffect, useLayoutEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { t } from "../../i18n";
import { useOnboarding } from "./useOnboarding";
// Тур «Показать где»: приложение остаётся видимым, а нужный элемент —
// единственное светлое место на затемнённом экране.
//
// Геометрия слоёв повторяет дизайн-референс `design/baseline/Онбординг`:
// рамка цели с отступом 6px, обводка `0 0 0 2px` и поверх неё пульсирующее
// кольцо, разлетающееся до scale(1.45).
//
// Все слои абсолютные внутри `.hub-shell` и считают координаты от него: цель
// живёт в прокручиваемой колонке, и фиксированный слой уехал бы от неё при
// первой же прокрутке. Затемнение — четыре прямоугольника вокруг цели, а не
// один `box-shadow: 0 0 0 9999px`: так корректно выходит на скриншотах и при
// экспорте страницы.
const CALLOUT_WIDTH = 344;
const PAD = 6;
type Rect = { x: number; y: number; w: number; h: number };
function measure(element: string): { target: Rect; rootW: number; rootH: number } | null {
const root = document.querySelector(".hub-shell");
const target = document.querySelector(`[data-onboarding-target="${element}"]`);
if (!(root instanceof HTMLElement) || !(target instanceof HTMLElement)) return null;
const rootBox = root.getBoundingClientRect();
const targetBox = target.getBoundingClientRect();
if (targetBox.width === 0 && targetBox.height === 0) return null;
return {
target: { x: targetBox.left - rootBox.left, y: targetBox.top - rootBox.top, w: targetBox.width, h: targetBox.height },
rootW: rootBox.width,
rootH: rootBox.height,
};
}
/** Конечные анимации, внутри которых лежит цель, — прежде всего въезд нового
* экрана (`surface-enter`, translateY 6px). Их надо дождаться: пока они идут,
* `getBoundingClientRect()` отдаёт позицию в полёте, причём композитная
* анимация повторяет одно и то же значение несколько кадров подряд, так что
* «подождать, пока перестанет меняться» здесь не работает. Бесконечные
* анимации (свой же пульс рамки, прелоадеры) пропускаем — их не дождаться. */
function settlingAnimations(target: Element): Promise<unknown>[] {
return document.getAnimations().flatMap((animation) => {
const effect = animation.effect;
const owner = effect instanceof KeyframeEffect ? effect.target : null;
if (!owner || !owner.contains(target)) return [];
if (effect!.getComputedTiming().iterations === Infinity) return [];
return [animation.finished.catch(() => undefined)];
});
}
export function OnboardingTour() {
const onboarding = useOnboarding();
const tourIndex = onboarding?.tourIndex ?? null;
const step = tourIndex === null ? null : onboarding?.steps[tourIndex] ?? null;
const element = step?.target.element ?? null;
const hideTour = onboarding?.hideTour;
const [box, setBox] = useState<{ target: Rect; rootW: number; rootH: number } | null>(null);
useLayoutEffect(() => {
if (!element) {
setBox(null);
return;
}
// Экран за окном только что переключился: цель может ещё не смонтироваться,
// а смонтировавшись — ехать вместе с новым маршрутом. Сначала ищем её по
// кадрам, показываем подсветку сразу, а как экран встанет — перемеряем.
// Если цель так и не нашлась (раздел пуст, элемент скрыт узким экраном),
// возвращаем окно: иначе человек остался бы перед приложением без
// единственного пути назад.
let cancelled = false;
let frame = 0;
const apply = () => {
const measured = measure(element);
if (measured) setBox(measured);
};
const locate = (attempts: number) => {
if (cancelled) return;
const target = document.querySelector(`[data-onboarding-target="${element}"]`);
const found = target instanceof HTMLElement && (target.offsetWidth > 0 || target.offsetHeight > 0);
if (!found) {
if (attempts >= 40) {
hideTour?.();
return;
}
frame = requestAnimationFrame(() => locate(attempts + 1));
return;
}
apply();
void Promise.all(settlingAnimations(target)).then(() => {
if (cancelled) return;
frame = requestAnimationFrame(() => {
if (!cancelled) apply();
});
});
};
locate(0);
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [element, hideTour]);
useEffect(() => {
if (!element) return;
const remeasure = () => setBox(measure(element));
window.addEventListener("resize", remeasure);
return () => window.removeEventListener("resize", remeasure);
}, [element]);
useEffect(() => {
if (!element || !hideTour) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
hideTour();
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [element, hideTour]);
if (!onboarding || tourIndex === null || !step || !box) return null;
const { target, rootW, rootH } = box;
const px = (value: number) => `${Math.round(value)}px`;
// Границы рамки цели: отступ 6px со всех сторон.
const top = Math.max(0, target.y - PAD);
const bottom = target.y + target.h + PAD;
const left = Math.max(0, target.x - PAD);
const right = target.x + target.w + PAD;
const frame = { top: px(target.y - PAD), left: px(target.x - PAD), width: px(target.w + PAD * 2), height: px(target.h + PAD * 2) };
// Колаут справа от цели, а если не помещается — слева.
const preferred = target.x + target.w + 20;
const calloutX = preferred + CALLOUT_WIDTH > rootW - 16 ? Math.max(16, target.x - CALLOUT_WIDTH - 20) : preferred;
const calloutY = Math.min(Math.max(16, target.y - 14), Math.max(16, rootH - 250));
return (
<>
<button className="ob-tour-backdrop" type="button" aria-label={t("onboarding.tour_ok")} onClick={onboarding.hideTour} />
<div className="ob-tour-dim" style={{ left: 0, top: 0, right: 0, height: px(top) }} />
<div className="ob-tour-dim" style={{ left: 0, top: px(bottom), right: 0, bottom: 0 }} />
<div className="ob-tour-dim" style={{ left: 0, top: px(top), width: px(left), height: px(bottom - top) }} />
<div className="ob-tour-dim" style={{ left: px(right), top: px(top), right: 0, height: px(bottom - top) }} />
<div className="ob-tour-spot" style={frame} />
<div className="ob-tour-ring" style={frame} />
<div className="ob-tour-callout" style={{ top: px(calloutY), left: px(calloutX) }} role="dialog" aria-label={step.target.title}>
<p className="ob-eyebrow">{t("onboarding.tour_eyebrow", { index: tourIndex + 1 })}</p>
<strong>{step.target.title}</strong>
<p>{step.target.text}</p>
<button className="ob-tour-back" type="button" onClick={onboarding.hideTour} autoFocus>
{t("onboarding.tour_ok")}
<Icon name="chevronLeft" size={15} strokeWidth={1.9} />
</button>
</div>
</>
);
}
@@ -0,0 +1,32 @@
import { api } from "../../api/client";
import { EMPTY_PROGRESS, type OnboardingProgress } from "./steps";
// Состояние онбординга живёт на сервере, а не в localStorage: требование —
// показать визард всем, кто его ещё не закрыл, включая тех, кто работает в
// установке давно. Признак хранится на членстве человека в организации, так
// что у каждого он свой.
export type OnboardingState = {
steps: OnboardingProgress;
dismissedAt: string | null;
completedAt: string | null;
};
export type OnboardingAction = "dismiss" | "complete" | "restart";
const ENDPOINT = "/api/v1/company/onboarding/";
export function fetchOnboarding(): Promise<OnboardingState> {
return api<OnboardingState>(ENDPOINT);
}
export function postOnboardingAction(action: OnboardingAction): Promise<OnboardingState> {
return api<OnboardingState>(ENDPOINT, { method: "POST", body: JSON.stringify({ action }) });
}
/** Состояние до первого ответа сервера: визард ещё не показываем. */
export const PENDING_STATE: OnboardingState = {
steps: EMPTY_PROGRESS,
dismissedAt: null,
completedAt: null,
};
@@ -0,0 +1,255 @@
// Восемь шагов «Начала работы»: от ключа модели до первого диалога с клиентом.
//
// Таблица описательная — она не знает ни про состояние визарда, ни про сеть.
// Порядок шагов равен порядку, в котором установку разумно настраивать: без
// провайдера молчит агент, без агента некому отвечать, без точки входа некуда
// писать.
//
// Ключ шага совпадает с именем факта в ответе `/company/onboarding/`: галочку
// ставит сервер по факту настройки, а не нажатие «Далее».
import type { SettingsSectionKey } from "../settings/sections";
import type { RouteKey } from "../../types";
import { webWidgetSnippet } from "../integrations/model";
import { t } from "../../i18n";
export type OnboardingStepKey =
| "providerConnected"
| "agentActive"
| "knowledgeFilled"
| "connectionBound"
| "widgetPublished"
| "employeeInvited"
| "platformConfigured"
| "firstConversation";
export type OnboardingProgress = Record<OnboardingStepKey, boolean>;
/** Элемент интерфейса, который подсвечивает тур: `data-onboarding-target`. */
export type TourTarget =
| "nav-agents"
| "nav-knowledge"
| "nav-employees"
| "settings-ai"
| "settings-integrations"
| "settings-platform"
| "chat-list";
export type PreviewRow = { label: string; value: string };
export type OnboardingStep = {
key: OnboardingStepKey;
name: string;
time: string;
title: string;
lead: string;
/** Хлебные крошки пути до раздела. */
path: string[];
instructions: string[];
warning: string;
preview: {
label: string;
rows: PreviewRow[];
chip: string;
/** «Ожидание» — жёлтый чип: результат шага виден не сразу. */
tone: "success" | "waiting";
code?: string;
};
/** Куда ведёт «Открыть раздел» и что подсвечивает «Показать где». */
target: { route: RouteKey; section?: SettingsSectionKey; element: TourTarget; title: string; text: string };
/** Раздел «Платформа» есть только у администратора установки. */
instanceOnly?: boolean;
};
// Путь в словаре хранится одной строкой со стрелками: переводчику так понятнее,
// чем три отдельных ключа, а крошки получаются разбором.
function crumbs(path: string): string[] {
return path.split("→").map((crumb) => crumb.trim()).filter(Boolean);
}
const STEPS: OnboardingStep[] = [
{
key: "providerConnected",
name: t("onboarding.s1.name"),
time: t("onboarding.s1.time"),
title: t("onboarding.s1.title"),
lead: t("onboarding.s1.lead"),
path: crumbs(t("onboarding.s1.path")),
instructions: [t("onboarding.s1.do_1"), t("onboarding.s1.do_2"), t("onboarding.s1.do_3"), t("onboarding.s1.do_4")],
warning: t("onboarding.s1.warning"),
preview: {
label: t("onboarding.s1.preview"),
rows: [
{ label: t("onboarding.s1.row1_label"), value: t("onboarding.s1.row1_value") },
{ label: t("onboarding.s1.row2_label"), value: t("onboarding.s1.row2_value") },
{ label: t("onboarding.s1.row3_label"), value: t("onboarding.s1.row3_value") },
],
chip: t("onboarding.s1.chip"),
tone: "success",
},
target: { route: "settings", section: "ai", element: "settings-ai", title: t("onboarding.s1.tour_title"), text: t("onboarding.s1.tour_text") },
},
{
key: "agentActive",
name: t("onboarding.s2.name"),
time: t("onboarding.s2.time"),
title: t("onboarding.s2.title"),
lead: t("onboarding.s2.lead"),
path: crumbs(t("onboarding.s2.path")),
instructions: [t("onboarding.s2.do_1"), t("onboarding.s2.do_2"), t("onboarding.s2.do_3"), t("onboarding.s2.do_4"), t("onboarding.s2.do_5")],
warning: t("onboarding.s2.warning"),
preview: {
label: t("onboarding.s2.preview"),
rows: [
{ label: t("onboarding.s2.row1_label"), value: t("onboarding.s2.row1_value") },
{ label: t("onboarding.s2.row2_label"), value: t("onboarding.s2.row2_value") },
{ label: t("onboarding.s2.row3_label"), value: t("onboarding.s2.row3_value") },
],
chip: t("onboarding.s2.chip"),
tone: "success",
},
target: { route: "agents", element: "nav-agents", title: t("onboarding.s2.tour_title"), text: t("onboarding.s2.tour_text") },
},
{
key: "knowledgeFilled",
name: t("onboarding.s3.name"),
time: t("onboarding.s3.time"),
title: t("onboarding.s3.title"),
lead: t("onboarding.s3.lead"),
path: crumbs(t("onboarding.s3.path")),
instructions: [t("onboarding.s3.do_1"), t("onboarding.s3.do_2"), t("onboarding.s3.do_3"), t("onboarding.s3.do_4")],
warning: t("onboarding.s3.warning"),
preview: {
label: t("onboarding.s3.preview"),
rows: [
{ label: t("onboarding.s3.row1_label"), value: t("onboarding.s3.row1_value") },
{ label: t("onboarding.s3.row2_label"), value: t("onboarding.s3.row2_value") },
{ label: t("onboarding.s3.row3_label"), value: t("onboarding.s3.row3_value") },
],
chip: t("onboarding.s3.chip"),
tone: "success",
},
target: { route: "knowledge", element: "nav-knowledge", title: t("onboarding.s3.tour_title"), text: t("onboarding.s3.tour_text") },
},
{
key: "connectionBound",
name: t("onboarding.s4.name"),
time: t("onboarding.s4.time"),
title: t("onboarding.s4.title"),
lead: t("onboarding.s4.lead"),
path: crumbs(t("onboarding.s4.path")),
instructions: [t("onboarding.s4.do_1"), t("onboarding.s4.do_2"), t("onboarding.s4.do_3"), t("onboarding.s4.do_4"), t("onboarding.s4.do_5")],
warning: t("onboarding.s4.warning"),
preview: {
label: t("onboarding.s4.preview"),
rows: [
{ label: t("onboarding.s4.row1_label"), value: t("onboarding.s4.row1_value") },
{ label: t("onboarding.s4.row2_label"), value: t("onboarding.s4.row2_value") },
{ label: t("onboarding.s4.row3_label"), value: t("onboarding.s4.row3_value") },
],
chip: t("onboarding.s4.chip"),
tone: "success",
},
target: { route: "settings", section: "integrations", element: "settings-integrations", title: t("onboarding.s4.tour_title"), text: t("onboarding.s4.tour_text") },
},
{
key: "widgetPublished",
name: t("onboarding.s5.name"),
time: t("onboarding.s5.time"),
title: t("onboarding.s5.title"),
lead: t("onboarding.s5.lead"),
path: crumbs(t("onboarding.s5.path")),
instructions: [t("onboarding.s5.do_1"), t("onboarding.s5.do_2"), t("onboarding.s5.do_3"), t("onboarding.s5.do_4")],
warning: t("onboarding.s5.warning"),
preview: {
label: t("onboarding.s5.preview"),
rows: [{ label: t("onboarding.s5.row1_label"), value: t("onboarding.s5.row1_value") }],
chip: t("onboarding.s5.chip"),
tone: "success",
// Тег ровно тот, что выдаёт раздел виджета, — с образцовым ключом.
code: webWidgetSnippet("wgt_9f2c41d8"),
},
target: { route: "settings", section: "integrations", element: "settings-integrations", title: t("onboarding.s5.tour_title"), text: t("onboarding.s5.tour_text") },
},
{
key: "employeeInvited",
name: t("onboarding.s6.name"),
time: t("onboarding.s6.time"),
title: t("onboarding.s6.title"),
lead: t("onboarding.s6.lead"),
path: crumbs(t("onboarding.s6.path")),
instructions: [t("onboarding.s6.do_1"), t("onboarding.s6.do_2"), t("onboarding.s6.do_3"), t("onboarding.s6.do_4")],
warning: t("onboarding.s6.warning"),
preview: {
label: t("onboarding.s6.preview"),
rows: [
{ label: t("onboarding.s6.row1_label"), value: t("onboarding.s6.row1_value") },
{ label: t("onboarding.s6.row2_label"), value: t("onboarding.s6.row2_value") },
{ label: t("onboarding.s6.row3_label"), value: t("onboarding.s6.row3_value") },
],
chip: t("onboarding.s6.chip"),
tone: "waiting",
},
target: { route: "employees", element: "nav-employees", title: t("onboarding.s6.tour_title"), text: t("onboarding.s6.tour_text") },
},
{
key: "platformConfigured",
name: t("onboarding.s7.name"),
time: t("onboarding.s7.time"),
title: t("onboarding.s7.title"),
lead: t("onboarding.s7.lead"),
path: crumbs(t("onboarding.s7.path")),
instructions: [t("onboarding.s7.do_1"), t("onboarding.s7.do_2"), t("onboarding.s7.do_3"), t("onboarding.s7.do_4")],
warning: t("onboarding.s7.warning"),
preview: {
label: t("onboarding.s7.preview"),
rows: [
{ label: t("onboarding.s7.row1_label"), value: t("onboarding.s7.row1_value") },
{ label: t("onboarding.s7.row2_label"), value: t("onboarding.s7.row2_value") },
{ label: t("onboarding.s7.row3_label"), value: t("onboarding.s7.row3_value") },
],
chip: t("onboarding.s7.chip"),
tone: "success",
},
target: { route: "settings", section: "platform", element: "settings-platform", title: t("onboarding.s7.tour_title"), text: t("onboarding.s7.tour_text") },
instanceOnly: true,
},
{
key: "firstConversation",
name: t("onboarding.s8.name"),
time: t("onboarding.s8.time"),
title: t("onboarding.s8.title"),
lead: t("onboarding.s8.lead"),
path: crumbs(t("onboarding.s8.path")),
instructions: [t("onboarding.s8.do_1"), t("onboarding.s8.do_2"), t("onboarding.s8.do_3"), t("onboarding.s8.do_4")],
warning: t("onboarding.s8.warning"),
preview: {
label: t("onboarding.s8.preview"),
rows: [
{ label: t("onboarding.s8.row1_label"), value: t("onboarding.s8.row1_value") },
{ label: t("onboarding.s8.row2_label"), value: t("onboarding.s8.row2_value") },
{ label: t("onboarding.s8.row3_label"), value: t("onboarding.s8.row3_value") },
],
chip: t("onboarding.s8.chip"),
tone: "success",
},
target: { route: "chat", element: "chat-list", title: t("onboarding.s8.tour_title"), text: t("onboarding.s8.tour_text") },
},
];
/** Шаги, доступные этому человеку: «Домен и почта» — только администратору
* установки, у остальных шагов семь, и счётчик считает от семи. */
export function visibleOnboardingSteps(isInstanceAdmin: boolean): OnboardingStep[] {
return STEPS.filter((step) => !step.instanceOnly || isInstanceAdmin);
}
export const EMPTY_PROGRESS: OnboardingProgress = {
providerConnected: false,
agentActive: false,
knowledgeFilled: false,
connectionBound: false,
widgetPublished: false,
employeeInvited: false,
platformConfigured: false,
firstConversation: false,
};
@@ -0,0 +1,999 @@
/* Онбординг «Начало работы»: приветствие, визард шагов, тур и точки возврата.
Верстка снята с дизайн-референса `design/baseline/Онбординг`: размеры, зазоры,
толщины обводок иконок и ховеры — оттуда, отсебятины здесь быть не должно.
Своих цветов нет — всё через токены `styles/base.css`, поэтому тёмная тема
получается сама. */
/* ── Оверлей и карточка ─────────────────────────────────────────────────── */
.ob-overlay {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--scrim);
backdrop-filter: blur(3px);
animation: ob-fade 200ms ease-out;
}
.ob-overlay.is-welcome { animation-duration: 220ms; }
.ob-overlay.is-steps { padding: 34px; }
.ob-card {
border: 1px solid var(--border-default);
border-radius: 18px;
background: var(--surface-card);
box-shadow: var(--modal-shadow);
overflow: hidden;
animation: ob-rise 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ob-welcome {
width: min(700px, 100%);
animation-duration: 320ms;
}
.ob-done { width: min(620px, 100%); }
.ob-steps {
width: min(1020px, 100%);
height: min(700px, 100%);
display: flex;
}
@keyframes ob-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ob-rise {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@keyframes ob-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0; transform: scale(1.45); }
}
/* ── Общие элементы ─────────────────────────────────────────────────────── */
.ob-close {
width: 32px;
height: 32px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-4);
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
/* В приветствии кнопка лежит поверх градиентной шапки, в визарде — обычный
элемент строки с чипом шага. */
.ob-close.is-floating {
position: absolute;
top: 18px;
right: 18px;
}
.ob-close:hover {
background: var(--n-9);
color: var(--n-2);
}
.ob-eyebrow {
display: block;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--primary-text);
}
.ob-logo-tile {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--primary);
color: #fff;
box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.ob-logo-tile.is-success {
background: var(--success);
box-shadow: none;
}
.ob-text-button {
border: 0;
padding: 0;
background: none;
color: var(--n-4);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color 140ms ease;
}
.ob-text-button:hover { color: var(--n-2); }
.ob-primary {
height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent);
transition: background 140ms ease;
}
.ob-primary:hover { background: var(--primary-hover); }
/* Приветствие: кнопка крупнее и с выраженной тенью. */
.ob-primary.is-welcome {
height: 42px;
gap: 9px;
padding: 0 22px;
border-radius: 10px;
font-size: 14px;
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 32%, transparent);
}
/* Финальный экран: та же крупная форма, но без тени. */
.ob-primary.is-done {
height: 40px;
gap: 9px;
padding: 0 20px;
border-radius: 10px;
font-size: 14px;
box-shadow: none;
}
.ob-secondary {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border: 1px solid var(--n-6);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.ob-secondary:hover {
background: var(--n-10);
color: var(--n-2);
}
.ob-ghost {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.ob-ghost:hover {
background: var(--n-9);
color: var(--n-2);
}
.ob-chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
font-weight: 700;
white-space: nowrap;
}
.ob-chip.is-accent {
height: 24px;
gap: 8px;
padding: 0 10px;
border: 1px solid var(--primary-border);
background: var(--primary-bg);
color: var(--primary-text);
font-size: 11.5px;
letter-spacing: 0.02em;
}
.ob-chip.is-success,
.ob-chip.is-waiting {
height: 21px;
gap: 5px;
padding: 0 9px;
border: 1px solid transparent;
font-size: 11px;
}
.ob-chip.is-success {
border-color: var(--success-border);
background: var(--success-bg);
color: var(--success-text);
}
.ob-chip.is-waiting {
border-color: var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
}
/* ── Приветствие ────────────────────────────────────────────────────────── */
.ob-welcome-head {
position: relative;
padding: 44px 48px 34px;
background: linear-gradient(180deg, var(--primary-bg), var(--surface-card));
}
.ob-welcome-head .ob-logo-tile { margin-bottom: 22px; }
.ob-welcome-head .ob-eyebrow { margin-bottom: 11px; }
.ob-welcome-head h2 {
margin: 0;
max-width: 520px;
font-size: 29px;
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.025em;
color: var(--n-1);
text-wrap: pretty;
}
.ob-welcome-head p {
margin: 14px 0 0;
max-width: 520px;
font-size: 14.5px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
background: var(--border-default);
}
.ob-facts > div {
padding: 17px 20px;
background: var(--surface-card);
}
.ob-facts strong {
display: block;
font-size: 13.5px;
font-weight: 700;
color: var(--n-1);
}
.ob-facts small {
display: block;
margin-top: 3px;
font-size: 12.5px;
line-height: 1.45;
color: var(--n-4);
}
.ob-welcome-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 48px 26px;
}
.ob-welcome-foot .ob-text-button {
height: 38px;
padding: 0 4px;
}
/* ── Визард: левая рейка ────────────────────────────────────────────────── */
.ob-rail {
width: 300px;
flex: none;
display: flex;
flex-direction: column;
background: var(--n-10);
border-right: 1px solid var(--border-default);
}
.ob-rail-head {
flex: none;
padding: 24px 22px 18px;
}
.ob-rail-brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 18px;
font-size: 14px;
font-weight: 700;
color: var(--n-1);
}
.ob-rail-brand > span:first-child {
width: 24px;
height: 24px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
}
.ob-rail-progress-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.ob-rail-progress-row span {
font-size: 12px;
font-weight: 600;
color: var(--n-3);
}
.ob-rail-progress-row small {
font-size: 11.5px;
font-weight: 600;
color: var(--n-4);
}
.ob-rail-bar {
height: 4px;
border-radius: var(--radius-pill);
background: var(--n-8);
overflow: hidden;
}
.ob-rail-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--primary);
transition: width 280ms ease;
}
.ob-rail-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 2px 12px 12px;
list-style: none;
overflow-y: auto;
}
.ob-rail-step {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 9px 11px;
border: 0;
border-radius: 9px;
background: transparent;
text-align: left;
cursor: pointer;
}
.ob-rail-step.is-current {
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.ob-rail-mark {
width: 22px;
height: 22px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--n-7);
border-radius: 50%;
background: var(--n-8);
color: var(--n-4);
font-size: 11.5px;
font-weight: 700;
}
.ob-rail-step.is-current .ob-rail-mark {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
/* Пройденный шаг остаётся зелёным, даже когда он же текущий: галочка важнее
номера — так в референсе. */
.ob-rail-step.is-done .ob-rail-mark {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.ob-rail-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--n-2);
}
.ob-rail-step.is-done .ob-rail-name { color: var(--n-3); }
.ob-rail-step.is-current .ob-rail-name {
font-weight: 700;
color: var(--n-1);
}
.ob-rail-step small {
flex: none;
font-size: 11px;
font-weight: 500;
color: var(--n-5);
}
.ob-rail-foot {
flex: none;
padding: 12px 22px 20px;
border-top: 1px solid var(--border-default);
}
.ob-rail-foot .ob-text-button { font-size: 12.5px; }
/* Полоса прогресса в правой части нужна только там, где рейка скрыта. */
.ob-narrow-bar { display: none; }
/* ── Визард: правая часть ───────────────────────────────────────────────── */
.ob-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ob-body-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 22px 32px 0;
}
.ob-body-scroll {
flex: 1;
min-height: 0;
padding: 14px 32px 26px;
overflow-y: auto;
}
.ob-body-scroll h2 {
margin: 0;
font-size: 23px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.022em;
color: var(--n-1);
text-wrap: pretty;
}
.ob-lead {
margin: 10px 0 0;
max-width: 560px;
font-size: 13.5px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-path {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 18px 0 0;
padding: 8px 11px;
border: 1px solid var(--border-default);
border-radius: 9px;
background: var(--n-10);
}
.ob-crumb {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 600;
color: var(--n-2);
}
.ob-crumb svg { color: var(--n-5); }
.ob-instructions {
display: flex;
flex-direction: column;
gap: 13px;
margin: 22px 0 0;
padding: 0;
list-style: none;
}
.ob-instructions li {
display: flex;
align-items: flex-start;
gap: 12px;
}
.ob-instructions li span:first-child {
width: 21px;
height: 21px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 1px;
border: 1px solid var(--primary-border);
border-radius: 50%;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 11px;
font-weight: 700;
}
.ob-instructions li span + span {
flex: 1;
min-width: 0;
font-size: 13.5px;
line-height: 1.55;
color: var(--n-2);
text-wrap: pretty;
}
.ob-warning {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 22px;
padding: 12px 14px;
border: 1px solid var(--warning-border);
border-radius: 10px;
background: var(--warning-bg);
color: var(--warning-text);
}
.ob-warning svg {
flex: none;
margin-top: 1px;
}
.ob-warning p {
flex: 1;
margin: 0;
font-size: 12.5px;
font-weight: 500;
line-height: 1.5;
text-wrap: pretty;
}
.ob-preview {
margin-top: 22px;
border: 1px solid var(--border-default);
border-radius: 12px;
background: var(--n-10);
overflow: hidden;
}
.ob-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-preview-label {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--n-4);
}
.ob-preview-label i {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--n-6);
}
.ob-preview-rows {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-default);
}
.ob-preview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 14px;
background: var(--n-10);
}
.ob-preview-row span {
font-size: 12px;
color: var(--n-4);
}
.ob-preview-row b {
min-width: 0;
font-size: 12.5px;
font-weight: 600;
color: var(--n-2);
text-align: right;
}
.ob-preview-code {
padding: 12px 14px;
border-top: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-preview-code code {
display: block;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.6;
color: var(--n-3);
word-break: break-all;
}
.ob-body-foot {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 32px 18px;
border-top: 1px solid var(--border-default);
background: var(--surface-card);
}
.ob-body-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Финальный экран ────────────────────────────────────────────────────── */
.ob-done-head { padding: 40px 40px 26px; }
.ob-done-head .ob-logo-tile { margin-bottom: 20px; }
.ob-done-head h2 {
margin: 0;
font-size: 25px;
font-weight: 700;
letter-spacing: -0.022em;
line-height: 1.2;
color: var(--n-1);
}
.ob-done-head p {
margin: 12px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--n-3);
text-wrap: pretty;
}
.ob-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
background: var(--border-default);
}
.ob-summary-item {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: var(--surface-card);
}
.ob-summary-item > span:first-child {
width: 20px;
height: 20px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--n-7);
border-radius: 50%;
background: var(--n-7);
color: #fff;
}
.ob-summary-item.is-done > span:first-child {
border-color: var(--success);
background: var(--success);
}
.ob-summary-item > span + span {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--n-4);
}
.ob-summary-item.is-done > span + span {
font-weight: 600;
color: var(--n-2);
}
.ob-done-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 40px 26px;
}
/* ── Тур «Показать где» ─────────────────────────────────────────────────── */
.ob-tour-backdrop {
position: absolute;
inset: 0;
z-index: 39;
border: 0;
padding: 0;
background: transparent;
cursor: default;
}
.ob-tour-dim {
position: absolute;
z-index: 40;
background: var(--scrim);
pointer-events: none;
}
.ob-tour-spot,
.ob-tour-ring {
position: absolute;
z-index: 41;
border-radius: 11px;
pointer-events: none;
}
.ob-tour-spot { box-shadow: 0 0 0 2px var(--primary); }
.ob-tour-ring {
border: 2px solid var(--primary);
animation: ob-pulse 1.9s ease-out infinite;
}
.ob-tour-callout {
position: absolute;
z-index: 42;
width: 344px;
padding: 16px 18px 14px;
border: 1px solid var(--primary-border);
border-radius: 13px;
background: var(--surface-card);
box-shadow: 0 20px 48px -12px rgba(16, 18, 22, 0.45);
animation: ob-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ob-tour-callout .ob-eyebrow {
margin: 0 0 8px;
font-size: 11px;
letter-spacing: 0.08em;
}
.ob-tour-callout strong {
display: block;
font-size: 14.5px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--n-1);
}
.ob-tour-callout p {
margin: 7px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--n-3);
text-wrap: pretty;
}
.ob-tour-back {
width: 100%;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 14px;
border: 0;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease;
}
.ob-tour-back:hover { background: var(--primary-hover); }
/* ── Точки возврата ─────────────────────────────────────────────────────── */
.ob-launcher {
position: absolute;
right: 24px;
bottom: 24px;
z-index: 20;
height: 44px;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0 18px 0 15px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-pill);
background: var(--surface-card);
color: var(--primary-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 28px -8px rgba(16, 18, 22, 0.3);
transition: background 140ms ease;
}
.ob-launcher:hover { background: var(--primary-bg); }
.ob-launcher small {
padding: 1px 7px;
border-radius: var(--radius-pill);
background: var(--primary-bg);
font-size: 11.5px;
font-weight: 700;
}
/* Ссылка «Начало работы» внизу субменю «Настроек». */
.settings-subnav-onboarding {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 38px;
padding: 0 11px;
border: 1px solid var(--primary-border);
border-radius: 9px;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 140ms ease;
}
.settings-subnav-onboarding:hover { background: color-mix(in srgb, var(--accent) 16%, var(--surface-card)); }
.settings-subnav-onboarding span { flex: 1; }
.settings-subnav-onboarding small {
font-size: 11.5px;
font-weight: 700;
}
.settings-subnav-onboarding-note {
margin: 7px 11px 2px;
font-size: 11.5px;
line-height: 1.45;
color: var(--n-4);
}
/* ── Узкий экран ────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.ob-overlay { padding: 16px; }
.ob-steps {
width: calc(100% - 32px);
height: auto;
max-height: 100%;
}
.ob-rail { display: none; }
.ob-narrow-bar {
display: block;
flex: none;
margin: 12px 32px 0;
}
.ob-body-foot {
flex-wrap: wrap;
row-gap: 10px;
}
.ob-body-actions {
flex-wrap: wrap;
width: 100%;
justify-content: flex-end;
}
.ob-tour-callout { width: min(344px, calc(100% - 32px)); }
}
@media (prefers-reduced-motion: reduce) {
.ob-overlay,
.ob-card,
.ob-tour-callout,
.ob-tour-ring {
animation: none;
}
.ob-rail-bar i { transition: none; }
}
@@ -0,0 +1,200 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { SettingsSectionKey } from "../settings/sections";
import type { RouteKey, SessionUser } from "../../types";
import { isManager } from "../../auth/access";
import { PENDING_STATE, fetchOnboarding, postOnboardingAction, type OnboardingState } from "./api";
import { visibleOnboardingSteps, type OnboardingStep } from "./steps";
import { t } from "../../i18n";
// Состояние онбординга на одну сессию рабочего места.
//
// Живёт контекстом, а не пропсами: визард монтируется в Shell, а ссылка
// «Начало работы» — в субменю «Настроек», глубоко в дереве страницы. Тянуть
// один и тот же прогресс двумя маршрутами дороже, чем один провайдер.
export type OnboardingView = "welcome" | "steps" | "done" | null;
export type OnboardingApi = {
/** Шаги, доступные этому человеку: у не-администратора установки их семь. */
steps: OnboardingStep[];
state: OnboardingState;
view: OnboardingView;
index: number;
/** Шаг, чьё место показывает тур; модалка на это время прячется. */
tourIndex: number | null;
doneCount: number;
/** Заголовок приветствия числом-словом, как в макете: «Восемь шагов…». */
welcomeTitle: string;
/** Есть что донастроить и человек уже закрывал визард — показываем пилюлю. */
showLauncher: boolean;
available: boolean;
open: () => void;
openWelcome: () => void;
startSteps: () => void;
goTo: (index: number) => void;
next: () => void;
back: () => void;
showTour: () => void;
hideTour: () => void;
openSection: () => void;
dismiss: () => void;
complete: () => void;
completeToChat: () => void;
restart: () => void;
};
const OnboardingContext = createContext<OnboardingApi | null>(null);
export const OnboardingProvider = OnboardingContext.Provider;
/** Внутри Shell контекст есть всегда; заглушка нужна тестам отдельных страниц. */
export function useOnboarding(): OnboardingApi | null {
return useContext(OnboardingContext);
}
export function useOnboardingState({ user, setRoute, openSettings }: {
user: SessionUser;
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
}): OnboardingApi {
const available = isManager(user);
const steps = useMemo(() => visibleOnboardingSteps(user.isInstanceAdmin), [user.isInstanceAdmin]);
const [state, setState] = useState<OnboardingState>(PENDING_STATE);
const [loaded, setLoaded] = useState(false);
const [view, setView] = useState<OnboardingView>(null);
const [index, setIndex] = useState(0);
const [tourIndex, setTourIndex] = useState<number | null>(null);
const refresh = useCallback(async () => {
try {
setState(await fetchOnboarding());
} catch {
// Молчим: онбординг — подсказка, а не рабочая функция. Не смог
// загрузиться — просто не показывается.
}
}, []);
useEffect(() => {
if (!available) return;
let cancelled = false;
fetchOnboarding()
.then((payload) => {
if (cancelled) return;
setState(payload);
setLoaded(true);
// Ни закрывал, ни проходил — значит видит визард при этом входе,
// сколько бы он ни работал в системе до сегодня.
if (!payload.dismissedAt && !payload.completedAt) setView("welcome");
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [available]);
const act = useCallback(async (action: "dismiss" | "complete" | "restart") => {
try {
setState(await postOnboardingAction(action));
} catch {
// Не записалось — визард всё равно закрыт до конца сессии.
}
}, []);
const doneCount = steps.filter((step) => state.steps[step.key]).length;
const goTo = useCallback((next: number) => {
setIndex(Math.max(0, Math.min(steps.length - 1, next)));
setTourIndex(null);
}, [steps.length]);
// Стабильная ссылка: тур вызывает её из эффекта замера, и меняющаяся
// функция перезапускала бы замер на каждом рендере Shell.
const hideTour = useCallback(() => setTourIndex(null), []);
const api: OnboardingApi = {
steps,
state,
view,
index,
tourIndex,
doneCount,
// Шагов бывает семь или восемь — по одному заголовку на каждый случай:
// в макете число написано словом, а не цифрой.
welcomeTitle: steps.length === 7 ? t("onboarding.welcome_title_7") : t("onboarding.welcome_title_8"),
available,
showLauncher: available && loaded && view === null && tourIndex === null && doneCount < steps.length,
open: () => {
void refresh();
setIndex(0);
setView("steps");
},
openWelcome: () => {
void refresh();
setIndex(0);
setView("welcome");
},
startSteps: () => {
setIndex(0);
setView("steps");
},
goTo,
next: () => {
if (index >= steps.length - 1) {
void refresh();
setView("done");
return;
}
goTo(index + 1);
},
back: () => {
if (index === 0) {
setView("welcome");
return;
}
goTo(index - 1);
},
showTour: () => {
const step = steps[index];
if (!step) return;
// Раздел «Настроек» открывается одним вызовом вместе с маршрутом.
if (step.target.section) openSettings(step.target.section);
else setRoute(step.target.route);
setTourIndex(index);
},
hideTour,
openSection: () => {
const step = steps[index];
if (!step) return;
if (step.target.section) openSettings(step.target.section);
else setRoute(step.target.route);
// Закрываем окно, но не отмечаем «закрыл»: человек ушёл настраивать и
// вернётся пилюлей в углу.
setTourIndex(null);
setView(null);
},
dismiss: () => {
setTourIndex(null);
setView(null);
void act("dismiss");
},
complete: () => {
setTourIndex(null);
setView(null);
void act("complete");
},
completeToChat: () => {
setTourIndex(null);
setView(null);
void act("complete");
setRoute("chat");
},
restart: () => {
setIndex(0);
setView("welcome");
void act("restart");
},
};
return api;
}
@@ -0,0 +1,142 @@
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { OrganizationLogoField } from "../administration/OrganizationLogoField";
import { FormField, SelectField } from "../../shared/form-controls";
import { BackLink, Button } from "../../shared/ui-controls";
import { timezoneLabel } from "../../shared/utils";
import { t } from "../../i18n";
import type { AuthenticatedUser, SessionUser } from "../../types";
import {
createOrganization,
loadOrganizationCreateOptions,
uploadNewOrganizationLogo,
type OrganizationCreateOptions,
type OrganizationDraft,
} from "./api";
// Страница создания организации (переключатель A1 → «Добавить организацию»).
// Те же поля, что в «Настройках → Организация»: имя, часовой пояс, валюта,
// язык, логотип. Создавший становится владельцем и сразу переключается в
// новую организацию — ответ сервера уже содержит обновлённый список членств.
type FieldErrors = Partial<Record<keyof OrganizationDraft, string>>;
export function OrganizationCreatePage({ user, onCreated, onBack }: {
user: SessionUser;
onCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [options, setOptions] = useState<OrganizationCreateOptions | null>(null);
const [draft, setDraft] = useState<OrganizationDraft>({
name: "",
// Новая организация наследует региональные параметры текущей: чаще всего
// человек заводит вторую компанию там же, где первую.
timezone: "Europe/Moscow",
language: "",
});
const [logo, setLogo] = useState<File | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
loadOrganizationCreateOptions()
.then((payload) => { if (active) setOptions(payload); })
.catch((requestError) => {
if (active) setError(requestError instanceof ApiError ? requestError.message : t("common.request_failed"));
});
return () => { active = false; };
}, []);
const logoPreview = useMemo(() => (logo ? URL.createObjectURL(logo) : null), [logo]);
useEffect(() => () => { if (logoPreview) URL.revokeObjectURL(logoPreview); }, [logoPreview]);
const valid = draft.name.trim() !== "" && !submitting;
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid) return;
setSubmitting(true);
setError("");
setErrors({});
let created;
try {
created = await createOrganization(draft);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("organizations.create_failed"));
}
setSubmitting(false);
return;
}
if (logo) {
// Логотип — уже в созданную организацию. Его неудача организацию не
// отменяет: человек попадает в неё и доложит логотип в «Настройках».
try {
await uploadNewOrganizationLogo(created.organizationPublicId, logo);
} catch {
window.setTimeout(() => window.alert(t("organizations.logo_failed")), 0);
}
}
onCreated(created.user, created.organizationPublicId);
}
const timezones = options?.timezones ?? [draft.timezone];
const languages = options?.languages ?? [];
return (
<div className="organization-create">
<BackLink label={user.organizationName || "Chatballs"} onClick={onBack} />
<header className="organization-create-head">
<h2>{t("organizations.create_title")}</h2>
<p>{t("organizations.create_lead")}</p>
</header>
<form className="administration-card administration-organization" onSubmit={submit}>
<OrganizationLogoField
logoUrl={logoPreview}
name={draft.name}
disabled={false}
saving={submitting}
onUpload={setLogo}
onRemove={() => setLogo(null)}
/>
<div className="administration-fields">
<div className="administration-field-wide">
<FormField
label={t("admin.organization_name")}
value={draft.name}
placeholder={t("organizations.name_placeholder")}
error={errors.name}
onChange={(name) => setDraft({ ...draft, name })}
/>
</div>
<SelectField
label={t("admin.time_zone")}
value={draft.timezone}
onChange={(timezone) => setDraft({ ...draft, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("settings.language")}
value={draft.language}
onChange={(language) => setDraft({ ...draft, language })}
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
/>
</div>
{error && <div className="administration-message error" role="alert">{error}</div>}
<div className="administration-actions">
<Button variant="secondary" disabled={submitting} onClick={onBack}>{t("common.cancel")}</Button>
<Button type="submit" variant="primary" icon="plus" disabled={!valid}>
{submitting ? t("organizations.creating") : t("organizations.create_submit")}
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,44 @@
import { api, apiUpload } from "../../api/client";
import type { AuthenticatedUser } from "../../types";
// Создание организации идёт без организации в адресе: её ещё нет. Клиент
// такие пути не переписывает (namespace «organizations» не тенантный).
const BASE = "/api/v1/organizations/";
export type OrganizationCreateOptions = {
timezones: string[];
languages: Array<{ code: string; label: string }>;
};
// Валюты в черновике нет: в продукте нет ни одной суммы в валюте организации,
// сервер проставляет её сам.
export type OrganizationDraft = {
name: string;
timezone: string;
// Пустая строка — «как в установке».
language: string;
};
export type OrganizationCreated = {
user: AuthenticatedUser;
organizationPublicId: string;
};
export function loadOrganizationCreateOptions(): Promise<OrganizationCreateOptions> {
return api<OrganizationCreateOptions>(`${BASE}options/`);
}
export function createOrganization(draft: OrganizationDraft): Promise<OrganizationCreated> {
return api<OrganizationCreated>(BASE, {
method: "POST",
body: JSON.stringify(draft),
});
}
/** Логотип загружается уже в созданную организацию — по её полному адресу,
* а не по активной: интерфейс ещё в прежней. */
export function uploadNewOrganizationLogo(organizationPublicId: string, file: File): Promise<unknown> {
const form = new FormData();
form.append("file", file);
return apiUpload(`${BASE}${organizationPublicId}/company/administration/logo/`, form);
}
@@ -0,0 +1,29 @@
/* Страница создания организации (переключатель A1 → «Добавить организацию»).
Карточка формы — та же, что «Настройки → Организация»; страница лишь даёт
ей заголовок, подводку и ширину раздела настроек. */
.organization-create {
width: 100%;
max-width: 1000px;
margin-inline: auto;
padding: 28px 40px 48px;
}
.organization-create-head {
margin: 14px 0 22px;
}
.organization-create-head h2 {
margin: 0;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
.organization-create-head p {
max-width: 560px;
margin: 5px 0 0;
color: var(--n-4);
font-size: 13px;
line-height: 1.5;
}
@@ -94,7 +94,7 @@ export function ProfileAppearanceCard({ user, onUserUpdated }: { user: SessionUs
<input
disabled={saving}
maxLength={7}
placeholder="#1677ff"
placeholder="#0f9b8e"
value={customAccent}
onChange={(event) => setCustomAccent(event.target.value)}
onKeyDown={(event) => {
@@ -154,23 +154,8 @@
color: var(--error-text);
}
/* Поля профиля: подпись 12.5/600 --n-3, поле 36px на подложке --n-10. */
.profile-page .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.profile-page .readonly-field input,
.profile-page .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Поле профиля моноширинным набором (идентификаторы) — на полступени мельче.
Сам скин поля общий, из shared/form-controls.css. */
.profile-page .readonly-field input.mono {
font-size: 13px;
@@ -866,11 +851,6 @@
gap: 14px;
}
.profile-page.is-mobile .readonly-field input {
height: 44px;
font-size: 15px;
}
.profile-page.is-mobile .readonly-field input.mono {
font-size: 14px;
}
@@ -19,8 +19,17 @@ import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { useOnboarding } from "../onboarding/useOnboarding";
import type { TourTarget } from "../onboarding/steps";
import { t } from "../../i18n";
// Раздел «Настроек» как цель тура онбординга: имена совпадают с таблицей шагов.
const SECTION_TOUR_TARGET: Partial<Record<SettingsSectionKey, TourTarget>> = {
ai: "settings-ai",
integrations: "settings-integrations",
platform: "settings-platform",
};
// «Настройки» (дизайн-базлайн v2, кадры N1–N7): субменю разделов 240px и один
// раздел на экране; ниже субменю — переходы на отдельные экраны «База знаний» и
// «Аудит действий». Профиль пользователя сюда не входит — это отдельная
@@ -56,6 +65,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const active = section ?? (mobile ? null : DEFAULT_SETTINGS_SECTION);
// Разделы про саму установку видит только её администратор.
const sections = visibleSettingsSections(user);
const onboarding = useOnboarding();
const current = sections.find((item) => item.key === active) ?? null;
const links = (
@@ -65,6 +75,19 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
<button className="settings-subnav-link" type="button" onClick={() => setRoute("administrationAudit")}>
<Icon name="list" size={16} strokeWidth={1.9} /><span>{t("settings.action_audit")}</span><Icon name="external" size={13} strokeWidth={2} />
</button>
{/* Возврат в онбординг: он открывается в любой момент, а не только при
первом входе. */}
{onboarding?.available && (
<>
<div className="settings-subnav-divider" />
<button className="settings-subnav-onboarding" type="button" onClick={onboarding.open}>
<Icon name="sparkles" size={16} strokeWidth={1.9} />
<span>{t("onboarding.title")}</span>
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
</button>
<p className="settings-subnav-onboarding-note">{t("onboarding.settings_hint")}</p>
</>
)}
</>
);
@@ -77,6 +100,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
className={`settings-subnav-item ${item.key === active ? "is-active" : ""}`}
key={item.key}
type="button"
data-onboarding-target={SECTION_TOUR_TARGET[item.key]}
onClick={() => openSection(item.key)}
>
<Icon name={item.icon} size={16} strokeWidth={1.9} />
@@ -229,33 +229,14 @@
font-size: 12.5px;
}
/* Карточка и поля раздела: подпись 12.5/600 --n-3, поле 36px на подложке
--n-10 (кадры N1/N6). */
/* Карточка раздела (кадры N1/N6). Поля — общий скин из
shared/form-controls.css, своей копии у раздела больше нет. */
.settings-content .administration-card {
padding: 22px 24px;
box-shadow: none;
}
.settings-content .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like input,
.settings-content .select-like select,
.settings-content .form-field.is-editable input {
height: 36px;
color: var(--n-1);
background: var(--n-10);
border-color: var(--n-7);
font-size: 13.5px;
}
/* Кнопки раздела: primary и вторичная 34px радиусом 8 (кадры N1–N7). */
.settings-content .primary-button,
@@ -913,9 +894,6 @@
padding-inline: 14px;
}
.settings-content .readonly-field input,
.settings-content .readonly-field select,
.settings-content .select-like select,
.settings-content .settings-group-create input {
height: 44px;
font-size: 15px;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../shared/icons";
import { Segmented } from "../../shared/ui";
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { MarkdownContent } from "../help-center/MarkdownContent";
@@ -444,24 +445,18 @@ export function PortalArticleEditor({
<aside className="portal-editor-rail">
<div className="portal-rail-fields">
<label>
<span>{t("admin.section")}</span>
<span className="portal-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<label>
<span>{t("portals.language")}</span>
<span className="portal-select">
<select value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<SelectField
label={t("admin.section")}
value={categoryId}
onChange={setCategoryId}
options={categories.map((item) => [String(item.id), item.name])}
/>
<SelectField
label={t("portals.language")}
value={locale}
onChange={setLocale}
options={LOCALE_OPTIONS.map(([value, label]) => [value, label])}
/>
<label>
<span>{t("ai.short_description")}<small>{t("portals.shown_list_search")}</small></span>
<textarea value={summary} onChange={(event) => setSummary(event.target.value)} />
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Button, CopyButton } from "../../shared/ui-controls";
import {
@@ -87,16 +88,16 @@ export function PortalBasicsSettings({
{fieldErrors.slug && <small className="portal-field-error">{fieldErrors.slug}</small>}
</div>
<label className="portal-field is-narrow">
<span className="portal-field-label">{t("portals.primary_language")}</span>
<span className="portal-select">
<select disabled={!canManage} value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<div className="portal-field is-narrow">
<SelectField
label={t("portals.primary_language")}
disabled={!canManage}
value={locale}
onChange={setLocale}
options={LOCALE_OPTIONS.map(([value, label]) => [value, label])}
/>
<small>{t("portals.language_filled_new_articles_articles")}</small>
</label>
</div>
<div className="portal-final-link">
<span>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import {
portalErrorMessage,
@@ -43,21 +44,16 @@ export function PortalWidgetSettings({
return (
<div className="portal-settings-card">
<label className="portal-field is-narrow">
<span className="portal-field-label">{t("common.web_widget_2")}</span>
<span className="portal-select">
<select
disabled={!canManage}
value={String(widgetId ?? "")}
onChange={(event) => setWidgetId(event.target.value ? Number(event.target.value) : null)}
>
<option value="">{t("portals.do_not_show")}</option>
{widgets.map((widget) => <option key={widget.id} value={widget.id}>{widget.name}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<div className="portal-field is-narrow">
<SelectField
label={t("common.web_widget_2")}
disabled={!canManage}
value={String(widgetId ?? "")}
onChange={(value) => setWidgetId(value ? Number(value) : null)}
options={[["", t("portals.do_not_show")], ...widgets.map((widget): [string, string] => [String(widget.id), widget.name])]}
/>
<small>{t("portals.widget_available_anonymous_portal_visitors")}</small>
</label>
</div>
{canManage && (
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void save()}>{t("portals.save_widget")}</Button>
@@ -477,8 +477,6 @@
font-weight: 500;
}
.portal-rail-fields .portal-select { height: 34px; font-size: 13px; }
.portal-rail-fields textarea {
width: 100%;
/* Краткое описание видно в списке статей и в поиске портала — его пишут
@@ -148,8 +148,7 @@
}
.portal-field > input:focus,
.portal-field textarea:focus,
.portal-select:focus-within {
.portal-field textarea:focus {
border-color: var(--primary);
}
@@ -197,36 +196,6 @@
font-weight: 400;
}
.portal-select {
position: relative;
display: flex;
height: 36px;
align-items: center;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-1);
font-size: 13.5px;
}
.portal-select select {
width: 100%;
padding: 0;
border: 0;
outline: 0;
appearance: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.portal-select > svg {
pointer-events: none;
color: var(--n-4);
}
.portal-final-link {
display: flex;
align-items: center;
+200 -5
View File
@@ -59,7 +59,6 @@ export const en: Record<MessageKey, Message> = {
"admin.creation_failed": "Creation failed",
"admin.credentials": "Credentials",
"admin.credentials_role_groups": "Credentials · role · groups",
"admin.currency": "Currency",
"admin.danger_zone": "Danger zone",
"admin.e_g_operator": "e.g. Operator",
"admin.earlier": "Earlier",
@@ -170,7 +169,6 @@ export const en: Record<MessageKey, Message> = {
"admin.result": "Result",
"admin.roles_groups_showing": "Roles and groups · showing",
"admin.roles_groups_shown": "Roles and groups · showing {shown} of {total}",
"admin.russian_rouble_rub": "Russian rouble (RUB)",
"admin.save_sign": "Save and sign in",
"admin.scan_qr_code": "Scan the QR code",
"admin.scan_qr_code_authenticator_app": "Scan the QR code in your authenticator app, then confirm with a code",
@@ -308,7 +306,6 @@ export const en: Record<MessageKey, Message> = {
"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",
@@ -1010,6 +1007,202 @@ export const en: Record<MessageKey, Message> = {
"portals.working": "working",
"portals.yaml_format_note": "A YAML file · format: articles: [{format}]",
"portals.yes_article_helped": "Yes, the article helped",
"onboarding.title": "Getting started",
"onboarding.eyebrow": "Your installation is ready to configure",
"onboarding.welcome_title_7": "Seven steps to your first customer reply",
"onboarding.welcome_title_8": "Eight steps to your first customer reply",
"onboarding.welcome_lead": "We'll go through what to set up and where: the model key, the agent, the knowledge, the entry point and the team. Every step gives the exact path in the interface and highlights the element on screen.",
"onboarding.fact_time": "≈ 25 minutes",
"onboarding.fact_time_note": "You can stop and come back",
"onboarding.fact_key": "Your own model key",
"onboarding.fact_key_note": "Or the demo provider, no key needed",
"onboarding.fact_data": "Your data stays with you",
"onboarding.fact_data_note": "Everything runs on your server",
"onboarding.later": "I'll do it later",
"onboarding.start": "Start setup",
"onboarding.progress": "{done} of {total} done",
"onboarding.progress_short": "{done}/{total}",
"onboarding.step_of": "Step {index} of {total} · {time}",
"onboarding.close_and_self": "Close and set up on my own",
"onboarding.back": "Back",
"onboarding.show_where": "Show me where",
"onboarding.open_section": "Open the section",
"onboarding.next": "Next",
"onboarding.finish": "Finish",
"onboarding.tour_eyebrow": "Step {index} · where to find it",
"onboarding.tour_ok": "Got it, back to the steps",
"onboarding.done_title": "All set — you can take customers now",
"onboarding.done_lead": "The agent answers from your knowledge and hands the hard cases to the team. You can come back to the steps in Settings → Getting started.",
"onboarding.restart": "Start over",
"onboarding.go_chat": "Go to chat",
"onboarding.settings_hint": "Step-by-step setup of your installation. Opens whenever you need it.",
"onboarding.steps_list": "Setup steps",
"onboarding.s1.name": "AI provider",
"onboarding.s1.time": "3 min",
"onboarding.s1.title": "Connect an AI provider",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: spend, limits and tokens are all counted in the system.",
"onboarding.s1.path": "Settings → AI provider → Add provider",
"onboarding.s1.do_1": "Open Settings in the sidebar and pick the AI provider section.",
"onboarding.s1.do_2": "Click Add provider: OpenRouter, Custom (OpenAI-compatible) or a local model.",
"onboarding.s1.do_3": "Paste the API key and set a default model — openai/gpt-4o-mini, for example.",
"onboarding.s1.do_4": "Click Check: the status should turn to Connected.",
"onboarding.s1.warning": "No key yet? Take the demo provider without a key — it answers from the agent's knowledge so you can look around, and you can swap it for a real one at any time.",
"onboarding.s1.preview": "Settings → AI provider",
"onboarding.s1.row1_label": "Provider",
"onboarding.s1.row1_value": "OpenRouter",
"onboarding.s1.row2_label": "Secret",
"onboarding.s1.row2_value": "sk-or-••••••••",
"onboarding.s1.row3_label": "Model",
"onboarding.s1.row3_value": "openai/gpt-4o-mini",
"onboarding.s1.chip": "Connected",
"onboarding.s1.tour_title": "Settings → AI provider",
"onboarding.s1.tour_text": "A section in the Settings submenu. This is where the organization's keys live: adding a provider, checking it and its status.",
"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 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.",
"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": "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 and the knowledge attached to it.",
"onboarding.s3.name": "Knowledge base",
"onboarding.s3.time": "from 10 min",
"onboarding.s3.title": "Fill the knowledge base",
"onboarding.s3.lead": "This is the only source the agent answers from. Search goes by meaning, so write the way your customers ask.",
"onboarding.s3.path": "Knowledge base → Create article",
"onboarding.s3.do_1": "Open the Knowledge base and create categories for the topics people write about.",
"onboarding.s3.do_2": "Add articles: the question in the title, a short unambiguous answer in the body.",
"onboarding.s3.do_3": "Load existing material through Import — as files, with no manual copying.",
"onboarding.s3.do_4": "Check on the agent card that the articles are attached to it.",
"onboarding.s3.warning": "Editing an article changes the bot's answers right away, and the public support portal reads the same article — there is no need to keep two texts.",
"onboarding.s3.preview": "Knowledge base",
"onboarding.s3.row1_label": "Categories",
"onboarding.s3.row1_value": "Delivery · Payment · Returns",
"onboarding.s3.row2_label": "Articles",
"onboarding.s3.row2_value": "12",
"onboarding.s3.row3_label": "Search",
"onboarding.s3.row3_value": "Semantic",
"onboarding.s3.chip": "Attached to the agent",
"onboarding.s3.tour_title": "The Knowledge base section",
"onboarding.s3.tour_text": "Articles, categories and import. This is where the agent takes its answers from, and where the support portal reads them.",
"onboarding.s4.name": "Entry point",
"onboarding.s4.time": "5 min",
"onboarding.s4.title": "Connect an entry point",
"onboarding.s4.lead": "An entry point is the channel a customer arrives through: Telegram, MAX, email or the chat on your site. Without one there is nowhere to write.",
"onboarding.s4.path": "Settings → Integrations → Add connection",
"onboarding.s4.do_1": "Settings → Integrations → Add connection.",
"onboarding.s4.do_2": "For a bot, paste the token: Telegram from BotFather, MAX from the developer console.",
"onboarding.s4.do_3": "For email, give the mailbox IMAP and SMTP settings and an app password.",
"onboarding.s4.do_4": "Bind the connection to the agent you created — otherwise there is nobody to handle the messages.",
"onboarding.s4.do_5": "Click Check and make sure the status is Connected.",
"onboarding.s4.warning": "Messages from Telegram and MAX are picked up by a background worker. If it is stopped the status stays Connected, but no conversations appear.",
"onboarding.s4.preview": "Settings → Integrations",
"onboarding.s4.row1_label": "Connection",
"onboarding.s4.row1_value": "Telegram · @romashka_bot",
"onboarding.s4.row2_label": "Agent",
"onboarding.s4.row2_value": "Consultant",
"onboarding.s4.row3_label": "Secret",
"onboarding.s4.row3_value": "78••••••••",
"onboarding.s4.chip": "Connected",
"onboarding.s4.tour_title": "Settings → Integrations",
"onboarding.s4.tour_text": "Bots, email and the web widget. A red dot on the item means some connection has dropped.",
"onboarding.s5.name": "Site widget",
"onboarding.s5.time": "3 min",
"onboarding.s5.title": "Put the chat on your site",
"onboarding.s5.lead": "The web widget is a single tag in your template. The chat opens in an isolated window on top of the site.",
"onboarding.s5.path": "Settings → Integrations → Web widget",
"onboarding.s5.do_1": "Create a Web widget connection and style it: title, accent, greeting.",
"onboarding.s5.do_2": "Fill in the allowed domains — an empty list blocks every site.",
"onboarding.s5.do_3": "Copy the snippet and paste it before the closing body tag on your site.",
"onboarding.s5.do_4": "Set the widget to Published and reload your site.",
"onboarding.s5.warning": "The key in data-widget-key must match the key in the widget settings — if they differ the chat simply will not appear.",
"onboarding.s5.preview": "Snippet for your site",
"onboarding.s5.row1_label": "Allowed domains",
"onboarding.s5.row1_value": "romashka.ru, *.romashka.ru",
"onboarding.s5.chip": "Published",
"onboarding.s5.tour_title": "Settings → Integrations",
"onboarding.s5.tour_text": "The widget lives among the connections: its key, its allowed domains and the ready-made tag to paste.",
"onboarding.s6.name": "Team",
"onboarding.s6.time": "4 min",
"onboarding.s6.title": "Invite your team",
"onboarding.s6.lead": "The agent hands hard questions to people. Add the people it will hand them to, and decide who sees what.",
"onboarding.s6.path": "Team → Invite",
"onboarding.s6.do_1": "Open Team and send invitations by email.",
"onboarding.s6.do_2": "Assign roles: the owner and the administrator see everything, an employee works only in the chat.",
"onboarding.s6.do_3": "In Settings → Groups, split conversations into visibility groups.",
"onboarding.s6.do_4": "Ask the team to turn on code-based sign-in (TOTP) in their profile.",
"onboarding.s6.warning": "Invitations go out by email. Until SMTP is configured (the Domain and email step) the letters will not arrive, and passwords have to be set by hand.",
"onboarding.s6.preview": "Team",
"onboarding.s6.row1_label": "Invited",
"onboarding.s6.row1_value": "3 people",
"onboarding.s6.row2_label": "Groups",
"onboarding.s6.row2_value": "First line · Billing",
"onboarding.s6.row3_label": "Roles",
"onboarding.s6.row3_value": "Admin · Employee",
"onboarding.s6.chip": "Waiting to sign in",
"onboarding.s6.tour_title": "The Team section",
"onboarding.s6.tour_text": "Invitations, roles and groups. An employee sees the conversations of their groups and the ones they are responsible for.",
"onboarding.s7.name": "Domain and email",
"onboarding.s7.time": "5 min",
"onboarding.s7.title": "Set the domain and email",
"onboarding.s7.lead": "For now the installation opens by IP — no HTTPS and no email. A domain and SMTP turn it into a working service.",
"onboarding.s7.path": "Settings → Platform",
"onboarding.s7.do_1": "Settings → Platform: enter the installation domain and switch to https.",
"onboarding.s7.do_2": "Make sure the domain's DNS points at the server and ports 80 and 443 are open from outside.",
"onboarding.s7.do_3": "Fill in outgoing email: host, port, encryption, login and app password.",
"onboarding.s7.do_4": "Click Send a test message and check that it arrives.",
"onboarding.s7.warning": "The certificate is issued on the first request to the domain: open it in a browser and wait a few seconds.",
"onboarding.s7.preview": "Settings → Platform",
"onboarding.s7.row1_label": "Address",
"onboarding.s7.row1_value": "https://support.romashka.ru",
"onboarding.s7.row2_label": "SMTP",
"onboarding.s7.row2_value": "smtp.yandex.ru : 465",
"onboarding.s7.row3_label": "Certificate",
"onboarding.s7.row3_value": "Let's Encrypt",
"onboarding.s7.chip": "HTTPS is on",
"onboarding.s7.tour_title": "Settings → Platform",
"onboarding.s7.tour_text": "Properties of the installation itself: address, outgoing email, updates. The section is visible to the installation administrator.",
"onboarding.s8.name": "Check",
"onboarding.s8.time": "2 min",
"onboarding.s8.title": "Message the bot as a customer",
"onboarding.s8.lead": "The last step is to walk the customer's path end to end: a message, the agent's answer and the handover to a human.",
"onboarding.s8.path": "Chat → All conversations",
"onboarding.s8.do_1": "Ask your bot, or the widget on your site, a question from the knowledge base.",
"onboarding.s8.do_2": "Open Chat: the conversation appears in the list with its channel, agent and waiting timer.",
"onboarding.s8.do_3": "Check the agent's answer — it should rest on your article.",
"onboarding.s8.do_4": "Click Take the conversation and answer yourself: that is how the handover to an operator works.",
"onboarding.s8.warning": "The three conversation modes — AI answers, an employee answers, paused — switch in the conversation header at any time.",
"onboarding.s8.preview": "Chat → All conversations",
"onboarding.s8.row1_label": "Conversation",
"onboarding.s8.row1_value": "Anna Smirnova · Telegram",
"onboarding.s8.row2_label": "Agent",
"onboarding.s8.row2_value": "Consultant",
"onboarding.s8.row3_label": "Mode",
"onboarding.s8.row3_value": "AI answers",
"onboarding.s8.chip": "Answered in 4s",
"onboarding.s8.tour_title": "The conversation list",
"onboarding.s8.tour_text": "Every request to the organization in one window: channel, agent, waiting time, labels and group.",
"organizations.add": "Add organization",
"organizations.create_title": "New organization",
"organizations.create_lead": "You will own the new organization and switch to it right away. The name, time zone and language can be changed later in Settings.",
"organizations.create_submit": "Create organization",
"organizations.creating": "Creating…",
"organizations.create_failed": "Could not create the organization",
"organizations.logo_failed": "The organization was created, but the logo failed to upload — add it in Settings.",
"organizations.name_placeholder": "For example, “Nord Atelier”",
"profile.accent_colour": "Accent colour",
"profile.address_unknown": "address unknown",
"profile.all_conversations": "All conversations",
@@ -1033,7 +1226,7 @@ export const en: Record<MessageKey, Message> = {
"profile.create_agent": "Create an agent",
"profile.current": "current",
"profile.current_password": "Current password",
"profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #1677ff",
"profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #0f9b8e",
"profile.dark": "Dark",
"profile.disconnect": "Disconnect",
"profile.drag_resize_double_click_reset": "Drag to resize, double-click to reset",
@@ -1046,6 +1239,9 @@ export const en: Record<MessageKey, Message> = {
"profile.getting_started_progress": "{done} of {total}",
"profile.go_start_page": "Go to the start page",
"profile.switch_organization": "Switch organization",
"auth.choose_organization_title": "Choose an organization",
"auth.choose_organization_subtitle": "{name}, you belong to several organizations. Which one to start with?",
"auth.choose_organization_logout": "Sign out",
"auth.invitation_title": "Organization invitation",
"auth.invitation_accepting": "Accepting the invitation…",
"auth.invitation_not_accepted": "The invitation was not accepted",
@@ -1286,7 +1482,6 @@ export const en: Record<MessageKey, Message> = {
"settings.language_as_installation": "Match the installation",
"settings.language_instance": "Installation language",
"settings.language_instance_hint": "The language of screens where there is no organization yet: sign-in, password reset, the first-run wizard. It is also the default for organizations that have not picked their own.",
"settings.language_org_hint": "The language the workspace opens in for this organization's operators. Anyone can pick their own in their profile.",
"settings.leave_empty_keep_unchanged": "leave empty to keep it unchanged",
"settings.lime": "Lime",
"settings.migrate_files_from_disk": "Migrate the files from disk",
+200 -5
View File
@@ -60,7 +60,6 @@ export const ru = {
"admin.creation_failed": "Ошибка создания",
"admin.credentials": "Учётные данные",
"admin.credentials_role_groups": "Учётные данные · роль · группы",
"admin.currency": "Валюта",
"admin.danger_zone": "Опасная зона",
"admin.e_g_operator": "напр. Оператор",
"admin.earlier": "Ранее",
@@ -171,7 +170,6 @@ export const ru = {
"admin.result": "Результат",
"admin.roles_groups_showing": "Роли и группы · показано",
"admin.roles_groups_shown": "Роли и группы · показано {shown} из {total}",
"admin.russian_rouble_rub": "Российский рубль (RUB)",
"admin.save_sign": "Сохранить и войти",
"admin.scan_qr_code": "Отсканируйте QR-код",
"admin.scan_qr_code_authenticator_app": "Отсканируйте QR-код в приложении-аутентификаторе, затем подтвердите кодом",
@@ -309,7 +307,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": "Удалить вложение",
@@ -1011,6 +1008,202 @@ export const ru = {
"portals.working": "работает",
"portals.yaml_format_note": "Файл YAML · формат: articles: [{format}]",
"portals.yes_article_helped": "Да, статья полезна",
"onboarding.title": "Начало работы",
"onboarding.eyebrow": "Установка готова к настройке",
"onboarding.welcome_title_7": "Семь шагов до первого ответа клиенту",
"onboarding.welcome_title_8": "Восемь шагов до первого ответа клиенту",
"onboarding.welcome_lead": "Покажем по порядку, что и где настроить: ключ модели, агента, знания, точку входа и команду. На каждом шаге — точный путь в интерфейсе и подсветка нужного элемента на экране.",
"onboarding.fact_time": "≈ 25 минут",
"onboarding.fact_time_note": "Можно прерваться и вернуться",
"onboarding.fact_key": "Свой ключ модели",
"onboarding.fact_key_note": "Или демо-провайдер без ключа",
"onboarding.fact_data": "Данные у вас",
"onboarding.fact_data_note": "Всё на вашем сервере",
"onboarding.later": "Пройду позже",
"onboarding.start": "Начать настройку",
"onboarding.progress": "Готово {done} из {total}",
"onboarding.progress_short": "{done}/{total}",
"onboarding.step_of": "Шаг {index} из {total} · {time}",
"onboarding.close_and_self": "Закрыть и настроить самому",
"onboarding.back": "Назад",
"onboarding.show_where": "Показать где",
"onboarding.open_section": "Открыть раздел",
"onboarding.next": "Далее",
"onboarding.finish": "Завершить",
"onboarding.tour_eyebrow": "Шаг {index} · где это находится",
"onboarding.tour_ok": "Понятно, к шагам",
"onboarding.done_title": "Всё готово — можно принимать клиентов",
"onboarding.done_lead": "Агент отвечает по вашим знаниям, сложное передаёт команде. Вернуться к шагам можно в «Настройки → Начало работы».",
"onboarding.restart": "Пройти заново",
"onboarding.go_chat": "Перейти в чат",
"onboarding.settings_hint": "Пошаговая настройка установки. Открывается в любой момент.",
"onboarding.steps_list": "Шаги настройки",
"onboarding.s1.name": "AI-провайдер",
"onboarding.s1.time": "3 мин",
"onboarding.s1.title": "Подключите AI-провайдера",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
"onboarding.s1.path": "Настройки → AI-провайдер → Добавить провайдера",
"onboarding.s1.do_1": "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер».",
"onboarding.s1.do_2": "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель.",
"onboarding.s1.do_3": "Вставьте API-ключ и укажите модель по умолчанию — например openai/gpt-4o-mini.",
"onboarding.s1.do_4": "Нажмите «Проверить»: статус должен стать «Подключено».",
"onboarding.s1.warning": "Ключа ещё нет? Возьмите «Демо-провайдер без ключа» — он отвечает по знаниям агента, чтобы осмотреться, и меняется на настоящий в любой момент.",
"onboarding.s1.preview": "Настройки → AI-провайдер",
"onboarding.s1.row1_label": "Провайдер",
"onboarding.s1.row1_value": "OpenRouter",
"onboarding.s1.row2_label": "Секрет",
"onboarding.s1.row2_value": "sk-or-••••••••",
"onboarding.s1.row3_label": "Модель",
"onboarding.s1.row3_value": "openai/gpt-4o-mini",
"onboarding.s1.chip": "Подключено",
"onboarding.s1.tour_title": "Настройки → AI-провайдер",
"onboarding.s1.tour_text": "Раздел в субменю «Настроек». Здесь живут ключи организации: добавление, проверка и статус провайдера.",
"onboarding.s2.name": "AI-агент",
"onboarding.s2.time": "4 мин",
"onboarding.s2.title": "Создайте AI-агента",
"onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает и каким правилам подчиняется.",
"onboarding.s2.path": "Агенты → Создать агента",
"onboarding.s2.do_1": "Откройте «Агенты» в сайдбаре и нажмите «Создать агента».",
"onboarding.s2.do_2": "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя.",
"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": "12 статей",
"onboarding.s2.row3_label": "Язык ответов",
"onboarding.s2.row3_value": "Как у клиента",
"onboarding.s2.chip": "Активен",
"onboarding.s2.tour_title": "Раздел «Агенты»",
"onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель и прикреплённые знания.",
"onboarding.s3.name": "База знаний",
"onboarding.s3.time": "от 10 мин",
"onboarding.s3.title": "Наполните базу знаний",
"onboarding.s3.lead": "Это единственный источник ответов агента. Поиск идёт по смыслу, поэтому пишите так, как спрашивают клиенты.",
"onboarding.s3.path": "База знаний → Создать статью",
"onboarding.s3.do_1": "Откройте «База знаний» и создайте категории под темы обращений.",
"onboarding.s3.do_2": "Добавьте статьи: вопрос в заголовке, короткий однозначный ответ в тексте.",
"onboarding.s3.do_3": "Готовые материалы залейте через «Импорт» — файлами, без ручного переноса.",
"onboarding.s3.do_4": "Проверьте на карточке агента, что статьи к нему прикреплены.",
"onboarding.s3.warning": "Правка статьи меняет ответы бота сразу, и ту же статью читает публичный портал поддержки — держать два текста не нужно.",
"onboarding.s3.preview": "База знаний",
"onboarding.s3.row1_label": "Категории",
"onboarding.s3.row1_value": "Доставка · Оплата · Возврат",
"onboarding.s3.row2_label": "Статей",
"onboarding.s3.row2_value": "12",
"onboarding.s3.row3_label": "Поиск",
"onboarding.s3.row3_value": "Семантический",
"onboarding.s3.chip": "Прикреплено к агенту",
"onboarding.s3.tour_title": "Раздел «База знаний»",
"onboarding.s3.tour_text": "Статьи, категории и импорт. Отсюда агент берёт ответы, отсюда же их читает портал поддержки.",
"onboarding.s4.name": "Точка входа",
"onboarding.s4.time": "5 мин",
"onboarding.s4.title": "Подключите точку входа",
"onboarding.s4.lead": "Точка входа — канал, по которому приходит клиент: Telegram, MAX, почта или чат на сайте. Без неё писать некуда.",
"onboarding.s4.path": "Настройки → Интеграции → Добавить подключение",
"onboarding.s4.do_1": "Настройки → «Интеграции» → «Добавить подключение».",
"onboarding.s4.do_2": "Для бота вставьте токен: Telegram — от BotFather, MAX — из кабинета разработчика.",
"onboarding.s4.do_3": "Для почты укажите IMAP и SMTP ящика и пароль приложения.",
"onboarding.s4.do_4": "Привяжите подключение к созданному агенту — иначе сообщения некому обрабатывать.",
"onboarding.s4.do_5": "Нажмите «Проверить» и убедитесь в статусе «Подключено».",
"onboarding.s4.warning": "Сообщения из Telegram и MAX забирает фоновый рабочий. Если он остановлен, статус останется «Подключено», а переписки не появятся.",
"onboarding.s4.preview": "Настройки → Интеграции",
"onboarding.s4.row1_label": "Подключение",
"onboarding.s4.row1_value": "Telegram · @romashka_bot",
"onboarding.s4.row2_label": "Агент",
"onboarding.s4.row2_value": "Консультант",
"onboarding.s4.row3_label": "Секрет",
"onboarding.s4.row3_value": "78••••••••",
"onboarding.s4.chip": "Подключено",
"onboarding.s4.tour_title": "Настройки → Интеграции",
"onboarding.s4.tour_text": "Боты, почта и веб-виджет. Красная точка у пункта означает, что какое-то подключение отвалилось.",
"onboarding.s5.name": "Виджет на сайт",
"onboarding.s5.time": "3 мин",
"onboarding.s5.title": "Поставьте чат на сайт",
"onboarding.s5.lead": "Веб-виджет — один тег в вашем шаблоне. Чат открывается в изолированном окне поверх сайта.",
"onboarding.s5.path": "Настройки → Интеграции → Web-виджет",
"onboarding.s5.do_1": "Создайте подключение «Web-виджет» и оформите его: заголовок, акцент, приветствие.",
"onboarding.s5.do_2": "Заполните разрешённые домены — пустой список запрещает все сайты.",
"onboarding.s5.do_3": "Скопируйте сниппет и вставьте его перед закрывающим body на своём сайте.",
"onboarding.s5.do_4": "Переведите виджет в статус «Опубликован» и обновите страницу сайта.",
"onboarding.s5.warning": "Ключ в data-widget-key должен совпадать с ключом из настроек виджета — при несовпадении чат просто не появится.",
"onboarding.s5.preview": "Сниппет для сайта",
"onboarding.s5.row1_label": "Разрешённые домены",
"onboarding.s5.row1_value": "romashka.ru, *.romashka.ru",
"onboarding.s5.chip": "Опубликован",
"onboarding.s5.tour_title": "Настройки → Интеграции",
"onboarding.s5.tour_text": "Виджет живёт среди подключений: там его ключ, разрешённые домены и готовый тег для вставки.",
"onboarding.s6.name": "Сотрудники",
"onboarding.s6.time": "4 мин",
"onboarding.s6.title": "Пригласите команду",
"onboarding.s6.lead": "Сложные вопросы агент передаёт людям. Заведите тех, кому он будет их отдавать, и решите, кто что видит.",
"onboarding.s6.path": "Сотрудники → Пригласить",
"onboarding.s6.do_1": "Откройте «Сотрудники» и отправьте приглашения по email.",
"onboarding.s6.do_2": "Назначьте роли: владелец и администратор видят всё, сотрудник работает только в чате.",
"onboarding.s6.do_3": "В Настройки → «Группы» распределите диалоги по группам видимости.",
"onboarding.s6.do_4": "Предложите команде включить вход по коду (TOTP) в своём профиле.",
"onboarding.s6.warning": "Приглашения уходят почтой. Пока не настроен SMTP (шаг «Домен и почта»), письма не дойдут — пароль придётся задавать вручную.",
"onboarding.s6.preview": "Сотрудники",
"onboarding.s6.row1_label": "Приглашены",
"onboarding.s6.row1_value": "3 человека",
"onboarding.s6.row2_label": "Группы",
"onboarding.s6.row2_value": "Первая линия · Биллинг",
"onboarding.s6.row3_label": "Роли",
"onboarding.s6.row3_value": "Админ · Сотрудник",
"onboarding.s6.chip": "Ожидают входа",
"onboarding.s6.tour_title": "Раздел «Сотрудники»",
"onboarding.s6.tour_text": "Приглашения, роли и группы. Сотрудник видит диалоги своих групп и те, за которые отвечает.",
"onboarding.s7.name": "Домен и почта",
"onboarding.s7.time": "5 мин",
"onboarding.s7.title": "Укажите домен и почту",
"onboarding.s7.lead": "Пока установка открывается по IP — без HTTPS и без писем. Домен и SMTP превращают её в рабочий сервис.",
"onboarding.s7.path": "Настройки → Платформа",
"onboarding.s7.do_1": "Настройки → «Платформа»: введите домен установки и переключитесь на https.",
"onboarding.s7.do_2": "Убедитесь, что DNS домена смотрит на сервер, а порты 80 и 443 открыты снаружи.",
"onboarding.s7.do_3": "Заполните исходящую почту: хост, порт, шифрование, логин и пароль приложения.",
"onboarding.s7.do_4": "Нажмите «Отправить тестовое письмо» и проверьте, что оно пришло.",
"onboarding.s7.warning": "Сертификат выдаётся по первому запросу к домену: откройте его в браузере и подождите несколько секунд.",
"onboarding.s7.preview": "Настройки → Платформа",
"onboarding.s7.row1_label": "Адрес",
"onboarding.s7.row1_value": "https://support.romashka.ru",
"onboarding.s7.row2_label": "SMTP",
"onboarding.s7.row2_value": "smtp.yandex.ru : 465",
"onboarding.s7.row3_label": "Сертификат",
"onboarding.s7.row3_value": "Let's Encrypt",
"onboarding.s7.chip": "HTTPS включён",
"onboarding.s7.tour_title": "Настройки → Платформа",
"onboarding.s7.tour_text": "Свойства самой установки: адрес, исходящая почта, обновления. Раздел виден администратору установки.",
"onboarding.s8.name": "Проверка",
"onboarding.s8.time": "2 мин",
"onboarding.s8.title": "Напишите боту как клиент",
"onboarding.s8.lead": "Последний шаг — пройти путь клиента целиком: сообщение, ответ агента и передача на человека.",
"onboarding.s8.path": "Чат → Все диалоги",
"onboarding.s8.do_1": "Напишите своему боту или в виджет на сайте вопрос из базы знаний.",
"onboarding.s8.do_2": "Откройте «Чат»: диалог появится в списке с каналом, агентом и таймером ожидания.",
"onboarding.s8.do_3": "Проверьте ответ агента — он должен опираться на вашу статью.",
"onboarding.s8.do_4": "Нажмите «Взять диалог» и ответьте сами: так работает передача на оператора.",
"onboarding.s8.warning": "Три режима диалога — отвечает AI, отвечает сотрудник, пауза — переключаются в шапке переписки в любой момент.",
"onboarding.s8.preview": "Чат → Все диалоги",
"onboarding.s8.row1_label": "Диалог",
"onboarding.s8.row1_value": "Анна Смирнова · Telegram",
"onboarding.s8.row2_label": "Агент",
"onboarding.s8.row2_value": "Консультант",
"onboarding.s8.row3_label": "Режим",
"onboarding.s8.row3_value": "Отвечает AI",
"onboarding.s8.chip": "Ответ за 4 сек",
"onboarding.s8.tour_title": "Список диалогов",
"onboarding.s8.tour_text": "Все обращения организации в одном окне: канал, агент, время ожидания, метки и группа.",
"organizations.add": "Добавить организацию",
"organizations.create_title": "Новая организация",
"organizations.create_lead": "Вы станете владельцем новой организации и сразу в неё переключитесь. Название, часовой пояс и язык потом можно поменять в «Настройках».",
"organizations.create_submit": "Создать организацию",
"organizations.creating": "Создаём…",
"organizations.create_failed": "Не удалось создать организацию",
"organizations.logo_failed": "Организация создана, но логотип загрузить не удалось — добавьте его в «Настройках».",
"organizations.name_placeholder": "Например, «Ателье Норд»",
"profile.accent_colour": "Акцентный цвет",
"profile.address_unknown": "адрес неизвестен",
"profile.all_conversations": "Все диалоги",
@@ -1034,7 +1227,7 @@ export const ru = {
"profile.create_agent": "Создать агента",
"profile.current": "текущая",
"profile.current_password": "Текущий пароль",
"profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #1677ff",
"profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #0f9b8e",
"profile.dark": "Тёмная",
"profile.disconnect": "Отключить",
"profile.drag_resize_double_click_reset": "Потяните, двойной клик — сбросить",
@@ -1047,6 +1240,9 @@ export const ru = {
"profile.getting_started_progress": "{done} из {total}",
"profile.go_start_page": "На главную",
"profile.switch_organization": "Переключить организацию",
"auth.choose_organization_title": "Выберите организацию",
"auth.choose_organization_subtitle": "{name}, вы состоите в нескольких организациях. С какой начать?",
"auth.choose_organization_logout": "Выйти",
"auth.invitation_title": "Приглашение в организацию",
"auth.invitation_accepting": "Принимаем приглашение…",
"auth.invitation_not_accepted": "Приглашение не принято",
@@ -1287,7 +1483,6 @@ export const ru = {
"settings.language_as_installation": "Как в установке",
"settings.language_instance": "Язык установки",
"settings.language_instance_hint": "Язык экранов, где организации ещё нет: вход, сброс пароля, мастер первого запуска. Он же — умолчание для организаций, которые своего языка не выбрали.",
"settings.language_org_hint": "Язык, на котором рабочее место открывается сотрудникам организации. Каждый может выбрать свой в профиле.",
"settings.leave_empty_keep_unchanged": "оставьте пустым, чтобы не менять",
"settings.lime": "Салатовый",
"settings.migrate_files_from_disk": "Перенести файлы с диска",
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { Icon } from "../shared/icons";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { t } from "../i18n";
// Блок «Запуск» в сайдбаре (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1): три шага
// с автоотметкой по факту, прогресс и primary-действие текущего шага.
// «Скрыть» — предпочтение клиента (localStorage); при полном прохождении блок
// исчезает сам.
type Checklist = {
agentCreated: boolean;
connectionBound: boolean;
employeeInvited: boolean;
done: boolean;
};
type Step = {
key: keyof Omit<Checklist, "done">;
label: string;
action: string;
route: RouteKey;
// Раздел «Настроек», если шаг ведёт в субменю (кадры N1–N7).
section?: SettingsSectionKey;
icon: Parameters<typeof Icon>[0]["name"];
};
const STEPS: Step[] = [
{ key: "agentCreated", label: t("profile.create_agent"), action: t("profile.create_agent"), route: "agents", icon: "robot" },
{ key: "connectionBound", label: t("profile.connect_entry_point"), action: t("profile.connect"), route: "settings", section: "integrations", icon: "plug" },
{ key: "employeeInvited", label: t("profile.invite_operators"), action: t("profile.invite"), route: "employees", icon: "team" },
];
function storageKey(user: SessionUser): string {
return `chatballs.launch.${user.organizationPublicId}.hidden`;
}
function isHidden(user: SessionUser): boolean {
try {
return localStorage.getItem(storageKey(user)) === "1";
} catch {
return false;
}
}
export function LaunchChecklist({ user, setRoute, openSettings }: { user: SessionUser; setRoute: (route: RouteKey) => void; openSettings: (section: SettingsSectionKey) => void }) {
const [checklist, setChecklist] = useState<Checklist | null>(null);
const [hidden, setHidden] = useState(() => isHidden(user));
useEffect(() => {
if (hidden) return;
let cancelled = false;
api<Checklist>("/api/v1/company/launch-checklist/")
.then((payload) => {
if (!cancelled) setChecklist(payload);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [hidden]);
if (hidden || !checklist || checklist.done) return null;
const doneCount = STEPS.filter((step) => checklist[step.key]).length;
const current = STEPS.find((step) => !checklist[step.key]);
function hide() {
try {
localStorage.setItem(storageKey(user), "1");
} catch {
// приватный режим — просто скрываем до перезагрузки
}
setHidden(true);
}
return (
<div className="launch-checklist">
<div className="launch-checklist-head">
<div><strong>{t("profile.getting_started")}</strong><small>{t("profile.getting_started_progress", { done: doneCount, total: STEPS.length })}</small></div>
<button aria-label={t("common.hide")} title={t("common.hide")} type="button" onClick={hide}>×</button>
</div>
<div className="launch-checklist-progress"><i style={{ width: `${Math.round((doneCount / STEPS.length) * 100)}%` }} /></div>
<div className="launch-checklist-steps">
{STEPS.map((step) => {
const stepDone = checklist[step.key];
const isCurrent = current?.key === step.key;
return (
<div className={`launch-checklist-step ${stepDone ? "is-done" : ""} ${isCurrent ? "is-current" : ""}`} key={step.key}>
<span className="launch-checklist-mark">{stepDone && <Icon name="check" size={10} />}</span>
<span>{step.label}</span>
</div>
);
})}
</div>
{current && (
<button className="launch-checklist-action" type="button" onClick={() => (current.section ? openSettings(current.section) : setRoute(current.route))}>
<Icon name={current.icon} size={13} />
{current.action}
</button>
)}
</div>
);
}
+37 -24
View File
@@ -1,7 +1,7 @@
import { notification as antToast } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
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";
@@ -11,11 +11,15 @@ import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { OnboardingLauncher } from "../features/onboarding/OnboardingLauncher";
import { OnboardingOverlay } from "../features/onboarding/OnboardingOverlay";
import { OnboardingTour } from "../features/onboarding/OnboardingTour";
import { OnboardingProvider, useOnboardingState } from "../features/onboarding/useOnboarding";
import { UpdateBanner } from "../features/updates/UpdateBanner";
import { ShellRouteContent } from "./ShellRouteContent";
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void }) {
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization, onOrganizationCreated }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
@@ -27,6 +31,10 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
// состояние (дизайн-базлайн v2 §4.1).
const chatScope = useChatScope(true);
// Онбординг «Начало работы»: окно, тур и пилюля возврата. Состояние живёт
// здесь, потому что ссылка «Начало работы» в субменю «Настроек» читает тот
// же прогресс.
const onboarding = useOnboardingState({ user, setRoute, openSettings: openSettingsRoute });
const loadWaitingCount = useCallback(async () => {
try {
@@ -112,27 +120,32 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
</div>
</main>
<OnboardingProvider value={onboarding}>
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
<UpdateBanner enabled={user.isInstanceAdmin} />
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isAudit ? "audit-scroll" : ""} ${isPortals ? "portals-scroll" : ""} ${isKnowledge || isKnowledgeEditor ? "knowledge-scroll" : ""}`}>
<div key={route} className={`hub-page enter-surface ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isAudit ? "audit-page-shell" : ""} ${isKnowledge ? "knowledge-page-shell" : ""} ${isKnowledgeEditor ? "knowledge-editor-shell" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} selectedEmployeeId={selectedEmployeeId} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} openKnowledgeEditor={openKnowledgeEditorRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} onOrganizationCreated={onOrganizationCreated} />
</div>
</main>
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
<OnboardingOverlay />
<OnboardingTour />
<OnboardingLauncher />
</div>
<NotificationDrawer
open={notifOpen}
items={notifications}
unreadCount={unreadCount}
onClose={() => setNotifOpen(false)}
onItemClick={onNotificationClick}
onMarkAll={onMarkAll}
/>
</div>
</OnboardingProvider>
);
}
@@ -6,13 +6,14 @@ import { EmployeeDetailPage, EmployeesPage } from "../features/employees/Employe
import { ProfilePage } from "../features/profile/ProfilePage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { AuditPage } from "../features/administration/AuditPage";
import { OrganizationCreatePage } from "../features/organizations/OrganizationCreatePage";
import { ChatPage } from "../features/chat/ChatPage";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import type { ConversationCounters } from "../features/conversations/model";
import { SalesClientDetailPage } from "../features/sales/client-detail/SalesClientDetailPage";
import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { LoadingState } from "../shared/ui";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
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 { hasCapability } from "../auth/access";
@@ -42,7 +43,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void }) {
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgent, openKnowledge, openKnowledgeEditor, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar, onOrganizationCreated }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void; onOrganizationCreated: (identity: AuthenticatedUser, organizationPublicId: string) => void }) {
return (
<>
{route === "employees" && <EmployeesPage groups={data.groups} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
@@ -51,6 +52,7 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} onBack={() => setRoute("chat")} />}
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} groups={data.groups} section={settingsSection} openSection={openSettings} setRoute={setRoute} />}
{route === "administrationAudit" && <AuditPage />}
{route === "organizationCreate" && <OrganizationCreatePage user={user} onCreated={onOrganizationCreated} onBack={() => setRoute("chat")} />}
{route === "salesClients" && <SalesClientsPage openClient={openClient} openIntegrations={() => openSettings("integrations")} />}
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} canEdit={hasCapability(user, "customers.manage")} canMerge={user.role === "OWNER"} openConversation={openConversation} openClient={openClient} openClients={() => setRoute("salesClients")} />}
{route === "chat" && (
+44 -27
View File
@@ -2,13 +2,12 @@ import { Dropdown } from "antd";
import type { CSSProperties } from "react";
import type { RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import { useResizableWidth } from "../shared/useResizableWidth";
import { Icon, LogoIcon } from "../shared/icons";
import { defaultRoute, isManager } from "../auth/access";
import { canCreateOrganization, defaultRoute, isManager } from "../auth/access";
import type { TourTarget } from "../features/onboarding/steps";
import type { DialogScope } from "../features/conversations/ConversationWorkspace";
import { agentColorOf, groupColorOf, type ConversationCounters } from "../features/conversations/model";
import { LaunchChecklist } from "./LaunchChecklist";
import { SidebarUserMenu } from "./SidebarUserMenu";
import { t } from "../i18n";
@@ -24,14 +23,17 @@ type SidebarLinkProps = {
route: RouteKey;
routeKey: RouteKey;
setRoute: (route: RouteKey) => void;
// Имя цели для тура онбординга: по нему «Показать где» находит пункт на экране.
target?: TourTarget;
};
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute }: SidebarLinkProps) {
function SidebarLink({ activeRoutes, badge, icon, label, route, routeKey, setRoute, target }: SidebarLinkProps) {
const active = activeRoutes?.includes(route) ?? routeKey === route;
return (
<button
className={`hub-nav-item ${active ? "is-active" : ""}`}
type="button"
data-onboarding-target={target}
onClick={() => setRoute(routeKey)}
>
<Icon name={icon} />
@@ -45,7 +47,6 @@ export function Sidebar({
route,
user,
setRoute,
openSettings,
onLogout,
onSwitchOrganization,
waitingCount = 0,
@@ -60,7 +61,6 @@ export function Sidebar({
route: RouteKey;
user: SessionUser;
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
onLogout: () => void;
onSwitchOrganization: (organizationPublicId: string) => void;
waitingCount?: number;
@@ -123,23 +123,41 @@ export function Sidebar({
placement="bottomLeft"
overlayClassName="app-dropdown is-wide"
menu={{
items: user.memberships.map((membership) => ({
key: membership.organizationPublicId,
label: (
<button
type="button"
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
onClick={() => {
if (membership.organizationPublicId !== user.organizationPublicId) {
onSwitchOrganization(membership.organizationPublicId);
}
}}
>
<Icon name="building" size={15} />
{membership.organizationName || "Chatballs"}
</button>
),
})),
items: [
...user.memberships.map((membership) => ({
key: membership.organizationPublicId,
label: (
<button
type="button"
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
onClick={() => {
if (membership.organizationPublicId !== user.organizationPublicId) {
onSwitchOrganization(membership.organizationPublicId);
}
}}
>
<Icon name="building" size={15} />
{membership.organizationName || "Chatballs"}
</button>
),
})),
// «Добавить организацию» — внизу списка, отделена чертой: ведёт на
// страницу создания, где человек становится владельцем новой.
...(canCreateOrganization(user)
? [
{ type: "divider" as const, key: "add-divider" },
{
key: "add-organization",
label: (
<button type="button" className="hub-brand-add" onClick={() => setRoute("organizationCreate")}>
<span className="hub-brand-add-icon"><Icon name="plus" size={11} strokeWidth={2.4} /></span>
{t("organizations.add")}
</button>
),
},
]
: []),
],
}}
>
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
@@ -152,10 +170,10 @@ export function Sidebar({
<nav className="hub-nav">
<SidebarLink icon="message" label={t("common.chat")} badge={waitingCount > 0 ? String(waitingCount) : undefined} route={route} routeKey="chat" setRoute={setRoute} />
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label={t("common.contacts")} route={route} routeKey="salesClients" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label={t("common.agents")} route={route} routeKey="agents" setRoute={setRoute} target="nav-agents" />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label={t("common.operators")} route={route} routeKey="employees" setRoute={setRoute} target="nav-employees" />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label={t("common.portals")} route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} />
<SidebarLink activeRoutes={["knowledge", "knowledgeDetail", "knowledgeCreate", "knowledgeEdit", "knowledgeCategories", "knowledgeImport"]} icon="book" label={t("common.knowledge_base")} route={route} routeKey="knowledge" setRoute={setRoute} target="nav-knowledge" />
<SidebarLink activeRoutes={["settings", "administrationAudit"]} icon="settings" label={t("common.settings")} route={route} routeKey="settings" setRoute={setRoute} />
</nav>
) : route === "profile" ? (
@@ -173,7 +191,6 @@ export function Sidebar({
}}
/>
)}
{manager && <LaunchChecklist user={user} setRoute={setRoute} openSettings={openSettings} />}
<SidebarUserMenu user={user} route={route} setRoute={setRoute} onLogout={onLogout} unreadCount={unreadCount} onOpenNotifications={onOpenNotifications} />
</div>
</aside>
+37 -125
View File
@@ -1,4 +1,5 @@
.hub-shell {
position: relative;
height: 100vh;
display: flex;
overflow: hidden;
@@ -208,6 +209,42 @@
color: var(--n-4);
}
/* «Добавить организацию» в переключателе (A1): такая же строка, как у
организаций, только на месте значка — пунктирная плитка с плюсом, как у
«пустого» слота. Ничего залитого: строка читается как действие, а не как
ещё одна организация, и не спорит с отмеченной текущей. */
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add {
color: var(--n-3);
}
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover {
color: var(--primary-text);
}
/* Селектор длиннее общего «button span { display: block }» из menu.css:
иначе плюс прижимается к левому верхнему углу плитки. */
.app-dropdown .ant-dropdown-menu-item button .hub-brand-add-icon {
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--n-4);
border: 1.5px dashed var(--n-6);
border-radius: 6px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.hub-brand-add-icon svg {
display: block;
}
.app-dropdown .ant-dropdown-menu-item button.hub-brand-add:hover .hub-brand-add-icon {
color: var(--primary-text);
border-color: var(--primary);
}
/* Логотип — сам по себе, без подложки: цвет текста темы. */
.hub-brand-mark {
width: 28px;
@@ -634,131 +671,6 @@
}
/* --- Блок «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2 A1) --- */
.launch-checklist {
flex: none;
margin: 6px 10px 8px;
padding: 12px 12px 10px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-card);
}
.launch-checklist-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.launch-checklist-head strong {
color: var(--text-heading);
font-size: 13px;
font-weight: 700;
}
.launch-checklist-head small {
margin-left: 7px;
color: var(--text-tertiary);
font-size: 11.5px;
font-weight: 500;
}
.launch-checklist-head button {
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-disabled);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.launch-checklist-head button:hover {
background: var(--n-9);
color: var(--text-body);
}
.launch-checklist-progress {
height: 3px;
border-radius: 2px;
background: var(--n-8);
margin-bottom: 12px;
overflow: hidden;
}
.launch-checklist-progress i {
display: block;
height: 100%;
background: var(--primary);
}
.launch-checklist-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.launch-checklist-step {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-tertiary);
font-size: 12.5px;
}
.launch-checklist-step.is-current {
color: var(--text-heading);
font-weight: 600;
}
.launch-checklist-step.is-done span:last-child {
text-decoration: line-through;
}
.launch-checklist-mark {
width: 16px;
height: 16px;
flex: none;
border: 1.5px solid var(--border-strong);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
}
.launch-checklist-step.is-done .launch-checklist-mark {
border-color: var(--primary);
background: var(--primary);
}
.launch-checklist-action {
margin-top: 11px;
width: 100%;
height: 30px;
border: none;
border-radius: 7px;
background: var(--primary);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.launch-checklist-action:hover {
background: var(--primary-hover);
}
/* Полоса установки демо-данных: пока worker наполняет организацию, человек
видит причину пустых разделов, а не гадает. */
.demo-install-banner {
+10
View File
@@ -14,6 +14,16 @@ const empty = {
settingsSection: null,
};
describe("organization create route", () => {
// Страница создания живёт вне организации: адрес без uuid, а сборка адреса
// не подставляет префикс текущей организации.
it("parses and builds /organizations/new", () => {
expect(routeFromPath("/organizations/new")).toEqual({ route: "organizationCreate", ...empty });
expect(routeFromPath("/organizations/new/")).toEqual({ route: "organizationCreate", ...empty });
expect(pathFromRoute("organizationCreate", null, "123e4567-e89b-12d3-a456-426614174000")).toBe("/organizations/new");
});
});
describe("sales detail routes", () => {
it("parses a client detail URL", () => {
expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", ...empty, clientId: 15 });
+6
View File
@@ -21,6 +21,11 @@ export type RouteState = {
export function routeFromPath(pathname: string, search = ""): RouteState {
const normalized = pathname.replace(/\/+$/, "") || "/";
// Страница создания организации живёт вне организации: у неё ещё нет
// адреса, а человек попадает сюда из переключателя любой из своих (A1).
if (normalized === "/organizations/new") {
return { route: "organizationCreate", organizationPublicId: null, employeeId: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
}
const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
const organizationPublicId = match?.[1] ?? null;
const path = match ? match[2] || "/" : normalized;
@@ -114,6 +119,7 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
export function pathFromRoute(route: RouteKey, entityId: number | string | null = null, organizationPublicId: string | null = null): string {
const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : "";
if (route === "organizationCreate") return "/organizations/new";
if (route === "salesClients") return `${prefix}/contacts`;
if (route === "salesClientDetail") return entityId ? `${prefix}/contacts/${entityId}` : `${prefix}/contacts`;
if (route === "chat") return `${prefix}/chat`;
+1
View File
@@ -22,4 +22,5 @@ export const routes: Record<RouteKey, string> = {
knowledgeCategories: t("shared.knowledge_categories"),
knowledgeImport: t("shared.knowledge_import"),
aiUsage: t("shared.ai_usage"),
organizationCreate: t("organizations.create_title"),
};
+3 -3
View File
@@ -7,13 +7,13 @@ import { t } from "../i18n";
export type UiTheme = "LIGHT" | "DARK" | "SYSTEM";
export const DEFAULT_ACCENT = "#1677ff";
export const DEFAULT_ACCENT = "#0f9b8e";
// Пресеты акцента из дизайн-базлайна v2 (Tweaks макета).
// Пресеты акцента; первый — цвет продукта по умолчанию.
export const ACCENT_PRESETS: Array<[string, string]> = [
["#0f9b8e", t("shared.teal")],
["#1677ff", t("shared.blue")],
["#6d5dfc", t("shared.indigo")],
["#0f9b8e", t("shared.teal")],
["#e8590c", t("shared.orange")],
];
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more