diff --git a/AGENTS.md b/AGENTS.md index 5e8ca44..08050fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Production deployment / миграция: - Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции. UI / дизайн: -- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system. +- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ. - Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс. - Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы. - Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок. diff --git a/apps/backend/chatballs/conversations/test_chat_extras.py b/apps/backend/chatballs/conversations/test_chat_extras.py index b4cc341..b1d224d 100644 --- a/apps/backend/chatballs/conversations/test_chat_extras.py +++ b/apps/backend/chatballs/conversations/test_chat_extras.py @@ -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) diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 4451830..a93645a 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -98,6 +98,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", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 310e90a..84a1375 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -102,6 +102,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": "Файл не найден", diff --git a/apps/backend/chatballs/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py index 52dfb67..a848268 100644 --- a/apps/backend/chatballs/identity/administration_views.py +++ b/apps/backend/chatballs/identity/administration_views.py @@ -293,37 +293,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(), + } diff --git a/apps/backend/chatballs/identity/company_urls.py b/apps/backend/chatballs/identity/company_urls.py index 799ffa0..5905938 100644 --- a/apps/backend/chatballs/identity/company_urls.py +++ b/apps/backend/chatballs/identity/company_urls.py @@ -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"), diff --git a/apps/backend/chatballs/identity/migrations/0038_membership_onboarding_dismissed_at.py b/apps/backend/chatballs/identity/migrations/0038_membership_onboarding_dismissed_at.py new file mode 100644 index 0000000..5f6e37b --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0038_membership_onboarding_dismissed_at.py @@ -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), + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index 63c66ac..5f15e1b 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -184,6 +184,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: diff --git a/apps/internal-ui/src/features/administration/styles.css b/apps/internal-ui/src/features/administration/styles.css index a318073..69fc981 100644 --- a/apps/internal-ui/src/features/administration/styles.css +++ b/apps/internal-ui/src/features/administration/styles.css @@ -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; @@ -112,6 +112,12 @@ grid-column: 1 / -1; } +/* Строка «Язык интерфейса» в карточке организации одна, разделять её сверху + нечем: линия из «Профиля», где такие строки идут подряд, здесь лишняя. */ +.administration-language { + border-top: 0; +} + .administration-message { margin-top: 14px; padding: 9px 12px; diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx index 25250a6..be2307d 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx @@ -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({