mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab26a1bcd | ||
|
|
09fb0177b1 | ||
|
|
b76871caf0 | ||
|
|
8580b480e7 | ||
|
|
eff6d7ac0c | ||
|
|
1cff9e2183 | ||
|
|
d8420e4379 | ||
|
|
21b8100598 | ||
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 |
No files matched your search
@@ -16,7 +16,7 @@ Production deployment / миграция:
|
||||
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
|
||||
|
||||
UI / дизайн:
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
|
||||
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
|
||||
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
|
||||
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Установлена последняя версия",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+55
@@ -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)]
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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
Reference in new issue
Block a user