✨ feat(chat): примитивы чата по дизайн-базлайну v2 — приоритет, метки, заметка, архив

Первая волна нового дизайна (design/baseline/Chatbolls Baseline v2, решения
владельца 2026-09-04):
- Conversation: приоритет (HIGH/MEDIUM/LOW/NONE), метки (словарь организации,
  создание на лету), внутренняя заметка, архив («удалить» = архив, видят и
  восстанавливают только администраторы); RLS/парные триггеры (tenancy/0021);
- шаблоны ответов: общие на организацию, CRUD у администраторов;
- серверные фильтры списка: агент, мои, ждущие, поиск, архив; спам скрыт из
  вкладок по умолчанию; счётчики дерева фильтров /conversations/counters/;
- фронт: вкладки Все · Мои · Ждут оператора, бейджи приоритета и меток в строке
  диалога, блок «Диалог» в контекст-панели (ответственный, группа, приоритет,
  метки, заметка) в обоих чатах, «Вернуть в очередь» и «Удалить диалог» в меню.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-04 20:28:57 +03:00
1 parent 14f5f33ce7
commit 477b8f3ba4
23 files changed
+1632 -37

No files matched your search

@@ -0,0 +1,345 @@
"""Дизайн-базлайн v2: приоритет, метки, заметка, архив, шаблоны, счётчики.
Решения владельца (2026-09-04): метки и приоритет входят в модель; «удалить
диалог» = архив, архив видят только администраторы; шаблоны ответов общие на
организацию (редактируют администраторы, используют все).
"""
from __future__ import annotations
from django.db.models import Count
from django.utils import timezone
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.api.permissions import HasCapability
from hub_platform.conversations.models import (
Conversation,
ConversationLabel,
ConversationPriority,
ControlMode,
LifecycleState,
ReplyTemplate,
)
from hub_platform.conversations.selectors import apply_conversation_visibility
from hub_platform.conversations.serializers import conversation_payload
from hub_platform.conversations.view_base import ConversationViewBase
from hub_platform.identity.policy import can_administer_access
def _label_payload(label: ConversationLabel) -> dict[str, object]:
return {"id": label.id, "name": label.name, "color": label.color}
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
return {
"id": template.id,
"title": template.title,
"text": template.text,
"updatedAt": template.updated_at.isoformat(),
}
class ConversationPriorityView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
try:
conversation = self._conversation(
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
priority = str(request.data.get("priority", "")).strip().upper()
if priority not in ConversationPriority.values:
return Response({"detail": "Неизвестный приоритет"}, status=400)
conversation.priority = priority
conversation.save(update_fields=["priority"])
self._audit(request, "priority_changed", conversation)
return Response(
{
"conversation": conversation_payload(
conversation, with_messages=True, viewer_id=request.user.id
)
}
)
class ConversationNoteView(ConversationViewBase):
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
try:
conversation = self._conversation(
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
note = str(request.data.get("note", ""))
if len(note) > 4000:
return Response({"detail": "Заметка длиннее 4000 символов"}, status=400)
conversation.note = note
conversation.save(update_fields=["note"])
return Response(
{
"conversation": conversation_payload(
conversation, with_messages=True, viewer_id=request.user.id
)
}
)
class ConversationLabelsView(ConversationViewBase):
"""Полная замена набора меток диалога (чипы в карточке)."""
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
try:
conversation = self._conversation(
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
label_ids = request.data.get("labelIds")
if not isinstance(label_ids, list) or not all(
isinstance(item, int) for item in label_ids
):
return Response({"detail": "labelIds must be a list of ids"}, status=400)
labels = list(
ConversationLabel.objects.filter(
organization_id=request.tenant_context.organization_id,
id__in=set(label_ids),
)
)
if len(labels) != len(set(label_ids)):
return Response({"detail": "Неизвестная метка"}, status=400)
conversation.labels.set(labels)
return Response(
{
"conversation": conversation_payload(
conversation, with_messages=True, viewer_id=request.user.id
)
}
)
class ConversationArchiveView(ConversationViewBase):
"""«Удалить диалог» = архив: скрыт из списков, восстановить может только
администратор."""
required_capability = "conversations.operate"
def post(self, request: Request, conversation_id: int) -> Response:
try:
conversation = self._conversation(
request, conversation_id, self.required_capability
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
archived = request.data.get("archived")
if not isinstance(archived, bool):
return Response({"detail": "archived must be a boolean"}, status=400)
if not archived and not can_administer_access(request.tenant_context.membership):
return Response(
{"detail": "Восстановить диалог может только администратор"}, status=403
)
conversation.archived_at = timezone.now() if archived else None
conversation.save(update_fields=["archived_at"])
self._audit(request, "archived" if archived else "unarchived", conversation)
return Response(
{
"conversation": conversation_payload(
conversation, with_messages=True, viewer_id=request.user.id
)
}
)
class ConversationCountersView(ConversationViewBase):
"""Счётчики для дерева фильтров (сайдбар сотрудника / охват админа)."""
def get(self, request: Request) -> Response:
# Чистый queryset без инбокс-аннотаций: values().annotate() иначе
# группировал бы по _last_message_at.
base = (
apply_conversation_visibility(
Conversation.objects.filter(
organization_id=request.tenant_context.organization_id
),
request.tenant_context,
)
.filter(archived_at__isnull=True)
.exclude(lifecycle=LifecycleState.SPAM)
.order_by()
)
open_qs = base.filter(lifecycle=LifecycleState.OPEN)
groups = [
{"id": row["group_id"], "name": row["group__name"], "count": row["count"]}
for row in open_qs.filter(group__isnull=False)
.values("group_id", "group__name")
.annotate(count=Count("id"))
.order_by("group__name")
]
ungrouped = open_qs.filter(group__isnull=True).count()
agents = [
{
"id": row["channel_id"],
"name": row["channel__name"],
"count": row["count"],
}
for row in open_qs.values("channel_id", "channel__name")
.annotate(count=Count("id"))
.order_by("channel__name")
]
return Response(
{
"all": open_qs.count(),
"waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(),
"mine": base.filter(assigned_operator_id=request.user.id).count(),
"ungrouped": ungrouped,
"groups": groups,
"agents": agents,
}
)
class LabelListView(APIView):
"""Словарь меток организации. Создание — лёгкое действие оператора
(«+ Добавить» в карточке), правка/удаление — администратора."""
permission_classes = [HasCapability]
required_capabilities = {
"GET": "conversations.view",
"POST": "conversations.operate",
}
def get(self, request: Request) -> Response:
labels = ConversationLabel.objects.filter(
organization_id=request.tenant_context.organization_id
)
return Response({"items": [_label_payload(label) for label in labels]})
def post(self, request: Request) -> Response:
name = str(request.data.get("name", "")).strip()
color = str(request.data.get("color", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
existing = ConversationLabel.objects.filter(
organization_id=request.tenant_context.organization_id, name__iexact=name
).first()
if existing is not None:
return Response({"label": _label_payload(existing)})
label = ConversationLabel.objects.create(
organization_id=request.tenant_context.organization_id,
name=name,
color=color,
)
return Response({"label": _label_payload(label)}, status=201)
class LabelDetailView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"PATCH": "settings.manage", "DELETE": "settings.manage"}
def _label(self, request: Request, label_id: int) -> ConversationLabel:
return ConversationLabel.objects.get(
organization_id=request.tenant_context.organization_id, id=label_id
)
def patch(self, request: Request, label_id: int) -> Response:
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
if "name" in request.data:
name = str(request.data.get("name", "")).strip()
if not name or len(name) > 60:
return Response({"detail": "Название метки: 1-60 символов"}, status=400)
label.name = name
if "color" in request.data:
color = str(request.data.get("color", "")).strip()
if len(color) > 20:
return Response({"detail": "Некорректный цвет"}, status=400)
label.color = color
label.save()
return Response({"label": _label_payload(label)})
def delete(self, request: Request, label_id: int) -> Response:
try:
label = self._label(request, label_id)
except ConversationLabel.DoesNotExist:
return Response({"detail": "Метка не найдена"}, status=404)
label.delete()
return Response(status=204)
class ReplyTemplateListView(APIView):
permission_classes = [HasCapability]
required_capabilities = {
"GET": "conversations.view",
"POST": "settings.manage",
}
def get(self, request: Request) -> Response:
templates = ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id
)
return Response({"items": [_template_payload(item) for item in templates]})
def post(self, request: Request) -> Response:
title = str(request.data.get("title", "")).strip()
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
return Response({"detail": "Шаблон с таким названием уже есть"}, status=409)
template = ReplyTemplate.objects.create(
organization_id=request.tenant_context.organization_id,
title=title,
text=text,
)
return Response({"template": _template_payload(template)}, status=201)
class ReplyTemplateDetailView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"PATCH": "settings.manage", "DELETE": "settings.manage"}
def _template(self, request: Request, template_id: int) -> ReplyTemplate:
return ReplyTemplate.objects.get(
organization_id=request.tenant_context.organization_id, id=template_id
)
def patch(self, request: Request, template_id: int) -> Response:
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
if "title" in request.data:
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": "Название шаблона: 1-120 символов"}, status=400)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": "Текст шаблона обязателен"}, status=400)
template.text = text
template.save()
return Response({"template": _template_payload(template)})
def delete(self, request: Request, template_id: int) -> Response:
try:
template = self._template(request, template_id)
except ReplyTemplate.DoesNotExist:
return Response({"detail": "Шаблон не найден"}, status=404)
template.delete()
return Response(status=204)
@@ -0,0 +1,71 @@
# Generated by Django 5.2.15 on 2026-09-04 16:41
import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0010_conversation_group'),
('identity', '0020_employeegroup_employeegroupmember_and_more'),
]
operations = [
migrations.AddField(
model_name='conversation',
name='archived_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='conversation',
name='note',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='conversation',
name='priority',
field=models.CharField(choices=[('HIGH', 'Высокий'), ('MEDIUM', 'Средний'), ('LOW', 'Низкий'), ('NONE', 'Не задан')], default='NONE', max_length=8),
),
migrations.CreateModel(
name='ConversationLabel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=60)),
('color', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='conversation_labels', to='identity.organization')),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='conversation',
name='labels',
field=models.ManyToManyField(blank=True, related_name='conversations', to='conversations.conversationlabel'),
),
migrations.CreateModel(
name='ReplyTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=120)),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reply_templates', to='identity.organization')),
],
options={
'ordering': ['title'],
},
),
migrations.AddConstraint(
model_name='conversationlabel',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_conversation_label_org_name_ci'),
),
migrations.AddConstraint(
model_name='replytemplate',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('title'), models.F('organization'), name='uniq_reply_template_org_title_ci'),
),
]
@@ -50,6 +50,38 @@ class LifecycleState(models.TextChoices):
SPAM = "SPAM", "Спам"
class ConversationPriority(models.TextChoices):
# Приоритет диалога (дизайн-базлайн v2, решение владельца 2026-09-04).
HIGH = "HIGH", "Высокий"
MEDIUM = "MEDIUM", "Средний"
LOW = "LOW", "Низкий"
NONE = "NONE", "Не задан"
class ConversationLabel(models.Model):
"""Метка диалога: цветной чип, общий словарь организации."""
organization = models.ForeignKey(
"identity.Organization", on_delete=models.PROTECT, related_name="conversation_labels"
)
name = models.CharField(max_length=60)
# HEX-цвет чипа; палитру предлагает клиент.
color = models.CharField(max_length=20, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
models.functions.Lower("name"), "organization",
name="uniq_conversation_label_org_name_ci",
)
]
def __str__(self) -> str:
return f"label:{self.organization_id}/{self.name}"
class ControlMode(models.TextChoices):
AI = "AI", "AI"
HUMAN = "HUMAN", "Оператор"
@@ -97,6 +129,15 @@ class Conversation(models.Model):
)
# «Ответственный» (ADR-HUB-0043): видит диалог независимо от групп.
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
priority = models.CharField(
max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE
)
labels = models.ManyToManyField(ConversationLabel, blank=True, related_name="conversations")
note = models.TextField(blank=True)
# «Удалить диалог» = архив (решение владельца): скрыт из списков, видят
# только администраторы; данные не удаляются.
archived_at = models.DateTimeField(null=True, blank=True)
previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
created_at = models.DateTimeField(auto_now_add=True)
last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True)
@@ -171,3 +212,28 @@ class Message(TenantRelationModel):
def __str__(self) -> str:
return f"msg:{self.conversation_id}/{self.author_type}"
class ReplyTemplate(models.Model):
"""Шаблон ответа оператора («/» в композере). Общий на организацию:
редактируют администраторы, используют все сотрудники."""
organization = models.ForeignKey(
"identity.Organization", on_delete=models.PROTECT, related_name="reply_templates"
)
title = models.CharField(max_length=120)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["title"]
constraints = [
models.UniqueConstraint(
models.functions.Lower("title"), "organization",
name="uniq_reply_template_org_title_ci",
)
]
def __str__(self) -> str:
return f"template:{self.organization_id}/{self.title}"
@@ -17,6 +17,7 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
"support_identity_snapshot",
"group",
)
.prefetch_related("labels")
# Инбокс сортируется по времени последнего сообщения (а не по служебной
# активности вроде claim/takeover); fallback — last_activity_at для
# диалогов без сообщений.
@@ -155,6 +155,21 @@ def conversation_payload(
"isAssignedToViewer": bool(
viewer_id and conversation.assigned_operator_id == viewer_id
),
"group": (
{"id": conversation.group_id, "name": conversation.group.name}
if conversation.group_id
else None
),
# Дизайн-базлайн v2: приоритет, метки, заметка, архив.
"priority": conversation.priority,
"labels": [
{"id": label.id, "name": label.name, "color": label.color}
for label in conversation.labels.all()
],
"note": conversation.note,
"archivedAt": (
conversation.archived_at.isoformat() if conversation.archived_at else None
),
"lastActivityAt": conversation.last_activity_at.isoformat(),
"createdAt": conversation.created_at.isoformat(),
}
@@ -0,0 +1,332 @@
import json
from django.test import TestCase
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.channels.models import Channel
from hub_platform.conversations.models import (
Contact,
Conversation,
ConversationLabel,
ConversationPriority,
ControlMode,
LifecycleState,
ReplyTemplate,
)
from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember
from hub_platform.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
class ChatExtrasTestCase(TestCase):
"""Дизайн-базлайн v2: приоритет, метки, заметка, архив, шаблоны, счётчики."""
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Chat", slug="chat-extras")
self.operators = EmployeeGroup.objects.create(
organization=self.organization, name="Операторы"
)
self.owner = self._member("owner@chat.test", EmployeeRole.OWNER)
self.employee = self._member("employee@chat.test", EmployeeRole.EMPLOYEE)
EmployeeGroupMember.objects.create(
organization=self.organization, group=self.operators, employee=self.employee
)
self.channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
self.conversation = self._conversation("Иван")
self.client = APIClient()
self.client.force_authenticate(self.employee.user)
self.admin_client = APIClient()
self.admin_client.force_authenticate(self.owner.user)
def _member(self, email: str, role: str) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
position_title="Specialist",
)
def _conversation(self, name: str, **extra) -> Conversation:
contact = Contact.objects.create(organization=self.organization, name=name)
return Conversation.objects.create(
organization=self.organization,
channel=self.channel,
contact=contact,
**extra,
)
def _post(self, client, suffix: str, body: dict):
return client.post(
f"/api/v1/conversations/{self.conversation.id}/{suffix}/",
data=json.dumps(body),
content_type="application/json",
)
class PriorityNoteTests(ChatExtrasTestCase):
def test_operator_sets_priority_and_note(self) -> None:
response = self._post(self.client, "priority", {"priority": "HIGH"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["conversation"]["priority"], "HIGH")
response = self._post(self.client, "note", {"note": "Просила писать на почту"})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json()["conversation"]["note"], "Просила писать на почту"
)
def test_unknown_priority_is_rejected(self) -> None:
response = self._post(self.client, "priority", {"priority": "URGENT"})
self.assertEqual(response.status_code, 400)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.priority, ConversationPriority.NONE)
class LabelTests(ChatExtrasTestCase):
def test_operator_creates_label_and_assigns_it(self) -> None:
created = self.client.post(
"/api/v1/conversations/labels/",
data=json.dumps({"name": "доставка", "color": "#1677ff"}),
content_type="application/json",
)
self.assertEqual(created.status_code, 201)
label_id = created.json()["label"]["id"]
# Повтор с тем же именем идемпотентен.
repeat = self.client.post(
"/api/v1/conversations/labels/",
data=json.dumps({"name": "Доставка"}),
content_type="application/json",
)
self.assertEqual(repeat.status_code, 200)
self.assertEqual(repeat.json()["label"]["id"], label_id)
assigned = self._post(self.client, "labels", {"labelIds": [label_id]})
self.assertEqual(assigned.status_code, 200)
self.assertEqual(
[item["name"] for item in assigned.json()["conversation"]["labels"]],
["доставка"],
)
cleared = self._post(self.client, "labels", {"labelIds": []})
self.assertEqual(cleared.json()["conversation"]["labels"], [])
def test_foreign_label_is_rejected(self) -> None:
other = Organization.objects.create(name="Other", slug="chat-extras-other")
foreign = ConversationLabel.objects.create(organization=other, name="чужая")
response = self._post(self.client, "labels", {"labelIds": [foreign.id]})
self.assertEqual(response.status_code, 400)
def test_label_management_requires_admin(self) -> None:
label = ConversationLabel.objects.create(
organization=self.organization, name="возврат"
)
renamed = self.client.patch(
f"/api/v1/conversations/labels/{label.id}/",
data=json.dumps({"name": "обмен"}),
content_type="application/json",
)
self.assertEqual(renamed.status_code, 403)
renamed = self.admin_client.patch(
f"/api/v1/conversations/labels/{label.id}/",
data=json.dumps({"name": "обмен"}),
content_type="application/json",
)
self.assertEqual(renamed.status_code, 200)
deleted = self.admin_client.delete(f"/api/v1/conversations/labels/{label.id}/")
self.assertEqual(deleted.status_code, 204)
class ArchiveTests(ChatExtrasTestCase):
def test_archived_dialog_is_hidden_from_lists_and_admin_restores(self) -> None:
response = self._post(self.client, "archive", {"archived": True})
self.assertEqual(response.status_code, 200)
self.assertIsNotNone(response.json()["conversation"]["archivedAt"])
listed = self.client.get("/api/v1/conversations/")
self.assertEqual(listed.json()["items"], [])
# Архив видит только администратор.
denied = self.client.get("/api/v1/conversations/?archived=1")
self.assertEqual(denied.status_code, 403)
archive = self.admin_client.get("/api/v1/conversations/?archived=1")
self.assertEqual(
[item["id"] for item in archive.json()["items"]], [self.conversation.id]
)
# Восстановление — только администратор.
rejected = self._post(self.client, "archive", {"archived": False})
self.assertEqual(rejected.status_code, 403)
restored = self._post(self.admin_client, "archive", {"archived": False})
self.assertEqual(restored.status_code, 200)
self.assertIsNone(restored.json()["conversation"]["archivedAt"])
def test_spam_is_hidden_from_default_list(self) -> None:
self.conversation.lifecycle = LifecycleState.SPAM
self.conversation.save(update_fields=["lifecycle"])
listed = self.client.get("/api/v1/conversations/")
self.assertEqual(listed.json()["items"], [])
spam = self.client.get("/api/v1/conversations/?lifecycle=SPAM")
self.assertEqual(len(spam.json()["items"]), 1)
class ListFilterTests(ChatExtrasTestCase):
def test_filters_by_agent_assigned_and_waiting(self) -> None:
other_channel = Channel.objects.create(
organization=self.organization, code="second", name="Вторая линия"
)
waiting = self._conversation("Ожидающий", control_mode=ControlMode.PAUSED)
mine = self._conversation("Мой", assigned_operator=self.employee.user)
Conversation.objects.filter(id=mine.id).update(channel=other_channel)
by_agent = self.client.get(f"/api/v1/conversations/?agent={other_channel.id}")
self.assertEqual([i["id"] for i in by_agent.json()["items"]], [mine.id])
assigned = self.client.get("/api/v1/conversations/?assigned=me")
self.assertEqual([i["id"] for i in assigned.json()["items"]], [mine.id])
waits = self.client.get("/api/v1/conversations/?waiting=1")
self.assertEqual([i["id"] for i in waits.json()["items"]], [waiting.id])
def test_search_matches_contact_name(self) -> None:
self._conversation("Мария Соколова")
found = self.client.get("/api/v1/conversations/?q=соколова")
self.assertEqual(len(found.json()["items"]), 1)
empty = self.client.get("/api/v1/conversations/?q=нет-такого")
self.assertEqual(found.status_code, 200)
self.assertEqual(empty.json()["items"], [])
class CountersTests(ChatExtrasTestCase):
def test_counters_reflect_visibility(self) -> None:
support = EmployeeGroup.objects.create(
organization=self.organization, name="Поддержка"
)
self._conversation("Групповой", group=self.operators)
self._conversation("Чужой", group=support)
self._conversation("Мой", assigned_operator=self.employee.user)
self._conversation(
"Ожидающий", group=self.operators, control_mode=ControlMode.PAUSED
)
response = self.client.get("/api/v1/conversations/counters/")
payload = response.json()
# EMPLOYEE не видит диалог чужой группы.
self.assertEqual(payload["all"], 4)
self.assertEqual(payload["waiting"], 1)
self.assertEqual(payload["mine"], 1)
self.assertEqual(
payload["groups"],
[{"id": self.operators.id, "name": "Операторы", "count": 2}],
)
self.assertEqual(payload["ungrouped"], 2)
admin = self.admin_client.get("/api/v1/conversations/counters/").json()
self.assertEqual(admin["all"], 5)
self.assertEqual(len(admin["groups"]), 2)
class ReplyTemplateTests(ChatExtrasTestCase):
def test_admin_manages_templates_employee_reads(self) -> None:
created = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте!"}),
content_type="application/json",
)
self.assertEqual(created.status_code, 201)
template_id = created.json()["template"]["id"]
duplicate = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "приветствие", "text": "Привет"}),
content_type="application/json",
)
self.assertEqual(duplicate.status_code, 409)
forbidden = self.client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "X", "text": "Y"}),
content_type="application/json",
)
self.assertEqual(forbidden.status_code, 403)
listed = self.client.get("/api/v1/conversations/templates/")
self.assertEqual(
[item["title"] for item in listed.json()["items"]], ["Приветствие"]
)
updated = self.admin_client.patch(
f"/api/v1/conversations/templates/{template_id}/",
data=json.dumps({"text": "Здравствуйте! Чем помочь?"}),
content_type="application/json",
)
self.assertEqual(updated.status_code, 200)
deleted = self.admin_client.delete(
f"/api/v1/conversations/templates/{template_id}/"
)
self.assertEqual(deleted.status_code, 204)
self.assertFalse(ReplyTemplate.objects.exists())
class LaunchChecklistTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="New", slug="launch-org")
owner = HumanUser.objects.create_user(
email="owner@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=owner,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.client = APIClient()
self.client.force_authenticate(owner)
def test_checklist_marks_steps_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/launch-checklist/").json()
self.assertEqual(
initial,
{
"agentCreated": False,
"connectionBound": False,
"employeeInvited": False,
"done": False,
},
)
channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
channel=channel,
)
second = HumanUser.objects.create_user(
email="second@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=second,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
position_title="Operator",
)
final = self.client.get("/api/v1/company/launch-checklist/").json()
self.assertTrue(final["done"])
@@ -1,6 +1,6 @@
from django.urls import path
from hub_platform.conversations import reporting_views, views
from hub_platform.conversations import chat_extras_views, reporting_views, views
urlpatterns = [
path("", views.ConversationListView.as_view(), name="conversation-list"),
@@ -18,4 +18,13 @@ urlpatterns = [
path("<int:conversation_id>/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"),
path("<int:conversation_id>/group/", views.ConversationGroupView.as_view(), name="conversation-group"),
path("<int:conversation_id>/assignee/", views.ConversationAssigneeView.as_view(), name="conversation-assignee"),
path("<int:conversation_id>/priority/", chat_extras_views.ConversationPriorityView.as_view(), name="conversation-priority"),
path("<int:conversation_id>/note/", chat_extras_views.ConversationNoteView.as_view(), name="conversation-note"),
path("<int:conversation_id>/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"),
path("<int:conversation_id>/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"),
path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"),
path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"),
path("labels/<int:label_id>/", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"),
path("templates/", chat_extras_views.ReplyTemplateListView.as_view(), name="reply-template-list"),
path("templates/<int:template_id>/", chat_extras_views.ReplyTemplateDetailView.as_view(), name="reply-template-detail"),
]
@@ -22,20 +22,49 @@ from hub_platform.conversations.services import (
from hub_platform.conversations.view_base import ConversationViewBase
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.identity.models import OrganizationMembership
from hub_platform.identity.policy import ResourceScope, authorize
from hub_platform.identity.policy import ResourceScope, authorize, can_administer_access
class ConversationListView(ConversationViewBase):
def get(self, request: Request) -> Response:
from django.db.models import Q
params = request.query_params
items = visible_conversations_for(request.tenant_context)
group = request.query_params.get("group")
# «Удалённые» (архив) скрыты; просмотр архива — только администратор.
if params.get("archived") == "1":
if not can_administer_access(request.tenant_context.membership):
return Response({"detail": "Архив доступен администраторам"}, status=403)
items = items.filter(archived_at__isnull=False)
else:
items = items.filter(archived_at__isnull=True)
group = params.get("group")
if group == "none":
items = items.filter(group__isnull=True)
elif group:
items = items.filter(group_id=group)
lifecycle = request.query_params.get("lifecycle")
agent = params.get("agent")
if agent:
items = items.filter(channel_id=agent)
lifecycle = params.get("lifecycle")
if lifecycle:
items = items.filter(lifecycle=lifecycle)
else:
# Спам не показывается в обычных вкладках (дизайн-базлайн v2).
items = items.exclude(lifecycle=LifecycleState.SPAM)
if params.get("assigned") == "me":
items = items.filter(assigned_operator_id=request.user.id)
if params.get("waiting") == "1":
items = items.filter(
lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED
)
query = params.get("q", "").strip()
if query:
items = items.filter(
Q(contact__name__icontains=query)
| Q(support_identity_snapshot__display_name__icontains=query)
| Q(messages__text__icontains=query)
).distinct()
items = list(items)
# Отметки прочтения просматривающего: бейдж считается персонально.
read_map = dict(
@@ -0,0 +1,94 @@
# Дизайн-базлайн v2: RLS, гранты и cross-tenant триггеры для меток диалогов и
# шаблонов ответов. M2M «диалог-метка» получает indirect-политику и парный
# триггер по образцу ai_aiagent_knowledge_items (tenancy/0002/0003).
from django.db import migrations
ORG_TABLES = ("conversations_conversationlabel", "conversations_replytemplate")
M2M_TABLE = "conversations_conversation_labels"
FORWARD_RLS = """
ALTER TABLE {table} OWNER TO custocrm_schema;
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON {table} FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO custocrm_runtime_app;
GRANT ALL ON {table} TO custocrm_schema;
GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq
TO custocrm_runtime_app, custocrm_schema;
DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table};
CREATE POLICY custocrm_tenant_isolation ON {table}
FOR ALL TO custocrm_runtime_app
USING (organization_id = custocrm.current_organization_id())
WITH CHECK (organization_id = custocrm.current_organization_id());
DROP POLICY IF EXISTS custocrm_schema_access ON {table};
CREATE POLICY custocrm_schema_access ON {table}
FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
"""
M2M_RLS = f"""
ALTER TABLE {M2M_TABLE} OWNER TO custocrm_schema;
ALTER TABLE {M2M_TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {M2M_TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON {M2M_TABLE} FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON {M2M_TABLE} TO custocrm_runtime_app;
GRANT ALL ON {M2M_TABLE} TO custocrm_schema;
GRANT USAGE, SELECT ON SEQUENCE {M2M_TABLE}_id_seq
TO custocrm_runtime_app, custocrm_schema;
DROP POLICY IF EXISTS custocrm_tenant_isolation ON {M2M_TABLE};
CREATE POLICY custocrm_tenant_isolation ON {M2M_TABLE}
FOR ALL TO custocrm_runtime_app
USING (
EXISTS (
SELECT 1 FROM conversations_conversation conversation
JOIN conversations_conversationlabel label ON label.id = conversationlabel_id
WHERE conversation.id = conversation_id
AND conversation.organization_id = custocrm.current_organization_id()
AND label.organization_id = conversation.organization_id
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM conversations_conversation conversation
JOIN conversations_conversationlabel label ON label.id = conversationlabel_id
WHERE conversation.id = conversation_id
AND conversation.organization_id = custocrm.current_organization_id()
AND label.organization_id = conversation.organization_id
)
);
DROP POLICY IF EXISTS custocrm_schema_access ON {M2M_TABLE};
CREATE POLICY custocrm_schema_access ON {M2M_TABLE}
FOR ALL TO custocrm_schema USING (true) WITH CHECK (true);
"""
def apply_guards(apps, schema_editor):
for table in ORG_TABLES:
schema_editor.execute(FORWARD_RLS.format(table=table))
schema_editor.execute(M2M_RLS)
schema_editor.execute(
f"DROP TRIGGER IF EXISTS c04_label_tpair ON {M2M_TABLE}"
)
schema_editor.execute(
f"CREATE CONSTRAINT TRIGGER c04_label_tpair AFTER INSERT OR UPDATE ON {M2M_TABLE} "
"DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "
"custocrm.enforce_tenant_pair('conversations_conversation', 'conversation_id', "
"'conversations_conversationlabel', 'conversationlabel_id')"
)
def remove_guards(apps, schema_editor):
schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_label_tpair ON {M2M_TABLE}")
for table in (*ORG_TABLES, M2M_TABLE):
schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}")
schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_schema_access ON {table}")
schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0020_drop_billing"),
("conversations", "0011_conversation_archived_at_conversation_note_and_more"),
]
operations = [migrations.RunPython(apply_guards, remove_guards)]
@@ -7,14 +7,21 @@ import { Button } from "../../shared/ui-controls";
export function ConversationActions({
open,
canReturnQueue,
onClose,
onSpam,
onReturnQueue,
onArchive,
}: {
open: boolean;
canReturnQueue: boolean;
onClose: () => void;
onSpam: () => Promise<boolean>;
onReturnQueue: () => void;
onArchive: () => Promise<boolean>;
}) {
const [confirmSpam, setConfirmSpam] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const [busy, setBusy] = useState(false);
if (!open) return null;
@@ -28,7 +35,22 @@ export function ConversationActions({
}
}
async function confirmArchiveAction() {
setBusy(true);
try {
if (await onArchive()) setConfirmArchive(false);
} finally {
setBusy(false);
}
}
const items = [
...(canReturnQueue
? [{
key: "queue",
label: <button type="button" onClick={onReturnQueue}><Icon name="refresh" size={15} />Вернуть в очередь</button>,
}]
: []),
{
key: "close",
label: <button type="button" onClick={onClose}><Icon name="lock" size={15} />Закрыть диалог</button>,
@@ -38,6 +60,10 @@ export function ConversationActions({
key: "spam",
label: <button className="danger" type="button" onClick={() => setConfirmSpam(true)}><Icon name="warning" size={15} />Пометить как спам</button>,
},
{
key: "archive",
label: <button className="danger" type="button" onClick={() => setConfirmArchive(true)}><Icon name="trash" size={15} />Удалить диалог</button>,
},
];
return (
@@ -57,6 +83,18 @@ export function ConversationActions({
<Button variant="danger-outline" icon="warning" disabled={busy} onClick={() => void confirm()}>Пометить как спам</Button>
</>}
/>
<DecisionDialog
open={confirmArchive}
onClose={() => !busy && setConfirmArchive(false)}
tone="danger"
icon="trash"
title="Удалить диалог?"
description="Диалог уйдёт в архив и исчезнет из списков. Архив видят только администраторы."
actions={<>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(false)}>Отмена</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmArchiveAction()}>Удалить</Button>
</>}
/>
</>
);
}
@@ -14,7 +14,7 @@ function fmtTime(value: string): string {
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean> }) {
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean> }) {
const timelineRef = useRef<HTMLDivElement>(null);
const messages = detail?.messages ?? [];
const lastMessageId = messages.length ? messages[messages.length - 1].id : 0;
@@ -54,7 +54,7 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals
<IconButton icon="video" label="Запросить видеозвонок" className="is-video" onClick={() => onCall("VIDEO")} />
</>
)}
<ConversationActions open={detail?.lifecycle === "OPEN"} onClose={onClose} onSpam={onSpam} />
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
</div>
</div>
<div className="sales-timeline" ref={timelineRef}>
@@ -12,6 +12,7 @@ import {
fetchConversations,
markConversationAsSpam,
releaseConversation,
setConversationArchived,
returnToQueue,
toConversationListItem,
type ApiConversation,
@@ -27,7 +28,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
isOwner?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null }) => ReactNode;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void }) => ReactNode;
initialConversationId?: number | null;
}) {
const [listTab, setListTab] = useState<ListTab>("all");
@@ -100,9 +101,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
return dialogs.filter((dialog) => {
if (query && !`${dialog.name} ${dialog.email} ${dialog.product} ${dialog.preview}`.toLowerCase().includes(query)) return false;
if (listTab === "wait") return dialog.mode === "wait";
if (listTab === "ai") return dialog.mode === "ai";
if (listTab === "operator") return dialog.mode === "operator";
if (listTab === "unread") return dialog.unread > 0;
if (listTab === "mine") return dialog.isMine;
return true;
});
}, [dialogs, listTab, search]);
@@ -134,6 +133,12 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
const onReturnQueue = () => { void updateConversation(returnToQueue); };
const onClose = () => { void updateConversation(closeConversation); };
const onSpam = () => updateConversation(markConversationAsSpam);
const onArchive = async () => {
const done = await updateConversation((id) => setConversationArchived(id, true));
// Архивный диалог исчезает из списка — снимаем выбор.
if (done) setSelectedId(null);
return done;
};
return (
<div className="sales-dialogs">
@@ -151,7 +156,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
setSelectedId={setSelectedId}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<CallOverlay
open={callController.open}
@@ -177,7 +182,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
onSent={() => selectedId != null && loadDetail(selectedId)}
/>
</section>
{renderContextPanel({ dialog: selectedDialog, detail })}
{renderContextPanel({ dialog: selectedDialog, detail, applyConversation: applyUpdated })}
</div>
);
}
@@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import { ContextSection } from "./ContextSection";
import { PriorityBars } from "./DialogList";
import {
createConversationLabel,
fetchConversationLabels,
setConversationArchived,
setConversationAssignee,
setConversationGroup,
setConversationLabels,
setConversationNote,
setConversationPriority,
type ApiConversation,
type ConversationLabelRef,
type ConversationPriority,
} from "./model";
import type { EmployeeGroupRef } from "../../types";
// Блок «Диалог» контекст-панели (дизайн-базлайн v2 §5): ответственный, группа,
// приоритет, метки, заметка. Селекты группы/ответственного заполняются данными
// менеджера; у сотрудника значения read-only, взятие диалога — в шапке ленты.
const PRIORITY_OPTIONS: Array<[ConversationPriority, string]> = [
["NONE", "Не задан"],
["LOW", "Низкий"],
["MEDIUM", "Средний"],
["HIGH", "Высокий"],
];
export function DialogControls({
detail,
groups,
employees,
applyConversation,
}: {
detail: ApiConversation;
groups: EmployeeGroupRef[];
employees: Array<{ id: number; name: string }>;
applyConversation: (updated: ApiConversation) => void;
}) {
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const onError = (text: string) => setErrorText(text);
const [note, setNote] = useState(detail.note);
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
const [newLabel, setNewLabel] = useState("");
useEffect(() => {
setNote(detail.note);
setNewLabel("");
}, [detail.id, detail.note]);
useEffect(() => {
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
}, []);
async function run(action: () => Promise<ApiConversation>) {
setBusy(true);
setErrorText("");
try {
applyConversation(await action());
} catch (error) {
onError(error instanceof Error ? error.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
async function addLabel() {
const name = newLabel.trim();
if (!name) return;
setBusy(true);
try {
const label = await createConversationLabel(name);
setLabels((current) =>
current.some((item) => item.id === label.id) ? current : [...current, label],
);
const ids = [...detail.labels.map((item) => item.id), label.id];
applyConversation(await setConversationLabels(detail.id, [...new Set(ids)]));
setNewLabel("");
} catch (error) {
onError(error instanceof Error ? error.message : "Не удалось добавить метку");
} finally {
setBusy(false);
}
}
const assignedIds = new Set(detail.labels.map((item) => item.id));
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
const noteDirty = note !== detail.note;
return (
<ContextSection title="ДИАЛОГ">
<div className="dialog-controls">
{errorText && <p className="dialog-controls-error">{errorText}</p>}
<label className="dialog-controls-field">
<span>Ответственный</span>
{employees.length > 0 ? (
<select
disabled={busy}
value={detail.assignedOperatorId ?? ""}
onChange={(event) =>
void run(() =>
setConversationAssignee(
detail.id,
event.target.value ? Number(event.target.value) : null,
),
)
}
>
<option value="">Не назначен</option>
{employees.map((employee) => (
<option value={employee.id} key={employee.id}>{employee.name}</option>
))}
</select>
) : (
<b>{detail.assignedOperator?.name ?? "Не назначен"}</b>
)}
</label>
<label className="dialog-controls-field">
<span>Группа</span>
{groups.length > 0 ? (
<select
disabled={busy}
value={detail.group?.id ?? ""}
onChange={(event) =>
void run(() =>
setConversationGroup(
detail.id,
event.target.value ? Number(event.target.value) : null,
),
)
}
>
<option value="">Без группы — видят все</option>
{groups.map((group) => (
<option value={group.id} key={group.id}>{group.name}</option>
))}
</select>
) : (
<b>{detail.group?.name ?? "Без группы"}</b>
)}
</label>
<label className="dialog-controls-field">
<span>Приоритет</span>
<span className="dialog-controls-priority">
<PriorityBars priority={detail.priority} />
<select
disabled={busy}
value={detail.priority}
onChange={(event) =>
void run(() =>
setConversationPriority(
detail.id,
event.target.value as ConversationPriority,
),
)
}
>
{PRIORITY_OPTIONS.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</select>
</span>
</label>
<div className="dialog-controls-field">
<span>Метки</span>
<div className="dialog-controls-labels">
{detail.labels.map((label) => (
<b className="sales-dialog-label" key={label.id}>
<i style={{ background: label.color || "var(--n-5)" }} />
{label.name}
<button
aria-label={`Снять метку ${label.name}`}
disabled={busy}
type="button"
onClick={() =>
void run(() =>
setConversationLabels(
detail.id,
detail.labels
.filter((item) => item.id !== label.id)
.map((item) => item.id),
),
)
}
>
×
</button>
</b>
))}
{availableLabels.length > 0 && (
<select
className="dialog-controls-add-label"
disabled={busy}
value=""
onChange={(event) => {
const id = Number(event.target.value);
if (!id) return;
void run(() =>
setConversationLabels(detail.id, [
...detail.labels.map((item) => item.id),
id,
]),
);
}}
>
<option value="">+ Метка</option>
{availableLabels.map((label) => (
<option value={label.id} key={label.id}>{label.name}</option>
))}
</select>
)}
</div>
<div className="dialog-controls-new-label">
<input
disabled={busy}
placeholder="Новая метка"
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void addLabel();
}
}}
/>
<button disabled={busy || !newLabel.trim()} type="button" onClick={() => void addLabel()}>
Добавить
</button>
</div>
</div>
<div className="dialog-controls-field">
<span>Заметка</span>
<textarea
disabled={busy}
placeholder="Внутренняя заметка — клиент её не видит"
rows={3}
value={note}
onChange={(event) => setNote(event.target.value)}
/>
{noteDirty && (
<div className="dialog-controls-note-actions">
<button disabled={busy} type="button" onClick={() => setNote(detail.note)}>Отменить</button>
<button
className="primary"
disabled={busy}
type="button"
onClick={() => void run(() => setConversationNote(detail.id, note))}
>
Сохранить
</button>
</div>
)}
</div>
</div>
</ContextSection>
);
}
export function archiveConversationAction(
detail: ApiConversation,
applyConversation: (updated: ApiConversation) => void,
onError: (text: string) => void,
): Promise<boolean> {
return setConversationArchived(detail.id, true)
.then((updated) => {
applyConversation(updated);
return true;
})
.catch((error) => {
onError(error instanceof Error ? error.message : "Не удалось удалить диалог");
return false;
});
}
@@ -20,7 +20,6 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
setSelectedId: (id: number) => void;
}) {
const waitCount = dialogs.filter((dialog) => dialog.mode === "wait").length;
const unreadCount = dialogs.filter((dialog) => dialog.unread > 0).length;
return (
<section className="sales-dialog-list">
<div className="sales-dialog-list-head">
@@ -29,10 +28,8 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
</div>
<div className="sales-dialog-tabs">
<DialogTab active={listTab === "all"} onClick={() => setListTab("all")}>Все</DialogTab>
<DialogTab active={listTab === "mine"} onClick={() => setListTab("mine")}>Мои</DialogTab>
<DialogTab active={listTab === "wait"} onClick={() => setListTab("wait")}>Ждут оператора {waitCount > 0 && <b>{waitCount}</b>}</DialogTab>
<DialogTab active={listTab === "ai"} onClick={() => setListTab("ai")}>AI</DialogTab>
<DialogTab active={listTab === "operator"} onClick={() => setListTab("operator")}>Оператор</DialogTab>
<DialogTab active={listTab === "unread"} onClick={() => setListTab("unread")}>Непрочитанные {unreadCount > 0 && <b>{unreadCount}</b>}</DialogTab>
</div>
<div className="sales-dialog-list-body">
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
@@ -59,7 +56,32 @@ function DialogListItem({ dialog, active, setSelectedId }: { dialog: Conversatio
<span className="sales-dialog-row-title"><strong>{dialog.name}</strong><em>{dialog.time}</em></span>
<span className="sales-dialog-row-meta"><small>{dialog.product}</small><b style={{ background: channel.bg, color: channel.color }}><i style={{ background: channel.color }} />{channel.label}</b></span>
<span className="sales-dialog-row-preview"><small className={dialog.unread ? "unread" : ""}>{dialog.preview}</small>{dialog.unread > 0 && <b>{dialog.unread}</b>}</span>
{(dialog.priority !== "NONE" || dialog.labels.length > 0) && (
<span className="sales-dialog-row-badges">
{dialog.priority !== "NONE" && <PriorityBars priority={dialog.priority} />}
{dialog.labels.map((label) => (
<b className="sales-dialog-label" key={label.id}><i style={{ background: label.color || "var(--n-5)" }} />{label.name}</b>
))}
</span>
)}
</span>
</button>
);
}
const PRIORITY_TITLE: Record<string, string> = { HIGH: "Высокий", MEDIUM: "Средний", LOW: "Низкий" };
const PRIORITY_ON: Record<string, number> = { HIGH: 3, MEDIUM: 2, LOW: 1 };
const PRIORITY_COLOR: Record<string, string> = { HIGH: "#ff4d4f", MEDIUM: "#fa8c16", LOW: "#1677ff" };
export function PriorityBars({ priority }: { priority: "HIGH" | "MEDIUM" | "LOW" | "NONE" }) {
if (priority === "NONE") return null;
const on = PRIORITY_ON[priority] ?? 0;
const color = PRIORITY_COLOR[priority];
return (
<span className="sales-priority-bars" title={PRIORITY_TITLE[priority]}>
{[5, 8, 11].map((height, index) => (
<i key={height} style={{ height, background: index < on ? color : "var(--n-7)" }} />
))}
</span>
);
}
@@ -38,6 +38,21 @@ export type OperatorCard = {
fields: OperatorCardField[];
};
export type ConversationPriority = "HIGH" | "MEDIUM" | "LOW" | "NONE";
export type ConversationLabelRef = { id: number; name: string; color: string };
export type ConversationCounters = {
all: number;
waiting: number;
mine: number;
ungrouped: number;
groups: Array<{ id: number; name: string; count: number }>;
agents: Array<{ id: number; name: string; count: number }>;
};
export type ReplyTemplateRef = { id: number; title: string; text: string; updatedAt: string };
export type ApiConversation = {
id: number;
channel: { code: string; name: string; product: { code: string; name: string } | null };
@@ -53,6 +68,12 @@ export type ApiConversation = {
assignedOperatorId: number | null;
assignedOperator: { id: number; name: string } | null;
isAssignedToViewer: boolean;
group: { id: number; name: string } | null;
// Дизайн-базлайн v2: приоритет, метки, заметка, архив.
priority: ConversationPriority;
labels: ConversationLabelRef[];
note: string;
archivedAt: string | null;
lastActivityAt: string;
createdAt: string;
lastMessage: ApiMessage | null;
@@ -116,13 +137,36 @@ export function toConversationListItem(conversation: ApiConversation): Conversat
preview: conversation.lastMessage?.text.replace(/\s+/g, " ").slice(0, 80) ?? "—",
time: new Date(conversation.lastActivityAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
unread: conversation.pendingCount ?? 0,
isMine: conversation.isAssignedToViewer,
priority: conversation.priority ?? "NONE",
labels: conversation.labels ?? [],
};
}
// Видимость inbox решает backend (ADR-HUB-0043): группы сотрудника + без группы
// + назначенные ему; владелец и админ видят всё.
export const fetchConversations = () =>
api<{ items: ApiConversation[] }>("/api/v1/conversations/").then((r) => r.items);
// + назначенные ему; владелец и админ видят всё. Фильтры — серверные.
export type ConversationListFilters = Partial<{
group: string; // id | "none"
agent: number; // id канала-агента
assigned: "me";
waiting: boolean;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
archived: boolean;
q: string;
}>;
export const fetchConversations = (filters: ConversationListFilters = {}) => {
const params = new URLSearchParams();
if (filters.group) params.set("group", filters.group);
if (filters.agent) params.set("agent", String(filters.agent));
if (filters.assigned) params.set("assigned", filters.assigned);
if (filters.waiting) params.set("waiting", "1");
if (filters.lifecycle) params.set("lifecycle", filters.lifecycle);
if (filters.archived) params.set("archived", "1");
if (filters.q) params.set("q", filters.q);
const suffix = params.size ? `?${params.toString()}` : "";
return api<{ items: ApiConversation[] }>(`/api/v1/conversations/${suffix}`).then((r) => r.items);
};
export const fetchConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/`).then((r) => r.conversation);
export const claimConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/claim/`, { method: "POST" }).then((r) => r.conversation);
export const releaseConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/release/`, { method: "POST" }).then((r) => r.conversation);
@@ -136,6 +180,39 @@ export const markConversationAsSpam = (id: number) => api<{ conversation: ApiCon
// support-метрики — отдельный endpoint); department-параметр backend не использует.
export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/conversations/stats/").then((r) => r.waiting);
// --- Дизайн-базлайн v2: карточка «Диалог», метки, шаблоны, счётчики ---
const conversationAction = (id: number, suffix: string, body: object) =>
api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/${suffix}/`, {
method: "POST",
body: JSON.stringify(body),
}).then((r) => r.conversation);
export const setConversationPriority = (id: number, priority: ConversationPriority) =>
conversationAction(id, "priority", { priority });
export const setConversationNote = (id: number, note: string) =>
conversationAction(id, "note", { note });
export const setConversationLabels = (id: number, labelIds: number[]) =>
conversationAction(id, "labels", { labelIds });
export const setConversationArchived = (id: number, archived: boolean) =>
conversationAction(id, "archive", { archived });
export const setConversationGroup = (id: number, groupId: number | null) =>
conversationAction(id, "group", { groupId });
export const setConversationAssignee = (id: number, userId: number | null) =>
conversationAction(id, "assignee", { userId });
export const fetchConversationCounters = () =>
api<ConversationCounters>("/api/v1/conversations/counters/");
export const fetchConversationLabels = () =>
api<{ items: ConversationLabelRef[] }>("/api/v1/conversations/labels/").then((r) => r.items);
export const createConversationLabel = (name: string, color = "") =>
api<{ label: ConversationLabelRef }>("/api/v1/conversations/labels/", {
method: "POST",
body: JSON.stringify({ name, color }),
}).then((r) => r.label);
export const fetchReplyTemplates = () =>
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
// --- Онлайн-звонки (SPEC-HUB-0013): запрос из диалога, ожидание, отмена ---
export type CallKind = "AUDIO" | "VIDEO";
@@ -3,7 +3,7 @@
export type DialogMode = "ai" | "closed" | "operator" | "wait";
export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting";
export type ListTab = "ai" | "all" | "operator" | "unread" | "wait";
export type ListTab = "all" | "mine" | "wait";
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
@@ -20,6 +20,10 @@ export type ConversationListItem = {
preview: string;
time: string;
unread: number;
// Дизайн-базлайн v2: вкладка «Мои», приоритет и бейджи строки.
isMine: boolean;
priority: "HIGH" | "MEDIUM" | "LOW" | "NONE";
labels: Array<{ id: number; name: string; color: string }>;
};
export type StatusInfo = {
@@ -1,18 +1,18 @@
import { useState } from "react";
import { ConversationWorkspace } from "../../features/conversations/ConversationWorkspace";
import type { SessionUser } from "../../types";
import type { EmployeeGroup, SessionUser } from "../../types";
import { SalesContextPanel } from "./dialogs/SalesContextPanel";
import type { SalesRightTab } from "./dialogs/context/SalesContextTabs";
export function SalesDialogsPage({ initialConversationId, user }: { initialConversationId?: number | null; user: SessionUser }) {
export function SalesDialogsPage({ initialConversationId, user, groups = [], employees = [] }: { initialConversationId?: number | null; user: SessionUser; groups?: EmployeeGroup[]; employees?: Array<{ id: number; name: string }> }) {
const [rightTab, setRightTab] = useState<SalesRightTab>("client");
return (
<ConversationWorkspace
isOwner={user.role === "OWNER"}
initialConversationId={initialConversationId}
renderContextPanel={({ dialog, detail }) => (
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} dialog={dialog} detail={detail} />
renderContextPanel={({ dialog, detail, applyConversation }) => (
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
/>
);
@@ -3,13 +3,14 @@ import { HistoryContext } from "./context/HistoryContext";
import { SalesContextTabs, type SalesRightTab } from "./context/SalesContextTabs";
import type { ApiConversation } from "../../conversations/model";
import type { ConversationListItem } from "../../conversations/types";
import type { EmployeeGroupRef } from "../../../types";
export function SalesContextPanel({ rightTab, setRightTab, dialog, detail }: { rightTab: SalesRightTab; setRightTab: (tab: SalesRightTab) => void; dialog: ConversationListItem | null; detail: ApiConversation | null }) {
export function SalesContextPanel({ rightTab, setRightTab, dialog, detail, groups = [], employees = [], applyConversation }: { rightTab: SalesRightTab; setRightTab: (tab: SalesRightTab) => void; dialog: ConversationListItem | null; detail: ApiConversation | null; groups?: EmployeeGroupRef[]; employees?: Array<{ id: number; name: string }>; applyConversation?: (updated: ApiConversation) => void }) {
return (
<section className="sales-context">
<SalesContextTabs rightTab={rightTab} setRightTab={setRightTab} />
<div className="sales-context-body">
{rightTab === "client" && <ClientContext dialog={dialog} detail={detail} />}
{rightTab === "client" && <ClientContext dialog={dialog} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />}
{rightTab === "history" && <HistoryContext detail={detail} />}
</div>
</section>
@@ -395,3 +395,182 @@
line-height: 1.45;
}
/* --- Блок «Диалог» контекст-панели (дизайн-базлайн v2 §5) --- */
.dialog-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.dialog-controls-error {
margin: 0;
padding: 8px 10px;
border: 1px solid var(--error-border);
border-radius: 8px;
background: var(--error-bg);
color: var(--error-text);
font-size: 12px;
}
.dialog-controls-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.dialog-controls-field > span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 500;
}
.dialog-controls-field > b {
color: var(--text-body);
font-size: 13px;
font-weight: 500;
}
.dialog-controls-field select,
.dialog-controls-field textarea,
.dialog-controls-new-label input {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-body);
font-family: inherit;
font-size: 13px;
}
.dialog-controls-field textarea {
resize: vertical;
line-height: 1.45;
}
.dialog-controls-priority {
display: flex;
align-items: center;
gap: 8px;
}
.dialog-controls-priority select {
flex: 1;
}
.sales-priority-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 11px;
flex: none;
}
.sales-priority-bars i {
display: block;
width: 3px;
border-radius: 1px;
}
.dialog-controls-labels {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
}
.sales-dialog-label {
display: inline-flex;
align-items: center;
gap: 5px;
height: 22px;
padding: 0 7px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--surface-card);
color: var(--text-secondary);
font-size: 11.5px;
font-weight: 500;
}
.sales-dialog-label i {
width: 7px;
height: 7px;
border-radius: 2px;
flex: none;
}
.sales-dialog-label button {
border: none;
background: transparent;
color: var(--text-disabled);
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
}
.sales-dialog-label button:hover {
color: var(--text-body);
}
.dialog-controls-add-label {
width: auto !important;
padding: 3px 6px !important;
font-size: 12px !important;
}
.dialog-controls-new-label {
display: flex;
gap: 6px;
margin-top: 6px;
}
.dialog-controls-new-label button {
flex: none;
padding: 0 12px;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-secondary);
font-size: 12.5px;
cursor: pointer;
}
.dialog-controls-new-label button:disabled {
opacity: 0.5;
cursor: default;
}
.dialog-controls-note-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
}
.dialog-controls-note-actions button {
padding: 5px 12px;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-secondary);
font-size: 12.5px;
cursor: pointer;
}
.dialog-controls-note-actions button.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.sales-dialog-row-badges {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
margin-top: 3px;
}
@@ -4,7 +4,9 @@ import { providerMeta } from "../../../../shared/providers";
import { FieldRow } from "../../../conversations/FieldRow";
import { ContextSection } from "../../../conversations/ContextSection";
import { requestContact, type ApiConversation } from "../../../conversations/model";
import { DialogControls } from "../../../conversations/DialogControls";
import type { ConversationListItem } from "../../../conversations/types";
import type { EmployeeGroupRef } from "../../../../types";
const LIFECYCLE_LABEL: Record<string, string> = { OPEN: "Открыт", CLOSED: "Закрыт", SPAM: "Спам" };
const CONTROL_LABEL: Record<string, string> = { AI: "AI ведёт", HUMAN: "Человек", PAUSED: "Пауза" };
@@ -14,7 +16,7 @@ function fmt(value?: string): string {
return new Date(value).toLocaleString("ru-RU", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
}
export function ClientContext({ dialog, detail }: { dialog: ConversationListItem | null; detail: ApiConversation | null }) {
export function ClientContext({ dialog, detail, groups = [], employees = [], applyConversation }: { dialog: ConversationListItem | null; detail: ApiConversation | null; groups?: EmployeeGroupRef[]; employees?: Array<{ id: number; name: string }>; applyConversation?: (updated: ApiConversation) => void }) {
const [requesting, setRequesting] = useState(false);
const [justRequested, setJustRequested] = useState(false);
const [requestError, setRequestError] = useState(false);
@@ -79,7 +81,16 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem
<FieldRow dot={channel.color} title={channel.label} text={detail?.connection?.name ?? "—"} note={dialog.product} />
</ContextSection>
<ContextSection title="ДИАЛОГ">
{detail && applyConversation && (
<DialogControls
detail={detail}
groups={groups}
employees={employees}
applyConversation={applyConversation}
/>
)}
<ContextSection title="СВЕДЕНИЯ">
<div className="sales-summary-grid">
<div><span>Статус</span><b>{LIFECYCLE_LABEL[detail?.lifecycle ?? ""] ?? "—"}</b></div>
<div><span>Режим</span><b>{CONTROL_LABEL[detail?.controlMode ?? ""] ?? "—"}</b></div>
@@ -1,10 +1,10 @@
import { useState } from "react";
import { ConversationWorkspace } from "../conversations/ConversationWorkspace";
import type { SessionUser } from "../../types";
import type { EmployeeGroup, SessionUser } from "../../types";
import { SupportContextPanel, type SupportRightTab } from "./context/SupportContextPanel";
export function SupportDialogsPage({ initialConversationId, user }: { initialConversationId?: number | null; user: SessionUser }) {
export function SupportDialogsPage({ initialConversationId, user, groups = [], employees = [] }: { initialConversationId?: number | null; user: SessionUser; groups?: EmployeeGroup[]; employees?: Array<{ id: number; name: string }> }) {
const [rightTab, setRightTab] = useState<SupportRightTab>("client");
return (
<ConversationWorkspace
@@ -12,8 +12,8 @@ export function SupportDialogsPage({ initialConversationId, user }: { initialCon
listTitle="Обращения"
searchPlaceholder="Поиск по клиенту, продукту…"
initialConversationId={initialConversationId}
renderContextPanel={({ detail }) => (
<SupportContextPanel rightTab={rightTab} setRightTab={setRightTab} detail={detail} />
renderContextPanel={({ detail, applyConversation }) => (
<SupportContextPanel rightTab={rightTab} setRightTab={setRightTab} detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
/>
);
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import type { ApiConversation } from "../../conversations/model";
import { DialogControls } from "../../conversations/DialogControls";
import { OperatorCards } from "./OperatorCards";
import { SupportHistory } from "./SupportHistory";
import type { EmployeeGroupRef } from "../../../types";
export type SupportRightTab = "client" | "history";
export function SupportContextPanel({ rightTab, setRightTab, detail }: { rightTab: SupportRightTab; setRightTab: (tab: SupportRightTab) => void; detail: ApiConversation | null }) {
export function SupportContextPanel({ rightTab, setRightTab, detail, groups = [], employees = [], applyConversation }: { rightTab: SupportRightTab; setRightTab: (tab: SupportRightTab) => void; detail: ApiConversation | null; groups?: EmployeeGroupRef[]; employees?: Array<{ id: number; name: string }>; applyConversation?: (updated: ApiConversation) => void }) {
return (
<section className="sales-context">
<div className="sales-context-tabs">
@@ -14,7 +16,14 @@ export function SupportContextPanel({ rightTab, setRightTab, detail }: { rightTa
<RightTabButton active={rightTab === "history"} onClick={() => setRightTab("history")}>История</RightTabButton>
</div>
<div className="sales-context-body">
{rightTab === "client" && <OperatorCards detail={detail} />}
{rightTab === "client" && (
<>
{detail && applyConversation && (
<DialogControls detail={detail} groups={groups} employees={employees} applyConversation={applyConversation} />
)}
<OperatorCards detail={detail} />
</>
)}
{rightTab === "history" && <SupportHistory detail={detail} />}
</div>
</section>
@@ -37,7 +37,6 @@ export function ShellRouteContent({ route, data, currentEmployee, selectedProduc
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} />}
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} />}
{(route === "administrationOrganization"
|| route === "administrationSubscription"
|| route === "administrationAudit") && (
<AdministrationPage
route={route}
@@ -48,8 +47,8 @@ export function ShellRouteContent({ route, data, currentEmployee, selectedProduc
{route === "salesClients" && <SalesClientsPage openClient={openClient} />}
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} openConversation={openConversation} />}
{route === "supportOverview" && <SupportOverviewPage />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} user={user} groups={data.groups} employees={dialogAssignees(data)} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} user={user} groups={data.groups} employees={dialogAssignees(data)} />}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
{route === "agentDetail" && (
<AgentDetailPage
@@ -73,3 +72,11 @@ export function ShellRouteContent({ route, data, currentEmployee, selectedProduc
</>
);
}
// Кандидаты в «Ответственные»: id сотрудника в employee-API — это id пользователя.
function dialogAssignees(data: AppData): Array<{ id: number; name: string }> {
return data.employees.map((employee) => ({
id: employee.id,
name: employee.fullName || employee.email,
}));
}