mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
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)
|
||||
+71
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user