mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(conversations): complete email dialogs and contacts
This commit is contained in:
1 parent
7e9667ee88
commit
55ab309ae7
61 files changed
+1384
-262
No files matched your search
@@ -21,7 +21,12 @@ from hub_platform.identity.models import AuditEvent
|
||||
from hub_platform.orders.models import Order, PaymentStatus
|
||||
|
||||
# Короткие коды для UI (совпадают с фронтовыми справочниками).
|
||||
PROVIDER_CODE = {"MAX": "MAX", "TELEGRAM": "TG", "WEB": "WEB"}
|
||||
PROVIDER_CODE = {
|
||||
"MAX": "MAX",
|
||||
"TELEGRAM": "TG",
|
||||
"WEB": "WEB",
|
||||
"EMAIL": "EMAIL",
|
||||
}
|
||||
PRODUCT_CODE = {"firepage": "FP", "foxray": "FX"}
|
||||
|
||||
# Понятные подписи для аудита диалогов.
|
||||
@@ -30,6 +35,7 @@ AUDIT_LABELS = {
|
||||
"conversations.released_to_ai": "Возврат к AI",
|
||||
"conversations.returned_to_queue": "Возврат в очередь",
|
||||
"conversations.closed": "Диалог закрыт",
|
||||
"conversations.marked_spam": "Диалог помечен как спам",
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +102,14 @@ def clients_overview(
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"phone": contact.phone,
|
||||
"email": next(
|
||||
(
|
||||
identity.external_user_id
|
||||
for identity in contact.identities.all()
|
||||
if identity.connection.provider == "EMAIL"
|
||||
),
|
||||
"",
|
||||
),
|
||||
# Первый непустой @логин среди identity каналов (остальные — в карточке).
|
||||
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
|
||||
# Статус выводится из данных: есть оплаченный заказ — клиент, иначе лид.
|
||||
@@ -167,7 +181,11 @@ def client_detail(
|
||||
identities = [
|
||||
{
|
||||
"provider": identity.connection.provider,
|
||||
"value": identity.display_name or identity.external_user_id,
|
||||
"value": (
|
||||
identity.external_user_id
|
||||
if identity.connection.provider == "EMAIL"
|
||||
else identity.display_name or identity.external_user_id
|
||||
),
|
||||
"username": identity.username,
|
||||
"createdAt": identity.created_at.isoformat(),
|
||||
}
|
||||
@@ -228,6 +246,14 @@ def client_detail(
|
||||
"cid": f"CUS-{contact.id}",
|
||||
"name": contact.name or "Гость",
|
||||
"phone": contact.phone,
|
||||
"email": next(
|
||||
(
|
||||
identity.external_user_id
|
||||
for identity in identity_qs
|
||||
if identity.connection.provider == "EMAIL"
|
||||
),
|
||||
"",
|
||||
),
|
||||
"channels": sorted(channels),
|
||||
"products": sorted(products),
|
||||
"openDialogs": open_dialogs,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"a",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"div",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"span",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
}
|
||||
_BLOCKED_TAGS = {
|
||||
"applet",
|
||||
"audio",
|
||||
"button",
|
||||
"embed",
|
||||
"form",
|
||||
"head",
|
||||
"iframe",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"object",
|
||||
"script",
|
||||
"select",
|
||||
"style",
|
||||
"svg",
|
||||
"textarea",
|
||||
"title",
|
||||
"video",
|
||||
}
|
||||
_VOID_TAGS = {"br", "hr"}
|
||||
|
||||
|
||||
def _safe_href(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
parsed = urlsplit(candidate)
|
||||
if parsed.scheme.lower() not in {"http", "https", "mailto"}:
|
||||
return ""
|
||||
return candidate
|
||||
|
||||
|
||||
class _Sanitizer(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self.blocked_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in _BLOCKED_TAGS:
|
||||
self.blocked_depth += 1
|
||||
return
|
||||
if self.blocked_depth or tag not in _ALLOWED_TAGS:
|
||||
return
|
||||
safe_attrs: list[str] = []
|
||||
if tag == "a":
|
||||
href = next((value or "" for key, value in attrs if key.lower() == "href"), "")
|
||||
href = _safe_href(href)
|
||||
if href:
|
||||
safe_attrs.extend(
|
||||
[
|
||||
f'href="{html.escape(href, quote=True)}"',
|
||||
'target="_blank"',
|
||||
'rel="noopener noreferrer"',
|
||||
]
|
||||
)
|
||||
suffix = f" {' '.join(safe_attrs)}" if safe_attrs else ""
|
||||
self.parts.append(f"<{tag}{suffix}>")
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in _BLOCKED_TAGS:
|
||||
self.blocked_depth = max(0, self.blocked_depth - 1)
|
||||
return
|
||||
if self.blocked_depth or tag not in _ALLOWED_TAGS or tag in _VOID_TAGS:
|
||||
return
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.blocked_depth:
|
||||
self.parts.append(html.escape(data))
|
||||
|
||||
|
||||
def sanitize_email_html(markup: str) -> str:
|
||||
parser = _Sanitizer()
|
||||
parser.feed(markup)
|
||||
parser.close()
|
||||
return "".join(parser.parts).strip()
|
||||
@@ -76,6 +76,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
logger.warning("Integration %s has no channel — inbound dropped", integration.id)
|
||||
return
|
||||
context = TenantContext.for_resource(channel.organization)
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
ai_available = bool(agent and agent.is_active)
|
||||
source = f"{integration.provider.lower()}:{integration.id}"
|
||||
if _already_processed(context, source, inbound.external_id, inbound.text):
|
||||
return
|
||||
@@ -123,8 +125,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
connection=integration,
|
||||
contact=contact,
|
||||
external_chat_id=inbound.chat_id,
|
||||
control_mode=ControlMode.AI,
|
||||
expected_responder=ExpectedResponder.AI,
|
||||
control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED,
|
||||
expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR,
|
||||
previous_conversation=previous,
|
||||
)
|
||||
# C07: only a brand-new dialog counts toward new_dialogs quota.
|
||||
@@ -146,10 +148,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
author_type=MessageAuthor.CONTACT,
|
||||
kind=MessageKind.CONTACT if is_contact_share else MessageKind.TEXT,
|
||||
text=message_text,
|
||||
content_html=inbound.content_html,
|
||||
external_id=inbound.external_id,
|
||||
)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
update_fields = ["external_chat_id", "last_activity_at"]
|
||||
if conversation.control_mode == ControlMode.AI and not ai_available:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
update_fields.extend(["control_mode", "expected_responder"])
|
||||
if inbound.thread_meta:
|
||||
# Email: Message-ID последнего входящего — для ответа в тред;
|
||||
# тема диалога фиксируется по первому письму (ADR-HUB-0035).
|
||||
@@ -202,7 +209,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
transports.send_contact_ack(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=ack)
|
||||
return
|
||||
|
||||
# AI отвечает только когда диалог ведёт AI (ADR-HUB-0003).
|
||||
# Операторский канал без активного агента сразу создаёт очередь и не
|
||||
# имитирует сбой AI перед клиентом.
|
||||
if conversation.control_mode != ControlMode.AI:
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("conversations", "0007_conversation_transport_meta"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="message",
|
||||
name="content_html",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -145,6 +145,9 @@ class Message(TenantRelationModel):
|
||||
# телефона), полученный контакт. Пустая строка = текст.
|
||||
kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True)
|
||||
text = models.TextField(blank=True)
|
||||
# Санитизированный HTML входящего email. Остальные транспорты и исходящие
|
||||
# ответы используют plain text.
|
||||
content_html = models.TextField(blank=True)
|
||||
external_id = models.CharField(max_length=128, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ def poll_all_messengers(context) -> int:
|
||||
for integration in Integration.objects.filter(
|
||||
organization=context.organization,
|
||||
provider__in=transports.SUPPORTED_PROVIDERS,
|
||||
is_active=True,
|
||||
channel__isnull=False,
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from hub_platform.conversations.clients import client_detail, clients_overview
|
||||
from hub_platform.conversations.command import command_center_overview
|
||||
from hub_platform.conversations.models import Contact
|
||||
from hub_platform.conversations.stats import sales_overview_stats
|
||||
from hub_platform.conversations.view_base import ConversationViewBase
|
||||
from hub_platform.identity.policy import accessible_department_ids
|
||||
|
||||
|
||||
class ConversationStatsView(ConversationViewBase):
|
||||
def get(self, request: Request) -> Response:
|
||||
period = request.query_params.get("period", "today")
|
||||
if period not in ("today", "d7", "d30"):
|
||||
period = "today"
|
||||
department_ids = accessible_department_ids(
|
||||
request.tenant_context.membership, self.required_capability
|
||||
)
|
||||
return Response(
|
||||
sales_overview_stats(request.tenant_context, period, department_ids)
|
||||
)
|
||||
|
||||
|
||||
class CommandOverviewView(ConversationViewBase):
|
||||
required_capability = "company.view"
|
||||
require_organization_scope = True
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
period = request.query_params.get("period", "today")
|
||||
if period not in ("today", "d7", "d30"):
|
||||
period = "today"
|
||||
return Response(command_center_overview(request.tenant_context, period))
|
||||
|
||||
|
||||
class ClientsView(ConversationViewBase):
|
||||
required_capability = "customers.view"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
department_ids = accessible_department_ids(
|
||||
request.tenant_context.membership, self.required_capability
|
||||
)
|
||||
return Response(
|
||||
{"items": clients_overview(self._org(request).id, department_ids)}
|
||||
)
|
||||
|
||||
|
||||
class ClientDetailView(ConversationViewBase):
|
||||
required_capability = "customers.view"
|
||||
|
||||
def get(self, request: Request, contact_id: int) -> Response:
|
||||
try:
|
||||
department_ids = accessible_department_ids(
|
||||
request.tenant_context.membership, self.required_capability
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"client": client_detail(
|
||||
self._org(request).id, contact_id, department_ids
|
||||
)
|
||||
}
|
||||
)
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
@@ -1,4 +1,5 @@
|
||||
from hub_platform.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor
|
||||
from hub_platform.integrations.models import IntegrationProvider
|
||||
|
||||
|
||||
def message_payload(message: Message) -> dict[str, object]:
|
||||
@@ -8,6 +9,7 @@ def message_payload(message: Message) -> dict[str, object]:
|
||||
"authorUserId": message.author_user_id,
|
||||
"kind": message.kind,
|
||||
"text": message.text,
|
||||
"contentHtml": message.content_html,
|
||||
"createdAt": message.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -74,6 +76,15 @@ def _contact_username(conversation: Conversation) -> str:
|
||||
return identity.username if identity else ""
|
||||
|
||||
|
||||
def _contact_email(conversation: Conversation) -> str:
|
||||
if (
|
||||
conversation.connection_id
|
||||
and conversation.connection.provider == IntegrationProvider.EMAIL
|
||||
):
|
||||
return conversation.external_chat_id
|
||||
return ""
|
||||
|
||||
|
||||
def _conversation_history(conversation: Conversation) -> list[Conversation]:
|
||||
# История по тому же источнику identity: для sales — по contact, для
|
||||
# support — по snapshot (ADR-HUB-0002: цепочка прошлых обращений).
|
||||
@@ -90,7 +101,13 @@ def _conversation_history(conversation: Conversation) -> list[Conversation]:
|
||||
)
|
||||
|
||||
|
||||
def conversation_payload(conversation: Conversation, *, with_messages: bool = False, last_read_id: int = 0) -> dict[str, object]:
|
||||
def conversation_payload(
|
||||
conversation: Conversation,
|
||||
*,
|
||||
with_messages: bool = False,
|
||||
last_read_id: int = 0,
|
||||
viewer_id: int | None = None,
|
||||
) -> dict[str, object]:
|
||||
last = None if with_messages else _last_message(conversation)
|
||||
channel = conversation.channel
|
||||
payload = {
|
||||
@@ -112,6 +129,7 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa
|
||||
"id": conversation.contact_id,
|
||||
"name": conversation.contact.name,
|
||||
"phone": conversation.contact.phone,
|
||||
"email": _contact_email(conversation),
|
||||
"username": _contact_username(conversation) if with_messages else "",
|
||||
}
|
||||
if conversation.contact_id
|
||||
@@ -122,6 +140,20 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa
|
||||
"controlMode": conversation.control_mode,
|
||||
"expectedResponder": conversation.expected_responder,
|
||||
"assignedOperatorId": conversation.assigned_operator_id,
|
||||
"assignedOperator": (
|
||||
{
|
||||
"id": conversation.assigned_operator_id,
|
||||
"name": (
|
||||
conversation.assigned_operator.full_name
|
||||
or conversation.assigned_operator.email
|
||||
),
|
||||
}
|
||||
if conversation.assigned_operator_id
|
||||
else None
|
||||
),
|
||||
"isAssignedToViewer": bool(
|
||||
viewer_id and conversation.assigned_operator_id == viewer_id
|
||||
),
|
||||
"lastActivityAt": conversation.last_activity_at.isoformat(),
|
||||
"createdAt": conversation.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ class ClaimError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_open(conversation: Conversation) -> None:
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
raise ClaimError("Диалог закрыт")
|
||||
|
||||
|
||||
def _operator_label(operator) -> str:
|
||||
return getattr(operator, "full_name", "") or operator.email
|
||||
|
||||
@@ -41,6 +46,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
operator = context.actor_user
|
||||
if operator is None or conversation.organization_id != context.organization_id:
|
||||
raise ClaimError("Диалог недоступен")
|
||||
_require_open(conversation)
|
||||
if (
|
||||
conversation.control_mode == ControlMode.HUMAN
|
||||
and conversation.assigned_operator_id
|
||||
@@ -64,6 +70,10 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
agent = getattr(conversation.channel, "ai_agent", None)
|
||||
if agent is None or not agent.is_active:
|
||||
raise ClaimError("У канала нет активного AI-агента")
|
||||
conversation.control_mode = ControlMode.AI
|
||||
conversation.assigned_operator = None
|
||||
conversation.expected_responder = ExpectedResponder.AI
|
||||
@@ -78,6 +88,7 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.assigned_operator = None
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
@@ -92,6 +103,7 @@ def post_operator_message(
|
||||
operator = context.actor_user
|
||||
if operator is None or conversation.organization_id != context.organization_id:
|
||||
raise Conversation.DoesNotExist
|
||||
_require_open(conversation)
|
||||
message = Message.objects.create(
|
||||
conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
|
||||
)
|
||||
@@ -118,6 +130,7 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me
|
||||
operator = context.actor_user
|
||||
if operator is None or conversation.organization_id != context.organization_id:
|
||||
raise Conversation.DoesNotExist
|
||||
_require_open(conversation)
|
||||
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
|
||||
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
|
||||
message = Message.objects.create(
|
||||
@@ -148,7 +161,40 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
conversation.lifecycle = LifecycleState.CLOSED
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.assigned_operator = None
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
conversation.save(update_fields=["lifecycle", "expected_responder"])
|
||||
conversation.save(
|
||||
update_fields=[
|
||||
"lifecycle",
|
||||
"control_mode",
|
||||
"assigned_operator",
|
||||
"expected_responder",
|
||||
]
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_conversation_as_spam(
|
||||
*, context: TenantContext, conversation_id: int
|
||||
) -> Conversation:
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
conversation.lifecycle = LifecycleState.SPAM
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.assigned_operator = None
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
conversation.save(
|
||||
update_fields=[
|
||||
"lifecycle",
|
||||
"control_mode",
|
||||
"assigned_operator",
|
||||
"expected_responder",
|
||||
]
|
||||
)
|
||||
return conversation
|
||||
@@ -8,6 +8,8 @@ from django.test import TestCase
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from hub_platform.conversations.clients import client_detail, clients_overview
|
||||
from hub_platform.conversations.serializers import conversation_payload
|
||||
from hub_platform.conversations.transports import email as email_transport
|
||||
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
|
||||
from hub_platform.identity.models import Organization
|
||||
@@ -86,6 +88,29 @@ class EmailNormalizeTests(TestCase):
|
||||
self.assertIn("Добрый день!", inbound.text)
|
||||
self.assertIn("«FoxRay»", inbound.text)
|
||||
self.assertNotIn("<p>", inbound.text)
|
||||
self.assertIn("<p>Добрый день!</p>", inbound.content_html)
|
||||
|
||||
def test_html_body_is_sanitized_before_ingest(self) -> None:
|
||||
inbound, _ = email_transport._normalize(
|
||||
_parsed(
|
||||
_raw(
|
||||
html=(
|
||||
'<p onclick="steal()">Здравствуйте!</p>'
|
||||
'<script>alert("xss")</script>'
|
||||
'<a href="javascript:alert(1)">опасная ссылка</a>'
|
||||
'<a href="https://example.com/path">сайт</a>'
|
||||
)
|
||||
)
|
||||
),
|
||||
own_address="support@edevs.tech",
|
||||
fallback_id="7:100",
|
||||
)
|
||||
self.assertIsNotNone(inbound)
|
||||
self.assertNotIn("onclick", inbound.content_html)
|
||||
self.assertNotIn("script", inbound.content_html)
|
||||
self.assertNotIn("alert", inbound.content_html)
|
||||
self.assertNotIn("javascript:", inbound.content_html)
|
||||
self.assertIn('href="https://example.com/path"', inbound.content_html)
|
||||
|
||||
def test_attachments_add_note(self) -> None:
|
||||
inbound, _ = email_transport._normalize(
|
||||
@@ -225,6 +250,7 @@ class EmailIngestThreadMetaTests(TestCase):
|
||||
with (
|
||||
mock.patch("hub_platform.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")),
|
||||
mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True),
|
||||
mock.patch("hub_platform.conversations.ingest.record_usage"),
|
||||
):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
@@ -236,3 +262,23 @@ class EmailIngestThreadMetaTests(TestCase):
|
||||
self.assertEqual(conversation.transport_meta["last_message_id"], "<m2@example.com>")
|
||||
authors = list(conversation.messages.values_list("author_type", flat=True))
|
||||
self.assertIn(MessageAuthor.CONTACT, authors)
|
||||
|
||||
def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:
|
||||
self._ingest(
|
||||
external_id="<identity@example.com>",
|
||||
subject="Контакты",
|
||||
message_id="<identity@example.com>",
|
||||
)
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
dialog = conversation_payload(conversation, with_messages=True)
|
||||
self.assertEqual(dialog["connection"]["provider"], "EMAIL")
|
||||
self.assertEqual(dialog["contact"]["email"], "ivan@example.com")
|
||||
self.assertEqual(dialog["messages"][0]["contentHtml"], "")
|
||||
|
||||
overview = clients_overview(self.organization.id)
|
||||
self.assertEqual(overview[0]["email"], "ivan@example.com")
|
||||
self.assertIn("EMAIL", overview[0]["channels"])
|
||||
|
||||
detail = client_detail(self.organization.id, conversation.contact_id)
|
||||
self.assertEqual(detail["email"], "ivan@example.com")
|
||||
self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")
|
||||
@@ -0,0 +1,190 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.conversations.ingest import ingest_inbound
|
||||
from hub_platform.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
MessageAuthor,
|
||||
)
|
||||
from hub_platform.conversations.transports.base import InboundMessage
|
||||
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
|
||||
from hub_platform.identity.models import HumanUser, Organization
|
||||
from hub_platform.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from hub_platform.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
return Integration.objects.create(
|
||||
organization=channel.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="test-bot",
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
class OperatorOnlyIngestTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech", password="temporary-password"
|
||||
)
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="operator-only",
|
||||
name="Операторский канал",
|
||||
)
|
||||
self.integration = _connection(self.channel)
|
||||
|
||||
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
|
||||
with (
|
||||
mock.patch(
|
||||
"hub_platform.conversations.ingest.run_channel_turn"
|
||||
) as ai_turn,
|
||||
mock.patch(
|
||||
"hub_platform.conversations.ingest.transports.send_reply"
|
||||
) as send,
|
||||
):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
return ai_turn, send
|
||||
|
||||
def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
InboundMessage(
|
||||
external_id="operator-1",
|
||||
user_id="user-1",
|
||||
chat_id="chat-1",
|
||||
text="Нужна помощь",
|
||||
display_name="Гость",
|
||||
)
|
||||
)
|
||||
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
self.assertEqual(
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR
|
||||
)
|
||||
self.assertEqual(
|
||||
list(conversation.messages.values_list("author_type", flat=True)),
|
||||
[MessageAuthor.CONTACT],
|
||||
)
|
||||
ai_turn.assert_not_called()
|
||||
send.assert_not_called()
|
||||
|
||||
def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:
|
||||
contact = Contact.objects.create(
|
||||
organization=self.organization, name="Клиент"
|
||||
)
|
||||
ConnectionIdentity.objects.create(
|
||||
contact=contact,
|
||||
connection=self.integration,
|
||||
external_user_id="user-existing",
|
||||
)
|
||||
conversation = Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
connection=self.integration,
|
||||
contact=contact,
|
||||
external_chat_id="chat-existing",
|
||||
control_mode=ControlMode.AI,
|
||||
expected_responder=ExpectedResponder.AI,
|
||||
)
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
InboundMessage(
|
||||
external_id="operator-2",
|
||||
user_id="user-existing",
|
||||
chat_id="chat-existing",
|
||||
text="Вы здесь?",
|
||||
display_name="Клиент",
|
||||
)
|
||||
)
|
||||
|
||||
conversation.refresh_from_db()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
self.assertEqual(
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR
|
||||
)
|
||||
self.assertEqual(conversation.messages.count(), 1)
|
||||
ai_turn.assert_not_called()
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
class ClosedConversationActionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech", password="temporary-password"
|
||||
)
|
||||
organization = Organization.objects.get(slug="edevs")
|
||||
channel = Channel.objects.create(
|
||||
organization=organization,
|
||||
code="closed-actions",
|
||||
name="Закрытые диалоги",
|
||||
)
|
||||
integration = _connection(channel)
|
||||
contact = Contact.objects.create(organization=organization, name="Иван")
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
self.conversation = Conversation.objects.create(
|
||||
organization=organization,
|
||||
channel=channel,
|
||||
connection=integration,
|
||||
contact=contact,
|
||||
lifecycle=LifecycleState.CLOSED,
|
||||
control_mode=ControlMode.HUMAN,
|
||||
assigned_operator=owner,
|
||||
expected_responder=ExpectedResponder.NOBODY,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech", password="temporary-password"
|
||||
)
|
||||
|
||||
def test_closed_dialog_rejects_operational_actions(self) -> None:
|
||||
base = f"/api/v1/conversations/{self.conversation.id}"
|
||||
self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)
|
||||
self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)
|
||||
self.assertEqual(
|
||||
self.client.post(f"{base}/return-queue/").status_code, 409
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(
|
||||
f"{base}/messages/",
|
||||
data=json.dumps({"text": "Поздний ответ"}),
|
||||
content_type="application/json",
|
||||
).status_code,
|
||||
409,
|
||||
)
|
||||
self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)
|
||||
self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)
|
||||
|
||||
def test_open_dialog_can_be_marked_as_spam(self) -> None:
|
||||
self.conversation.lifecycle = LifecycleState.OPEN
|
||||
self.conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
self.conversation.save(
|
||||
update_fields=["lifecycle", "expected_responder"]
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/conversations/{self.conversation.id}/spam/"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["conversation"]
|
||||
self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)
|
||||
self.assertEqual(payload["controlMode"], ControlMode.PAUSED)
|
||||
self.assertIsNone(payload["assignedOperator"])
|
||||
self.assertEqual(
|
||||
payload["expectedResponder"], ExpectedResponder.NOBODY
|
||||
)
|
||||
@@ -24,6 +24,9 @@ class InboundMessage:
|
||||
username: str = ""
|
||||
# Телефон из явного шаринга контакта (кнопка/форма); text при этом может быть пуст.
|
||||
phone: str = ""
|
||||
# Безопасный форматированный вариант входящего письма. Plain text остаётся
|
||||
# обязательным fallback для AI, поиска, уведомлений и превью.
|
||||
content_html: str = ""
|
||||
# Транспортная мета для тредирования ответа (email: subject/last_message_id).
|
||||
# Пишется в Conversation.transport_meta при ingest (ADR-HUB-0035).
|
||||
thread_meta: dict | None = None
|
||||
|
||||
@@ -18,6 +18,7 @@ from email.utils import parseaddr
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from hub_platform.conversations.html_sanitizer import sanitize_email_html
|
||||
from hub_platform.conversations.transports.base import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +57,13 @@ def _body_text(message) -> str:
|
||||
return _html_to_text(str(rich.get_content())) if rich is not None else ""
|
||||
|
||||
|
||||
def _body_html(message) -> str:
|
||||
rich = message.get_body(preferencelist=("html",))
|
||||
if rich is None:
|
||||
return ""
|
||||
return sanitize_email_html(str(rich.get_content()))
|
||||
|
||||
|
||||
def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundMessage | None, str]:
|
||||
"""Parse one RFC822 message → (InboundMessage | None, raw Message-ID)."""
|
||||
display_name, address = parseaddr(str(message.get("From", "")))
|
||||
@@ -65,6 +73,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM
|
||||
return None, ""
|
||||
message_id = str(message.get("Message-ID", "")).strip()
|
||||
text = _body_text(message)
|
||||
content_html = _body_html(message)
|
||||
attachments = sum(1 for _ in message.iter_attachments())
|
||||
if attachments:
|
||||
# Вложения в первой итерации не принимаются (ADR-HUB-0035).
|
||||
@@ -77,6 +86,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM
|
||||
user_id=address,
|
||||
chat_id=address,
|
||||
text=text,
|
||||
content_html=content_html,
|
||||
display_name=str(display_name).strip() or address,
|
||||
thread_meta={"subject": str(message.get("Subject", "")).strip(), "last_message_id": message_id},
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from hub_platform.conversations import views
|
||||
from hub_platform.conversations import reporting_views, views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ConversationListView.as_view(), name="conversation-list"),
|
||||
path("stats/", views.ConversationStatsView.as_view(), name="conversation-stats"),
|
||||
path("command-overview/", views.CommandOverviewView.as_view(), name="conversation-command-overview"),
|
||||
path("clients/", views.ClientsView.as_view(), name="conversation-clients"),
|
||||
path("clients/<int:contact_id>/", views.ClientDetailView.as_view(), name="conversation-client-detail"),
|
||||
path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"),
|
||||
path("command-overview/", reporting_views.CommandOverviewView.as_view(), name="conversation-command-overview"),
|
||||
path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"),
|
||||
path("clients/<int:contact_id>/", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"),
|
||||
path("<int:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
|
||||
path("<int:conversation_id>/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"),
|
||||
path("<int:conversation_id>/release/", views.ConversationReleaseView.as_view(), name="conversation-release"),
|
||||
@@ -15,4 +15,5 @@ urlpatterns = [
|
||||
path("<int:conversation_id>/messages/", views.ConversationMessageView.as_view(), name="conversation-messages"),
|
||||
path("<int:conversation_id>/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"),
|
||||
path("<int:conversation_id>/close/", views.ConversationCloseView.as_view(), name="conversation-close"),
|
||||
path("<int:conversation_id>/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from hub_platform.api.permissions import HasCapability
|
||||
from hub_platform.conversations.models import Conversation
|
||||
from hub_platform.conversations.selectors import conversation_for_context
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.identity.policy import require_capability
|
||||
|
||||
|
||||
class ConversationViewBase(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "conversations.view"
|
||||
|
||||
def _org(self, request: Request):
|
||||
return request.tenant_context.organization
|
||||
|
||||
def _conversation(
|
||||
self,
|
||||
request: Request,
|
||||
conversation_id: int,
|
||||
capability: str = "conversations.view",
|
||||
) -> Conversation:
|
||||
conversation = conversation_for_context(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
if not require_capability(
|
||||
request.tenant_context.membership, capability, conversation
|
||||
):
|
||||
raise Conversation.DoesNotExist
|
||||
return conversation
|
||||
|
||||
def _audit(
|
||||
self, request: Request, action: str, conversation: Conversation
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
action=f"conversations.{action}",
|
||||
actor=request.user,
|
||||
organization=self._org(request),
|
||||
object_type="Conversation",
|
||||
object_id=str(conversation.id),
|
||||
request=request,
|
||||
)
|
||||
@@ -1,67 +1,33 @@
|
||||
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.clients import client_detail, clients_overview
|
||||
from hub_platform.conversations.command import command_center_overview
|
||||
from hub_platform.conversations.models import Contact, ControlMode, Conversation, ConversationRead
|
||||
from hub_platform.conversations.selectors import (
|
||||
conversation_for_context,
|
||||
conversations_for_context,
|
||||
from hub_platform.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationRead,
|
||||
LifecycleState,
|
||||
)
|
||||
from hub_platform.conversations.selectors import conversations_for_context
|
||||
from hub_platform.conversations.serializers import conversation_payload, message_payload
|
||||
from hub_platform.conversations.services import (
|
||||
ClaimError,
|
||||
claim_conversation,
|
||||
close_conversation,
|
||||
mark_conversation_as_spam,
|
||||
post_operator_message,
|
||||
release_to_ai,
|
||||
request_contact,
|
||||
return_to_queue,
|
||||
)
|
||||
from hub_platform.conversations.stats import sales_overview_stats
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.conversations.view_base import ConversationViewBase
|
||||
from hub_platform.identity.policy import (
|
||||
ResourceScope,
|
||||
accessible_department_ids,
|
||||
authorize,
|
||||
require_capability,
|
||||
)
|
||||
|
||||
|
||||
class _Base(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "conversations.view"
|
||||
|
||||
def _org(self, request: Request):
|
||||
return request.tenant_context.organization
|
||||
|
||||
def _conversation(
|
||||
self,
|
||||
request: Request,
|
||||
conversation_id: int,
|
||||
capability: str = "conversations.view",
|
||||
) -> Conversation:
|
||||
conversation = conversation_for_context(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
if not require_capability(request.tenant_context.membership, capability, conversation):
|
||||
raise Conversation.DoesNotExist
|
||||
return conversation
|
||||
|
||||
def _audit(self, request: Request, action: str, conversation: Conversation) -> None:
|
||||
record_audit_event(
|
||||
action=f"conversations.{action}",
|
||||
actor=request.user,
|
||||
organization=self._org(request),
|
||||
object_type="Conversation",
|
||||
object_id=str(conversation.id),
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
class ConversationListView(_Base):
|
||||
class ConversationListView(ConversationViewBase):
|
||||
def get(self, request: Request) -> Response:
|
||||
items = conversations_for_context(request.tenant_context)
|
||||
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
|
||||
@@ -79,10 +45,21 @@ class ConversationListView(_Base):
|
||||
ConversationRead.objects.filter(user=request.user, conversation__in=items)
|
||||
.values_list("conversation_id", "last_read_message_id")
|
||||
)
|
||||
return Response({"items": [conversation_payload(c, last_read_id=read_map.get(c.id, 0)) for c in items]})
|
||||
return Response(
|
||||
{
|
||||
"items": [
|
||||
conversation_payload(
|
||||
c,
|
||||
last_read_id=read_map.get(c.id, 0),
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
for c in items
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationDetailView(_Base):
|
||||
class ConversationDetailView(ConversationViewBase):
|
||||
def get(self, request: Request, conversation_id: int) -> Response:
|
||||
try:
|
||||
conversation = self._conversation(request, conversation_id)
|
||||
@@ -98,10 +75,18 @@ class ConversationDetailView(_Base):
|
||||
if not created and read.last_read_message_id < last_id:
|
||||
read.last_read_message_id = last_id
|
||||
read.save(update_fields=["last_read_message_id", "updated_at"])
|
||||
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationClaimView(_Base):
|
||||
class ConversationClaimView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
@@ -116,10 +101,18 @@ class ConversationClaimView(_Base):
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "claimed", conversation)
|
||||
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationReleaseView(_Base):
|
||||
class ConversationReleaseView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
@@ -127,70 +120,51 @@ class ConversationReleaseView(_Base):
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
conversation = release_to_ai(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
self._audit(request, "released_to_ai", conversation)
|
||||
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
|
||||
|
||||
|
||||
class ConversationReturnQueueView(_Base):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
conversation = return_to_queue(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
self._audit(request, "returned_to_queue", conversation)
|
||||
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
|
||||
|
||||
|
||||
class ConversationStatsView(_Base):
|
||||
def get(self, request: Request) -> Response:
|
||||
period = request.query_params.get("period", "today")
|
||||
if period not in ("today", "d7", "d30"):
|
||||
period = "today"
|
||||
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
|
||||
return Response(sales_overview_stats(request.tenant_context, period, department_ids))
|
||||
|
||||
|
||||
class CommandOverviewView(_Base):
|
||||
required_capability = "company.view"
|
||||
require_organization_scope = True
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
period = request.query_params.get("period", "today")
|
||||
if period not in ("today", "d7", "d30"):
|
||||
period = "today"
|
||||
return Response(command_center_overview(request.tenant_context, period))
|
||||
|
||||
|
||||
class ClientsView(_Base):
|
||||
required_capability = "customers.view"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
|
||||
return Response({"items": clients_overview(self._org(request).id, department_ids)})
|
||||
|
||||
|
||||
class ClientDetailView(_Base):
|
||||
required_capability = "customers.view"
|
||||
|
||||
def get(self, request: Request, contact_id: int) -> Response:
|
||||
try:
|
||||
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
|
||||
return Response(
|
||||
{"client": client_detail(self._org(request).id, contact_id, department_ids)}
|
||||
conversation = release_to_ai(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
except Contact.DoesNotExist:
|
||||
return Response({"detail": "Клиент не найден"}, status=404)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "released_to_ai", conversation)
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationMessageView(_Base):
|
||||
class ConversationReturnQueueView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
try:
|
||||
conversation = return_to_queue(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "returned_to_queue", conversation)
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationMessageView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
@@ -203,6 +177,8 @@ class ConversationMessageView(_Base):
|
||||
return Response({"detail": "Пустое сообщение"}, status=400)
|
||||
if conversation.control_mode != ControlMode.HUMAN:
|
||||
return Response({"detail": "Сначала перехватите диалог"}, status=409)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
manager_override = authorize(
|
||||
request.tenant_context.membership,
|
||||
self.required_capability,
|
||||
@@ -210,13 +186,16 @@ class ConversationMessageView(_Base):
|
||||
)
|
||||
if conversation.assigned_operator_id != request.user.id and not manager_override:
|
||||
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
|
||||
message = post_operator_message(
|
||||
context=request.tenant_context, conversation=conversation, text=text
|
||||
)
|
||||
try:
|
||||
message = post_operator_message(
|
||||
context=request.tenant_context, conversation=conversation, text=text
|
||||
)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return Response({"message": message_payload(message)}, status=201)
|
||||
|
||||
|
||||
class ConversationRequestContactView(_Base):
|
||||
class ConversationRequestContactView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
@@ -226,14 +205,22 @@ class ConversationRequestContactView(_Base):
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
if not conversation.contact_id or not conversation.connection_id:
|
||||
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
|
||||
if conversation.lifecycle != LifecycleState.OPEN:
|
||||
return Response({"detail": "Диалог закрыт"}, status=409)
|
||||
if conversation.contact.phone:
|
||||
return Response({"detail": "Контакт уже получен"}, status=409)
|
||||
message = request_contact(context=request.tenant_context, conversation=conversation)
|
||||
try:
|
||||
message = request_contact(
|
||||
context=request.tenant_context,
|
||||
conversation=conversation,
|
||||
)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "contact_requested", conversation)
|
||||
return Response({"message": message_payload(message)}, status=201)
|
||||
|
||||
|
||||
class ConversationCloseView(_Base):
|
||||
class ConversationCloseView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
@@ -241,8 +228,45 @@ class ConversationCloseView(_Base):
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
conversation = close_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
try:
|
||||
conversation = close_conversation(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "closed", conversation)
|
||||
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConversationSpamView(ConversationViewBase):
|
||||
required_capability = "conversations.operate"
|
||||
|
||||
def post(self, request: Request, conversation_id: int) -> Response:
|
||||
try:
|
||||
self._conversation(request, conversation_id, self.required_capability)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({"detail": "Диалог не найден"}, status=404)
|
||||
try:
|
||||
conversation = mark_conversation_as_spam(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
except ClaimError as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
self._audit(request, "marked_spam", conversation)
|
||||
return Response(
|
||||
{
|
||||
"conversation": conversation_payload(
|
||||
conversation,
|
||||
with_messages=True,
|
||||
viewer_id=request.user.id,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("integrations", "0004_alter_integration_provider_email"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="integration",
|
||||
name="is_active",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -49,6 +49,9 @@ class Integration(models.Model):
|
||||
# Несекретная конфигурация: base_url, модель по умолчанию и т.п.
|
||||
config = models.JSONField(default=dict, blank=True)
|
||||
status = models.CharField(max_length=16, choices=IntegrationStatus.choices, default=IntegrationStatus.UNCHECKED)
|
||||
# Операционное состояние подключения. В отличие от status (результата
|
||||
# последней проверки), is_active явно разрешает или запрещает runtime.
|
||||
is_active = models.BooleanField(default=True)
|
||||
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
|
||||
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
|
||||
# Курсор Long Polling (marker MAX / offset Telegram).
|
||||
|
||||
@@ -9,6 +9,7 @@ def integration_payload(integration: Integration) -> dict[str, object]:
|
||||
"provider": integration.provider,
|
||||
"name": integration.name,
|
||||
"hasSecret": bool(integration.secret),
|
||||
"isActive": integration.is_active,
|
||||
"config": {
|
||||
"baseUrl": integration.config.get("base_url", ""),
|
||||
"defaultModel": integration.config.get("default_model", ""),
|
||||
|
||||
@@ -24,17 +24,26 @@ class IntegrationInput:
|
||||
secret: str | None = None # None = не менять при update
|
||||
config: dict = field(default_factory=dict)
|
||||
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
def _resolve_channel(organization: Organization, channel_id: int | None):
|
||||
def _resolve_channel(
|
||||
organization: Organization,
|
||||
channel_id: int | None,
|
||||
*,
|
||||
current_channel_id: int | None = None,
|
||||
):
|
||||
if not channel_id:
|
||||
return None
|
||||
from hub_platform.channels.models import Channel
|
||||
|
||||
try:
|
||||
return Channel.objects.get(organization=organization, id=channel_id)
|
||||
channel = Channel.objects.get(organization=organization, id=channel_id)
|
||||
except Channel.DoesNotExist as error:
|
||||
raise ValidationError({"channel": "Channel not found"}) from error
|
||||
if not channel.is_active and channel.id != current_channel_id:
|
||||
raise ValidationError({"channel": "Inactive channel cannot accept connections"})
|
||||
return channel
|
||||
|
||||
|
||||
def _email_config(config: dict) -> dict:
|
||||
@@ -124,6 +133,7 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
|
||||
secret=(data.secret or "").strip(),
|
||||
config=_normalized_config(provider, data.config),
|
||||
channel=_resolve_channel(organization, data.channel_id),
|
||||
is_active=True if data.is_active is None else data.is_active,
|
||||
status=IntegrationStatus.UNCHECKED,
|
||||
)
|
||||
integration.full_clean(exclude=["secret"])
|
||||
@@ -148,7 +158,13 @@ def update_integration(
|
||||
raise ValidationError({"integration": "Integration belongs to another organization"})
|
||||
integration.name = data.name.strip() or integration.name
|
||||
integration.config = _normalized_config(integration.provider, data.config)
|
||||
integration.channel = _resolve_channel(integration.organization, data.channel_id)
|
||||
integration.channel = _resolve_channel(
|
||||
integration.organization,
|
||||
data.channel_id,
|
||||
current_channel_id=integration.channel_id,
|
||||
)
|
||||
if data.is_active is not None:
|
||||
integration.is_active = data.is_active
|
||||
# Пустой/отсутствующий секрет при обновлении не затирает существующий.
|
||||
if data.secret:
|
||||
integration.secret = data.secret.strip()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
|
||||
from hub_platform.identity.models import Organization
|
||||
from hub_platform.integrations.models import IntegrationProvider
|
||||
from hub_platform.integrations.serializers import integration_payload
|
||||
from hub_platform.integrations.services import (
|
||||
IntegrationInput,
|
||||
create_integration,
|
||||
update_integration,
|
||||
)
|
||||
from hub_platform.testing import system_tenant_context
|
||||
|
||||
|
||||
class IntegrationActivationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech", password="temporary-password"
|
||||
)
|
||||
organization = Organization.objects.get(slug="edevs")
|
||||
self.context = system_tenant_context(organization)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=organization,
|
||||
code="activation",
|
||||
name="Канал подключения",
|
||||
)
|
||||
|
||||
def test_activation_round_trip_in_service_and_payload(self) -> None:
|
||||
integration = create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="Виджет",
|
||||
channel_id=self.channel.id,
|
||||
),
|
||||
)
|
||||
updated = update_integration(
|
||||
context=self.context,
|
||||
integration=integration,
|
||||
data=IntegrationInput(
|
||||
provider=integration.provider,
|
||||
name=integration.name,
|
||||
config=integration.config,
|
||||
channel_id=self.channel.id,
|
||||
is_active=False,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(updated.is_active)
|
||||
self.assertFalse(integration_payload(updated)["isActive"])
|
||||
|
||||
def test_new_binding_to_inactive_channel_is_rejected(self) -> None:
|
||||
self.channel.is_active = False
|
||||
self.channel.save(update_fields=["is_active"])
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.WEB,
|
||||
name="Виджет",
|
||||
channel_id=self.channel.id,
|
||||
),
|
||||
)
|
||||
@@ -31,6 +31,11 @@ def _input(body: dict[str, object], *, current: Integration | None = None) -> In
|
||||
secret=body.get("secret") if "secret" in body else None,
|
||||
config=config if isinstance(config, dict) else {},
|
||||
channel_id=channel_id,
|
||||
is_active=(
|
||||
body["isActive"]
|
||||
if isinstance(body.get("isActive"), bool)
|
||||
else (current.is_active if current else None)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -170,6 +170,49 @@ class PollerSelectionTests(NotifierTestBase):
|
||||
self.assertIn(client_bot.id, polled_ids)
|
||||
self.assertNotIn(self.integration.id, polled_ids)
|
||||
|
||||
def test_disabled_connection_and_inactive_channel_are_not_polled(self) -> None:
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.conversations import poller
|
||||
|
||||
active_channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="active-channel",
|
||||
name="Активный канал",
|
||||
)
|
||||
inactive_channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="inactive-channel",
|
||||
name="Неактивный канал",
|
||||
is_active=False,
|
||||
)
|
||||
Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="disabled-client",
|
||||
secret="token",
|
||||
channel=active_channel,
|
||||
is_active=False,
|
||||
)
|
||||
Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="inactive-channel-client",
|
||||
secret="token",
|
||||
channel=inactive_channel,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"hub_platform.conversations.poller.transports.poll",
|
||||
return_value=([], ""),
|
||||
) as poll:
|
||||
poller.poll_all_messengers(self.context)
|
||||
|
||||
polled_names = [call.args[0].name for call in poll.call_args_list]
|
||||
self.assertNotIn("disabled-client", polled_names)
|
||||
self.assertNotIn("inactive-channel-client", polled_names)
|
||||
|
||||
|
||||
class BindingApiTests(NotifierTestBase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -4,9 +4,36 @@ import { Icon } from "../../shared/icons";
|
||||
import { sendOperatorMessage } from "./model";
|
||||
import type { ControlMode } from "./types";
|
||||
|
||||
export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
export function Composer({ mode, loaded, assignedOperatorName, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState("");
|
||||
|
||||
if (conversationId == null) {
|
||||
return <div className="sales-composer"><div className="sales-waiting-composer"><div><strong>Выберите диалог</strong></div></div></div>;
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="sales-composer"><div className="sales-waiting-composer"><div><strong>Загрузка диалога…</strong></div></div></div>;
|
||||
}
|
||||
|
||||
if (mode === "closed") {
|
||||
return (
|
||||
<div className="sales-composer"><div className="sales-waiting-composer">
|
||||
<span><Icon name="lock" size={19} /></span>
|
||||
<div><strong>Диалог закрыт</strong><p>История сохранена. Новое обращение клиента создаст новый диалог.</p></div>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "assigned") {
|
||||
return (
|
||||
<div className="sales-composer"><div className="sales-waiting-composer">
|
||||
<span><Icon name="lock" size={19} /></span>
|
||||
<div><strong>{assignedOperatorName ? `Диалог ведёт ${assignedOperatorName}` : "Диалог ведёт другой оператор"}</strong><p>Отправка сообщений доступна назначенному оператору.</p></div>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "waiting") {
|
||||
return (
|
||||
@@ -32,10 +59,13 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
|
||||
const value = text.trim();
|
||||
if (!value || conversationId == null || sending) return;
|
||||
setSending(true);
|
||||
setSendError("");
|
||||
try {
|
||||
await sendOperatorMessage(conversationId, value);
|
||||
setText("");
|
||||
onSent();
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : "Не удалось отправить сообщение");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -50,7 +80,6 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
|
||||
<button onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<div className="sales-message-input">
|
||||
<button aria-label="Прикрепить файл"><Icon name="paperclip" size={19} /></button>
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="Введите сообщение…"
|
||||
@@ -60,6 +89,7 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
|
||||
/>
|
||||
<button onClick={() => void send()} disabled={sending}>Отправить<Icon name="send" size={15} /></button>
|
||||
</div>
|
||||
{sendError && <div className="sales-composer-error">{sendError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { DecisionDialog } from "../../shared/DecisionDialog";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
|
||||
export function ConversationActions({
|
||||
open,
|
||||
onClose,
|
||||
onSpam,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSpam: () => Promise<boolean>;
|
||||
}) {
|
||||
const [confirmSpam, setConfirmSpam] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function confirm() {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (await onSpam()) setConfirmSpam(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "close",
|
||||
label: <button type="button" onClick={onClose}><Icon name="lock" size={15} />Закрыть диалог</button>,
|
||||
},
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "spam",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmSpam(true)}><Icon name="warning" size={15} />Пометить как спам</button>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomRight" overlayClassName="app-dropdown">
|
||||
<button className="sales-more-button row-menu-button" type="button" aria-label="Действия диалога"><Icon name="more" size={18} /></button>
|
||||
</Dropdown>
|
||||
<DecisionDialog
|
||||
open={confirmSpam}
|
||||
onClose={() => !busy && setConfirmSpam(false)}
|
||||
tone="danger"
|
||||
icon="warning"
|
||||
title="Пометить диалог как спам?"
|
||||
description="Диалог будет закрыт для ответов и останется в истории со статусом «Спам»."
|
||||
actions={<>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmSpam(false)}>Отмена</Button>
|
||||
<Button variant="danger-outline" icon="warning" disabled={busy} onClick={() => void confirm()}>Пометить как спам</Button>
|
||||
</>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { ConversationActions } from "./ConversationActions";
|
||||
import { EmailMessageBody } from "./EmailMessageBody";
|
||||
import { statusFor } from "./data";
|
||||
import { providerMeta } from "../../shared/providers";
|
||||
import type { ApiConversation, ApiMessage } from "./model";
|
||||
@@ -10,7 +12,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, onClaim, onCall }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void }) {
|
||||
export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void; onClose: () => void; onSpam: () => Promise<boolean> }) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const messages = detail?.messages ?? [];
|
||||
const lastMessageId = messages.length ? messages[messages.length - 1].id : 0;
|
||||
@@ -24,7 +26,7 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal
|
||||
if (!dialog) {
|
||||
return <div className="sales-timeline"><div className="sales-timeline-inner"><div className="sales-wait-note">Выберите диалог</div></div></div>;
|
||||
}
|
||||
const status = statusFor(controlMode);
|
||||
const status = statusFor(controlMode, detail?.assignedOperator?.name);
|
||||
const channel = providerMeta[dialog.channel];
|
||||
return (
|
||||
<>
|
||||
@@ -39,10 +41,10 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal
|
||||
<div className="sales-conversation-actions">
|
||||
{controlMode === "waiting" && <button className="sales-claim-button" onClick={onClaim}><Icon name="check" size={15} />Забрать</button>}
|
||||
{controlMode === "ai" && <button className="sales-ai-button" onClick={onClaim}>Перехватить AI</button>}
|
||||
{detail?.lifecycle === "OPEN" && (
|
||||
{detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && (
|
||||
<button className="sales-more-button" aria-label="Запросить онлайн-звонок" title="Запросить онлайн-звонок" onClick={onCall}><Icon name="phone" size={17} /></button>
|
||||
)}
|
||||
<button className="sales-more-button" aria-label="Действия диалога"><Icon name="more" size={18} /></button>
|
||||
<ConversationActions open={detail?.lifecycle === "OPEN"} onClose={onClose} onSpam={onSpam} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-timeline" ref={timelineRef}>
|
||||
@@ -65,7 +67,9 @@ function MessageRow({ message, dialog }: { message: ApiMessage; dialog: Conversa
|
||||
const actor = message.author === "AI" ? "AI-агент" : message.author === "OPERATOR" ? "Оператор" : undefined;
|
||||
return (
|
||||
<Message side={side} initials={dialog.initials} avatarBg={dialog.avatarBg} actor={actor} time={fmtTime(message.createdAt)}>
|
||||
{message.text}
|
||||
{message.author === "CONTACT" && message.contentHtml
|
||||
? <EmailMessageBody html={message.contentHtml} />
|
||||
: message.text}
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { CallOverlay } from "./CallOverlay";
|
||||
import { Composer } from "./Composer";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
controlModeOf,
|
||||
fetchConversation,
|
||||
fetchConversations,
|
||||
markConversationAsSpam,
|
||||
releaseConversation,
|
||||
returnToQueue,
|
||||
toConversationListItem,
|
||||
@@ -35,25 +36,37 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
const [search, setSearch] = useState("");
|
||||
const [conversations, setConversations] = useState<ApiConversation[]>([]);
|
||||
const [listLoaded, setListLoaded] = useState(false);
|
||||
const [listError, setListError] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<number | null>(initialConversationId ?? null);
|
||||
const [detail, setDetail] = useState<ApiConversation | null>(null);
|
||||
const [detailError, setDetailError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const selectedIdRef = useRef<number | null>(selectedId);
|
||||
selectedIdRef.current = selectedId;
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const items = await fetchConversations(department);
|
||||
setConversations(items);
|
||||
setListLoaded(true);
|
||||
setListError("");
|
||||
setSelectedId((current) => current ?? items[0]?.id ?? null);
|
||||
} catch {
|
||||
/* keep previous list on transient errors */
|
||||
setListError("Не удалось обновить список диалогов");
|
||||
}
|
||||
}, [department]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
try {
|
||||
setDetail(await fetchConversation(id));
|
||||
const loaded = await fetchConversation(id);
|
||||
if (selectedIdRef.current === id) {
|
||||
setDetail(loaded);
|
||||
setDetailError("");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
if (selectedIdRef.current === id) {
|
||||
setDetailError("Не удалось загрузить диалог");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -69,6 +82,9 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId == null) return;
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
setActionError("");
|
||||
void loadDetail(selectedId);
|
||||
const timer = setInterval(() => loadDetail(selectedId), 3000);
|
||||
return () => clearInterval(timer);
|
||||
@@ -84,7 +100,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return dialogs.filter((dialog) => {
|
||||
if (query && !`${dialog.name} ${dialog.product} ${dialog.preview}`.toLowerCase().includes(query)) return false;
|
||||
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";
|
||||
@@ -94,29 +110,32 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
}, [dialogs, listTab, search]);
|
||||
|
||||
const selectedDialog = dialogs.find((dialog) => dialog.id === selectedId) ?? null;
|
||||
const controlMode = detail ? controlModeOf(detail) : "ai";
|
||||
const detailLoaded = detail?.id === selectedId;
|
||||
const controlMode = detailLoaded ? controlModeOf(detail) : "waiting";
|
||||
|
||||
function applyUpdated(updated: ApiConversation) {
|
||||
setDetail(updated);
|
||||
setActionError("");
|
||||
void loadList();
|
||||
}
|
||||
|
||||
const onClaim = async () => {
|
||||
if (selectedId == null) return;
|
||||
try { applyUpdated(await claimConversation(selectedId)); } catch { /* ignore */ }
|
||||
};
|
||||
const onRelease = async () => {
|
||||
if (selectedId == null) return;
|
||||
try { applyUpdated(await releaseConversation(selectedId)); } catch { /* ignore */ }
|
||||
};
|
||||
const onReturnQueue = async () => {
|
||||
if (selectedId == null) return;
|
||||
try { applyUpdated(await returnToQueue(selectedId)); } catch { /* ignore */ }
|
||||
};
|
||||
const onClose = async () => {
|
||||
if (selectedId == null) return;
|
||||
try { applyUpdated(await closeConversation(selectedId)); } catch { /* ignore */ }
|
||||
};
|
||||
async function updateConversation(action: (id: number) => Promise<ApiConversation>): Promise<boolean> {
|
||||
if (selectedId == null) return false;
|
||||
setActionError("");
|
||||
try {
|
||||
applyUpdated(await action(selectedId));
|
||||
return true;
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : "Не удалось выполнить действие");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onClaim = () => { void updateConversation(claimConversation); };
|
||||
const onRelease = () => { void updateConversation(releaseConversation); };
|
||||
const onReturnQueue = () => { void updateConversation(returnToQueue); };
|
||||
const onClose = () => { void updateConversation(closeConversation); };
|
||||
const onSpam = () => updateConversation(markConversationAsSpam);
|
||||
|
||||
return (
|
||||
<div className="sales-dialogs">
|
||||
@@ -128,12 +147,14 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
listTab={listTab}
|
||||
selectedId={selectedId ?? -1}
|
||||
search={search}
|
||||
errorText={listError}
|
||||
setSearch={setSearch}
|
||||
setListTab={setListTab}
|
||||
setSelectedId={setSelectedId}
|
||||
/>
|
||||
<section className="sales-conversation">
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={() => void callController.start()} />
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={() => void callController.start()} onClose={onClose} onSpam={onSpam} />
|
||||
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
|
||||
<CallOverlay
|
||||
open={callController.open}
|
||||
dialog={selectedDialog}
|
||||
@@ -147,6 +168,8 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
|
||||
/>
|
||||
<Composer
|
||||
mode={controlMode}
|
||||
loaded={detailLoaded}
|
||||
assignedOperatorName={detail?.assignedOperator?.name}
|
||||
conversationId={selectedId}
|
||||
onClaim={onClaim}
|
||||
onRelease={onRelease}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { providerMeta } from "../../shared/providers";
|
||||
import type { ConversationListItem, ListTab } from "./types";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
|
||||
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", dialogs, filtered, listTab, selectedId, search, setSearch, setListTab, setSelectedId }: {
|
||||
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
|
||||
title?: string;
|
||||
searchPlaceholder?: string;
|
||||
dialogs: ConversationListItem[];
|
||||
@@ -13,6 +13,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
|
||||
listTab: ListTab;
|
||||
selectedId: number;
|
||||
search: string;
|
||||
errorText?: string;
|
||||
setSearch: (value: string) => void;
|
||||
setListTab: (tab: ListTab) => void;
|
||||
setSelectedId: (id: number) => void;
|
||||
@@ -33,6 +34,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
|
||||
<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>}
|
||||
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
const DOCUMENT_START = `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'; img-src data:; style-src 'unsafe-inline'">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; color: #262626; background: #fff; font: 13.5px/1.5 Arial, sans-serif; overflow-wrap: anywhere; }
|
||||
p, ul, ol, blockquote, pre, table { margin: 0 0 10px; }
|
||||
p:last-child, ul:last-child, ol:last-child, blockquote:last-child, pre:last-child, table:last-child { margin-bottom: 0; }
|
||||
blockquote { margin-left: 0; padding-left: 12px; color: #595959; border-left: 3px solid #d9d9d9; }
|
||||
pre { padding: 8px; background: #f5f5f5; white-space: pre-wrap; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 5px 7px; border: 1px solid #d9d9d9; text-align: left; vertical-align: top; }
|
||||
a { color: #1677ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>`;
|
||||
|
||||
export function EmailMessageBody({ html }: { html: string }) {
|
||||
const srcDoc = useMemo(
|
||||
() => `${DOCUMENT_START}${html}</body></html>`,
|
||||
[html],
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
className="sales-email-body-frame"
|
||||
referrerPolicy="no-referrer"
|
||||
sandbox=""
|
||||
srcDoc={srcDoc}
|
||||
title="Содержимое письма"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@ export const modeDots: Record<DialogMode, string> = {
|
||||
closed: "#bfbfbf",
|
||||
};
|
||||
|
||||
export function statusFor(mode: ControlMode): StatusInfo {
|
||||
export function statusFor(mode: ControlMode, assignedOperatorName?: string): StatusInfo {
|
||||
if (mode === "ai") return { label: "AI отвечает", color: "#722ed1", bg: "#f9f0ff", border: "#efdbff", dot: "#722ed1" };
|
||||
if (mode === "human") return { label: "Вы ведёте диалог", color: "#0958d9", bg: "#e6f4ff", border: "#91caff", dot: "#1677ff" };
|
||||
if (mode === "assigned") return { label: assignedOperatorName ? `Ведёт ${assignedOperatorName}` : "Ведёт другой оператор", color: "#595959", bg: "#fafafa", border: "#d9d9d9", dot: "#8c8c8c" };
|
||||
if (mode === "closed") return { label: "Диалог закрыт", color: "#595959", bg: "#f5f5f5", border: "#d9d9d9", dot: "#bfbfbf" };
|
||||
return { label: "Ждёт оператора", color: "#d48806", bg: "#fffbe6", border: "#ffe58f", dot: "#faad14" };
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { api } from "../../api/client";
|
||||
import type { ChannelKey, ConversationListItem, ControlMode, DialogMode } from "./types";
|
||||
|
||||
// kind: "" — текст, "contact_request" — запрос контакта, "contact" — клиент поделился номером.
|
||||
export type ApiMessage = { id: number; author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; kind?: string; text: string; createdAt: string };
|
||||
export type ApiMessage = { id: number; author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; kind?: string; text: string; contentHtml?: string; createdAt: string };
|
||||
|
||||
export type HistoryItem = {
|
||||
id: number;
|
||||
channelName: string;
|
||||
provider: "MAX" | "TELEGRAM" | "WEB" | null;
|
||||
provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB" | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
@@ -41,16 +41,18 @@ export type OperatorCard = {
|
||||
export type ApiConversation = {
|
||||
id: number;
|
||||
channel: { code: string; name: string; product: { code: string; name: string } | null };
|
||||
connection: { id: number; provider: "MAX" | "TELEGRAM" | "WEB"; name: string } | null;
|
||||
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string } | null;
|
||||
// Источник identity: sales Contact (лид) ИЛИ verified SupportIdentitySnapshot.
|
||||
// ADR-HUB-0022: ровно один заполнен.
|
||||
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
|
||||
contact: { id: number; name: string; phone?: string; username?: string } | null;
|
||||
contact: { id: number; name: string; email?: string; phone?: string; username?: string } | null;
|
||||
supportIdentitySnapshot: SupportIdentitySnapshotRef | null;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
controlMode: "AI" | "HUMAN" | "PAUSED";
|
||||
expectedResponder: string;
|
||||
assignedOperatorId: number | null;
|
||||
assignedOperator: { id: number; name: string } | null;
|
||||
isAssignedToViewer: boolean;
|
||||
lastActivityAt: string;
|
||||
createdAt: string;
|
||||
lastMessage: ApiMessage | null;
|
||||
@@ -60,10 +62,18 @@ export type ApiConversation = {
|
||||
};
|
||||
|
||||
const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"];
|
||||
const PROVIDER_CHANNEL: Record<string, ChannelKey> = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
|
||||
const PROVIDER_CHANNEL: Record<string, ChannelKey> = {
|
||||
EMAIL: "EMAIL",
|
||||
MAX: "MAX",
|
||||
TELEGRAM: "TG",
|
||||
WEB: "WEB",
|
||||
};
|
||||
|
||||
export function controlModeOf(conversation: ApiConversation): ControlMode {
|
||||
if (conversation.controlMode === "HUMAN") return "human";
|
||||
if (conversation.lifecycle !== "OPEN") return "closed";
|
||||
if (conversation.controlMode === "HUMAN") {
|
||||
return conversation.isAssignedToViewer ? "human" : "assigned";
|
||||
}
|
||||
if (conversation.controlMode === "AI") return "ai";
|
||||
return "waiting";
|
||||
}
|
||||
@@ -100,6 +110,7 @@ export function toConversationListItem(conversation: ApiConversation): Conversat
|
||||
avatarBg: AVATAR_PALETTE[seed % AVATAR_PALETTE.length],
|
||||
product: conversation.channel.name,
|
||||
channel: PROVIDER_CHANNEL[conversation.connection?.provider ?? "WEB"] ?? "WEB",
|
||||
email: conversation.contact?.email ?? "",
|
||||
mode: dialogMode(conversation),
|
||||
preview: conversation.lastMessage?.text.replace(/\s+/g, " ").slice(0, 80) ?? "—",
|
||||
time: new Date(conversation.lastActivityAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
|
||||
@@ -118,6 +129,7 @@ export const returnToQueue = (id: number) => api<{ conversation: ApiConversation
|
||||
// Запрос контакта: в TG/MAX клиент видит кнопку «Поделиться контактом», в веб-чате — форму телефона.
|
||||
export const requestContact = (id: number) => api(`/api/v1/conversations/${id}/request-contact/`, { method: "POST" });
|
||||
export const closeConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/close/`, { method: "POST" }).then((r) => r.conversation);
|
||||
export const markConversationAsSpam = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/spam/`, { method: "POST" }).then((r) => r.conversation);
|
||||
// Бейдж ожидающих диалогов. ConversationStatsView сейчас sales-only (SPEC §12:
|
||||
// support-метрики — отдельный endpoint); department-параметр backend не использует.
|
||||
export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/conversations/stats/").then((r) => r.waiting);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// общий conversation workspace, не отдельная реализация под каждый отдел.
|
||||
|
||||
export type DialogMode = "ai" | "closed" | "operator" | "wait";
|
||||
export type ControlMode = "ai" | "human" | "waiting";
|
||||
export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting";
|
||||
export type ListTab = "ai" | "all" | "operator" | "unread" | "wait";
|
||||
export type ChannelKey = "MAX" | "TG" | "WEB";
|
||||
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
|
||||
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
|
||||
export type ConversationListItem = {
|
||||
@@ -14,6 +14,7 @@ export type ConversationListItem = {
|
||||
avatarBg: string;
|
||||
product: string;
|
||||
channel: ChannelKey;
|
||||
email: string;
|
||||
mode: DialogMode;
|
||||
preview: string;
|
||||
time: string;
|
||||
|
||||
@@ -17,10 +17,11 @@ type ConnectionsTableProps = {
|
||||
testingId: number | null;
|
||||
onTest: (integration: Integration) => void;
|
||||
onEdit: (integration: Integration) => void;
|
||||
onToggleActive: (integration: Integration) => void;
|
||||
onDelete: (integration: Integration) => void;
|
||||
};
|
||||
|
||||
export function ConnectionsTable({ items, testingId, onTest, onEdit, onDelete }: ConnectionsTableProps) {
|
||||
export function ConnectionsTable({ items, testingId, onTest, onEdit, onToggleActive, onDelete }: ConnectionsTableProps) {
|
||||
return (
|
||||
<div className="table-card">
|
||||
<table className="baseline-table">
|
||||
@@ -44,7 +45,7 @@ export function ConnectionsTable({ items, testingId, onTest, onEdit, onDelete }:
|
||||
<td>{item.channel ? item.channel.name : <span className="product-empty-value">—</span>}</td>
|
||||
<td><StatusCell integration={item} /></td>
|
||||
<td className="row-actions">
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onDelete={onDelete} />
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onToggleActive={onToggleActive} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -52,6 +52,20 @@ export function IntegrationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(integration: Integration) {
|
||||
try {
|
||||
const payload = await api<{ integration: Integration }>(`/api/v1/integrations/${integration.id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isActive: !integration.isActive }),
|
||||
});
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === integration.id ? payload.integration : item
|
||||
)));
|
||||
} catch {
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
function startDelete(integration: Integration) {
|
||||
setDeletingError(null);
|
||||
setDeleting(integration);
|
||||
@@ -72,7 +86,13 @@ export function IntegrationsPage() {
|
||||
const providers = items.filter((item) => item.kind === "LLM_PROVIDER");
|
||||
const isConnections = tab === "MESSENGER";
|
||||
const shown = isConnections ? connections : providers;
|
||||
const rowHandlers = { testingId, onTest: test, onEdit: (item: Integration) => setForm({ initial: item, kind: item.kind }), onDelete: startDelete };
|
||||
const rowHandlers = {
|
||||
testingId,
|
||||
onTest: test,
|
||||
onEdit: (item: Integration) => setForm({ initial: item, kind: item.kind }),
|
||||
onToggleActive: toggleActive,
|
||||
onDelete: startDelete,
|
||||
};
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
|
||||
@@ -6,10 +6,11 @@ type ProvidersTableProps = {
|
||||
testingId: number | null;
|
||||
onTest: (integration: Integration) => void;
|
||||
onEdit: (integration: Integration) => void;
|
||||
onToggleActive: (integration: Integration) => void;
|
||||
onDelete: (integration: Integration) => void;
|
||||
};
|
||||
|
||||
export function ProvidersTable({ items, testingId, onTest, onEdit, onDelete }: ProvidersTableProps) {
|
||||
export function ProvidersTable({ items, testingId, onTest, onEdit, onToggleActive, onDelete }: ProvidersTableProps) {
|
||||
return (
|
||||
<div className="table-card">
|
||||
<table className="baseline-table">
|
||||
@@ -40,7 +41,7 @@ export function ProvidersTable({ items, testingId, onTest, onEdit, onDelete }: P
|
||||
</td>
|
||||
<td><StatusCell integration={item} /></td>
|
||||
<td className="row-actions">
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onDelete={onDelete} />
|
||||
<RowActions integration={item} testing={testingId === item.id} onTest={onTest} onEdit={onEdit} onToggleActive={onToggleActive} onDelete={onDelete} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Integration = {
|
||||
provider: IntegrationProvider;
|
||||
name: string;
|
||||
hasSecret: boolean;
|
||||
isActive: boolean;
|
||||
// purpose="notifications" — сервисный бот уведомлений сотрудников (не привязан к каналу продаж).
|
||||
// email/imap*/smtp* — Email-подключение (SPEC-HUB-0025 §3.1).
|
||||
config: {
|
||||
|
||||
@@ -33,6 +33,14 @@ export function formatChecked(value: string | null): string {
|
||||
}
|
||||
|
||||
export function StatusCell({ integration }: { integration: Integration }) {
|
||||
if (!integration.isActive) {
|
||||
return (
|
||||
<div className="integration-status">
|
||||
<ToneBadge bg="#f5f5f5" color="#8c8c8c">Отключено</ToneBadge>
|
||||
<small>Приём и отправка сообщений остановлены</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const status = STATUS_META[integration.status];
|
||||
return (
|
||||
<div className="integration-status">
|
||||
@@ -47,14 +55,24 @@ type RowActionsProps = {
|
||||
testing: boolean;
|
||||
onTest: (integration: Integration) => void;
|
||||
onEdit: (integration: Integration) => void;
|
||||
onToggleActive: (integration: Integration) => void;
|
||||
onDelete: (integration: Integration) => void;
|
||||
};
|
||||
|
||||
export function RowActions({ integration, testing, onTest, onEdit, onDelete }: RowActionsProps) {
|
||||
export function RowActions({ integration, testing, onTest, onEdit, onToggleActive, onDelete }: RowActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const meta = PROVIDERS[integration.provider];
|
||||
const menuItems = [
|
||||
{ key: "edit", label: <button type="button" onClick={() => { setOpen(false); onEdit(integration); }}><Icon name="edit" size={15} />Изменить</button> },
|
||||
...(integration.kind === "MESSENGER" ? [{
|
||||
key: "active",
|
||||
label: (
|
||||
<button type="button" onClick={() => { setOpen(false); onToggleActive(integration); }}>
|
||||
<Icon name={integration.isActive ? "pause" : "plug"} size={15} />
|
||||
{integration.isActive ? "Отключить" : "Включить"}
|
||||
</button>
|
||||
),
|
||||
}] : []),
|
||||
{ type: "divider" as const },
|
||||
{ key: "delete", label: <button type="button" className="warning" onClick={() => { setOpen(false); onDelete(integration); }}><Icon name="trash" size={15} />Удалить</button> },
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EmptyState, LoadingState } from "../../shared/ui";
|
||||
import { SalesClientsFilters } from "./clients/SalesClientsFilters";
|
||||
import { SalesClientsHeader } from "./clients/SalesClientsHeader";
|
||||
import { SalesClientsTable } from "./clients/SalesClientsTable";
|
||||
import { exportClientsCsv } from "./clients/exportClientsCsv";
|
||||
import { useClientsData } from "./clients/useClientsData";
|
||||
import { useSalesClients } from "./clients/useSalesClients";
|
||||
|
||||
@@ -11,7 +12,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
|
||||
|
||||
return (
|
||||
<>
|
||||
<SalesClientsHeader shownCount={clients.rows.length} totalCount={data.length} />
|
||||
<SalesClientsHeader shownCount={clients.filteredCount} totalCount={data.length} onExport={() => exportClientsCsv(clients.filteredRows)} />
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
@@ -19,7 +20,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
|
||||
) : (
|
||||
<>
|
||||
<SalesClientsFilters clients={clients} />
|
||||
<SalesClientsTable clients={clients} openClient={openClient} totalCount={data.length} />
|
||||
<SalesClientsTable clients={clients} openClient={openClient} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { EmptyState, LoadingState } from "../../../shared/ui";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import { type ClientDetailTab } from "./model";
|
||||
import { SalesClientAuditTab } from "./SalesClientAuditTab";
|
||||
import { SalesClientConsentTab } from "./SalesClientConsentTab";
|
||||
@@ -13,7 +12,7 @@ import { SalesClientOverviewTab } from "./SalesClientOverviewTab";
|
||||
import { SalesClientTabs } from "./SalesClientTabs";
|
||||
import { useClientDetail } from "./useClientDetail";
|
||||
|
||||
export function SalesClientDetailPage({ contactId, setRoute }: { contactId: number | null; setRoute: (route: RouteKey) => void }) {
|
||||
export function SalesClientDetailPage({ contactId, openConversation }: { contactId: number | null; openConversation: (conversationId: number) => void }) {
|
||||
const [tab, setTab] = useState<ClientDetailTab>("overview");
|
||||
const { client, loading, error } = useClientDetail(contactId);
|
||||
|
||||
@@ -22,10 +21,10 @@ export function SalesClientDetailPage({ contactId, setRoute }: { contactId: numb
|
||||
|
||||
return (
|
||||
<>
|
||||
<SalesClientHeader client={client} setRoute={setRoute} />
|
||||
<SalesClientHeader client={client} openConversation={openConversation} />
|
||||
<SalesClientTabs activeTab={tab} setActiveTab={setTab} />
|
||||
{tab === "overview" && <SalesClientOverviewTab client={client} setRoute={setRoute} />}
|
||||
{tab === "dialogs" && <SalesClientDialogsTab dialogs={client.dialogs} setRoute={setRoute} />}
|
||||
{tab === "overview" && <SalesClientOverviewTab client={client} openConversation={openConversation} />}
|
||||
{tab === "dialogs" && <SalesClientDialogsTab dialogs={client.dialogs} openConversation={openConversation} />}
|
||||
{tab === "orders" && <SalesClientSalesTab contactId={contactId} />}
|
||||
{tab === "ids" && <SalesClientIdentitiesTab identities={client.identities} />}
|
||||
{tab === "consent" && <SalesClientConsentTab />}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { EmptyState } from "../../../shared/ui";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { ClientDetailVm } from "./model";
|
||||
|
||||
export function SalesClientDialogsTab({ dialogs, setRoute }: { dialogs: ClientDetailVm["dialogs"]; setRoute: (route: RouteKey) => void }) {
|
||||
export function SalesClientDialogsTab({ dialogs, openConversation }: { dialogs: ClientDetailVm["dialogs"]; openConversation: (conversationId: number) => void }) {
|
||||
if (dialogs.length === 0) return <EmptyState title="У контакта ещё нет диалогов" />;
|
||||
return (
|
||||
<div className="sales-client-list-card">
|
||||
{dialogs.map((dialog) => (
|
||||
<button className="sales-client-dialog-link" type="button" onClick={() => setRoute("salesDialogs")} key={dialog.id}>
|
||||
<button className="sales-client-dialog-link" type="button" onClick={() => openConversation(dialog.id)} key={dialog.id}>
|
||||
<span className="sales-client-dialog-dot" style={{ background: dialog.active ? "#1677ff" : "#bfbfbf" }} />
|
||||
<span className="sales-client-dialog-copy"><strong>{dialog.title}</strong><small>{dialog.meta}</small></span>
|
||||
<b className={dialog.active ? "active" : ""}>{dialog.status}</b>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { ClientDetailVm } from "./model";
|
||||
|
||||
export function SalesClientHeader({ client, setRoute }: { client: ClientDetailVm; setRoute: (route: RouteKey) => void }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
export function SalesClientHeader({ client, openConversation }: { client: ClientDetailVm; openConversation: (conversationId: number) => void }) {
|
||||
const targetDialog = client.dialogs.find((dialog) => dialog.active) ?? client.dialogs[0];
|
||||
|
||||
return (
|
||||
<div className="sales-client-detail-header-card">
|
||||
@@ -29,20 +25,9 @@ export function SalesClientHeader({ client, setRoute }: { client: ClientDetailVm
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-client-detail-actions">
|
||||
<Button className="sales-client-primary" icon="message" variant="primary" onClick={() => setRoute("salesDialogs")}>Открыть диалог</Button>
|
||||
<Dropdown menu={{ items: clientMenuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} placement="bottomRight" overlayClassName="app-dropdown is-wide">
|
||||
<button className="row-menu-button" type="button" aria-label="Действия контакта"><Icon name="more" size={18} /></button>
|
||||
</Dropdown>
|
||||
<Button className="sales-client-primary" icon="message" variant="primary" disabled={!targetDialog} onClick={() => targetDialog && openConversation(targetDialog.id)}>Открыть диалог</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const clientMenuItems = [
|
||||
{ key: "merge", label: <button type="button"><Icon name="list" size={15} />Объединить контакты</button> },
|
||||
{ key: "split", label: <button type="button"><Icon name="split" size={15} />Разъединить контакты</button> },
|
||||
{ key: "edit", label: <button type="button"><Icon name="edit" size={15} />Исправить данные</button> },
|
||||
{ type: "divider" as const },
|
||||
{ key: "anonymize", label: <button className="danger" type="button"><Icon name="eyeOff" size={15} />Обезличить данные</button> },
|
||||
];
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { ClientDetailVm } from "./model";
|
||||
|
||||
export function SalesClientOverviewTab({ client, setRoute }: { client: ClientDetailVm; setRoute: (route: RouteKey) => void }) {
|
||||
export function SalesClientOverviewTab({ client, openConversation }: { client: ClientDetailVm; openConversation: (conversationId: number) => void }) {
|
||||
const currentDialog = client.dialogs.find((dialog) => dialog.active);
|
||||
return (
|
||||
<div className="sales-client-overview">
|
||||
@@ -18,7 +17,7 @@ export function SalesClientOverviewTab({ client, setRoute }: { client: ClientDet
|
||||
<span className="sales-client-status-blue"><i />{currentDialog.status}</span>
|
||||
</div>
|
||||
<p className="sales-client-current-dialog">{currentDialog.meta}<br />«{currentDialog.title}»</p>
|
||||
<Button className="sales-client-outline-action" variant="secondary" onClick={() => setRoute("salesDialogs")}>Открыть диалог</Button>
|
||||
<Button className="sales-client-outline-action" variant="secondary" onClick={() => openConversation(currentDialog.id)}>Открыть диалог</Button>
|
||||
</section>
|
||||
)}
|
||||
<section className="sales-client-section-card">
|
||||
|
||||
@@ -11,8 +11,8 @@ export const clientDetailTabs: Array<{ key: ClientDetailTab; label: string }> =
|
||||
{ key: "audit", label: "Аудит" },
|
||||
];
|
||||
|
||||
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
|
||||
const PROVIDER_LABEL: Record<string, string> = { MAX: "MAX", TELEGRAM: "Telegram", WEB: "Web Chat" };
|
||||
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
|
||||
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", WEB: "Web Chat" };
|
||||
const PAYMENT_LABEL: Record<string, string> = { PENDING: "Ожидает", PAID: "Оплачен", CANCELLED: "Отменён", REFUNDED: "Возврат" };
|
||||
const FULFILLMENT_LABEL: Record<string, string> = { NONE: "—", PENDING: "В процессе", DELIVERED: "Исполнен", FAILED: "Ошибка" };
|
||||
|
||||
@@ -22,6 +22,8 @@ export type ApiClientDetail = {
|
||||
id: number;
|
||||
cid: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
channels: ClientChannelCode[];
|
||||
products: ClientProductCode[];
|
||||
openDialogs: number;
|
||||
@@ -71,8 +73,8 @@ export function toClientDetailVm(api: ApiClientDetail): ClientDetailVm {
|
||||
name: api.name,
|
||||
initials: initialsOf(api.name),
|
||||
avatarBg: isGuest ? "#8c8c8c" : avatarColor(api.cid),
|
||||
email: isGuest ? "без контакта" : "—",
|
||||
phone: "идентификация по каналу",
|
||||
email: api.email || (isGuest ? "без контакта" : "—"),
|
||||
phone: api.phone || "—",
|
||||
channels: api.channels.map((code) => ({ label: channelMap[code].label, color: channelMap[code].color, bg: channelMap[code].bg })),
|
||||
products: api.products.map((code) => ({ name: productMap[code].name, color: productMap[code].color, bg: productMap[code].bg })),
|
||||
summary: [
|
||||
|
||||
@@ -7,9 +7,6 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client:
|
||||
const menuOpen = menu === client.cid;
|
||||
const menuItems = [
|
||||
{ key: "open", label: <button type="button" onClick={() => { setMenu(null); openClient(client.id); }}><Icon name="external" size={15} />Открыть клиента</button> },
|
||||
{ key: "merge", label: <button type="button"><Icon name="list" size={15} />Объединить контакты</button> },
|
||||
{ type: "divider" as const },
|
||||
{ key: "anonymize", label: <button className="danger" type="button"><Icon name="eyeOff" size={15} />Обезличить данные</button> },
|
||||
];
|
||||
return (
|
||||
<tr>
|
||||
@@ -26,8 +23,8 @@ export function SalesClientRow({ client, menu, openClient, setMenu }: { client:
|
||||
<span className="sales-client-status" style={{ background: client.statusMeta.bg, color: client.statusMeta.color }}>{client.statusMeta.label}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={`sales-client-contact ${client.phone ? "" : "muted"}`}>{client.phone || "—"}</div>
|
||||
<small className="sales-client-phone">{client.username ? `@${client.username}` : ""}</small>
|
||||
<div className={`sales-client-contact ${client.phone || client.email ? "" : "muted"}`}>{client.phone || client.email || "—"}</div>
|
||||
<small className="sales-client-phone">{client.phone && client.email ? client.email : client.username ? `@${client.username}` : ""}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div className="sales-client-tags">
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SalesClientsState } from "./useSalesClients";
|
||||
export function SalesClientsFilters({ clients }: { clients: SalesClientsState }) {
|
||||
return (
|
||||
<div className="sales-clients-filterbar">
|
||||
<SearchInput className="sales-clients-search" value={clients.query} onChange={clients.setQuery} placeholder="Поиск по имени, телефону или логину…" />
|
||||
<SearchInput className="sales-clients-search" value={clients.query} onChange={clients.setQuery} placeholder="Поиск по имени, email, телефону или логину…" />
|
||||
<SalesFilterDropdown
|
||||
active={clients.productFilter.length > 0 || clients.dropdown === "products"}
|
||||
count={clients.productFilter.length}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionButton } from "../../../shared/ui-controls";
|
||||
|
||||
export function SalesClientsHeader({ shownCount, totalCount }: { shownCount: number; totalCount: number }) {
|
||||
export function SalesClientsHeader({ shownCount, totalCount, onExport }: { shownCount: number; totalCount: number; onExport: () => void }) {
|
||||
return (
|
||||
<div className="sales-clients-header">
|
||||
<div>
|
||||
@@ -8,8 +8,7 @@ export function SalesClientsHeader({ shownCount, totalCount }: { shownCount: num
|
||||
<p>Лиды и клиенты отдела продаж · показано <b>{shownCount}</b> из {totalCount}</p>
|
||||
</div>
|
||||
<div className="sales-clients-header-actions">
|
||||
<ActionButton icon="columns">Столбцы</ActionButton>
|
||||
<ActionButton icon="download">Экспорт</ActionButton>
|
||||
<ActionButton icon="download" disabled={shownCount === 0} onClick={onExport}>Экспорт</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { paginationItems } from "./SalesClientsPagination";
|
||||
|
||||
describe("paginationItems", () => {
|
||||
it("returns every page for a short list", () => {
|
||||
expect(paginationItems(1, 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("keeps the current page and boundaries for a long list", () => {
|
||||
expect(paginationItems(5, 10)).toEqual([
|
||||
1,
|
||||
"ellipsis",
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
"ellipsis",
|
||||
10,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add a leading ellipsis next to the first page", () => {
|
||||
expect(paginationItems(2, 10)).toEqual([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
"ellipsis",
|
||||
10,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import { TablePagination } from "../../../shared/ui-controls";
|
||||
|
||||
export function SalesClientsPagination({ shownCount, totalCount }: { shownCount: number; totalCount: number }) {
|
||||
return <TablePagination className="sales-clients-pagination" shown={shownCount} total={totalCount} pages={[1]} pageSizeLabel={`${Math.max(shownCount, 1)} / стр.`} />;
|
||||
export function paginationItems(page: number, pageCount: number): Array<number | "ellipsis"> {
|
||||
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1);
|
||||
const pages = new Set([1, pageCount, page - 1, page, page + 1]);
|
||||
const visible = [...pages].filter((item) => item >= 1 && item <= pageCount).sort((a, b) => a - b);
|
||||
const result: Array<number | "ellipsis"> = [];
|
||||
visible.forEach((item, index) => {
|
||||
if (index > 0 && item - visible[index - 1] > 1) result.push("ellipsis");
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function SalesClientsPagination({ page, pageCount, shownCount, totalCount, setPage }: { page: number; pageCount: number; shownCount: number; totalCount: number; setPage: (page: number) => void }) {
|
||||
return <TablePagination className="sales-clients-pagination" currentPage={page} onPageChange={setPage} shown={shownCount} total={totalCount} pages={paginationItems(page, pageCount)} pageSizeLabel="20 / стр." />;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SalesClientsPagination } from "./SalesClientsPagination";
|
||||
import type { ClientSortKey } from "./model";
|
||||
import type { SalesClientsState } from "./useSalesClients";
|
||||
|
||||
export function SalesClientsTable({ clients, openClient, totalCount }: { clients: SalesClientsState; openClient: (id: number) => void; totalCount: number }) {
|
||||
export function SalesClientsTable({ clients, openClient }: { clients: SalesClientsState; openClient: (id: number) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="sales-clients-card">
|
||||
@@ -14,7 +14,7 @@ export function SalesClientsTable({ clients, openClient, totalCount }: { clients
|
||||
<tr>
|
||||
<th>КОНТАКТ</th>
|
||||
<th>СТАТУС</th>
|
||||
<th>ТЕЛЕФОН / ЛОГИН</th>
|
||||
<th>ТЕЛЕФОН / EMAIL / ЛОГИН</th>
|
||||
<th>КАНАЛЫ</th>
|
||||
<th>ПРОДУКТЫ</th>
|
||||
<SortableTh label="ПОСЛ. ДИАЛОГ" sortKey="last" clients={clients} />
|
||||
@@ -30,7 +30,7 @@ export function SalesClientsTable({ clients, openClient, totalCount }: { clients
|
||||
</table>
|
||||
</div>
|
||||
{clients.rows.length === 0 && <SalesClientsEmpty />}
|
||||
<SalesClientsPagination shownCount={clients.rows.length} totalCount={totalCount} />
|
||||
<SalesClientsPagination page={clients.page} pageCount={clients.pageCount} shownCount={clients.rows.length} totalCount={clients.filteredCount} setPage={clients.setPage} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SalesClientRowVm } from "./model";
|
||||
|
||||
const HEADERS = [
|
||||
"Контакт",
|
||||
"Статус",
|
||||
"Телефон",
|
||||
"Email",
|
||||
"Логин",
|
||||
"Каналы",
|
||||
"Продукты",
|
||||
"Последний диалог",
|
||||
"Открытые диалоги",
|
||||
"Заказы",
|
||||
"Сумма покупок",
|
||||
];
|
||||
|
||||
function csvCell(value: string | number): string {
|
||||
const text = String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
export function exportClientsCsv(rows: SalesClientRowVm[]): void {
|
||||
const data = rows.map((client) => [
|
||||
client.name,
|
||||
client.statusMeta.label,
|
||||
client.phone,
|
||||
client.email,
|
||||
client.username,
|
||||
client.channels.map((channel) => channel.full).join(", "),
|
||||
client.products.map((product) => product.name).join(", "),
|
||||
client.lastLabel,
|
||||
client.openDialogs,
|
||||
client.orders,
|
||||
client.totalLabel,
|
||||
]);
|
||||
const csv = [HEADERS, ...data]
|
||||
.map((row) => row.map(csvCell).join(","))
|
||||
.join("\r\n");
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export type ClientProductCode = "FP" | "FX";
|
||||
export type ClientChannelCode = "MAX" | "TG" | "WEB";
|
||||
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
export type ClientSortKey = "last" | "open" | "orders" | "total";
|
||||
export type ClientDropdown = "products" | "channels";
|
||||
|
||||
@@ -12,6 +12,7 @@ export type SalesClient = {
|
||||
avatarBg: string;
|
||||
cid: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
username: string;
|
||||
status: ClientStatus;
|
||||
anon?: boolean;
|
||||
@@ -42,6 +43,7 @@ export const statusMap = {
|
||||
} satisfies Record<ClientStatus, { label: string; color: string; bg: string }>;
|
||||
|
||||
export const channelMap = {
|
||||
EMAIL: { label: "Email", full: "Email", color: "#d48806", bg: "#fff7e6" },
|
||||
MAX: { label: "MAX", full: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
|
||||
TG: { label: "TG", full: "Telegram", color: "#2f8fd0", bg: "#eaf6fd" },
|
||||
WEB: { label: "Web", full: "Web Chat", color: "#0f9b8e", bg: "#e8f7f4" },
|
||||
@@ -53,6 +55,7 @@ export const productMap = {
|
||||
} satisfies Record<ClientProductCode, { name: string; color: string; bg: string }>;
|
||||
|
||||
export const channelOptions: Array<{ code: ClientChannelCode; name: string; color: string }> = [
|
||||
{ code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
|
||||
{ code: "MAX", name: "MAX", color: channelMap.MAX.color },
|
||||
{ code: "TG", name: "Telegram", color: channelMap.TG.color },
|
||||
{ code: "WEB", name: "Web Chat", color: channelMap.WEB.color },
|
||||
@@ -76,6 +79,7 @@ export type ApiClient = {
|
||||
cid: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
username: string;
|
||||
status: ClientStatus;
|
||||
channels: ClientChannelCode[];
|
||||
@@ -120,6 +124,7 @@ export function toSalesClient(api: ApiClient): SalesClient {
|
||||
avatarBg: isGuest ? "#8c8c8c" : avatarColor(api.cid),
|
||||
cid: api.cid,
|
||||
phone: api.phone,
|
||||
email: api.email,
|
||||
username: api.username,
|
||||
status: api.status,
|
||||
anon: isGuest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { ClientChannelCode, ClientDropdown, ClientProductCode, ClientSortKey, ClientStatus, SalesClient } from "./model";
|
||||
import { toSalesClientRow } from "./model";
|
||||
@@ -16,11 +16,13 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [menu, setMenu] = useState<string | null>(null);
|
||||
const [dropdown, setDropdown] = useState<ClientDropdown | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = salesClients.filter((client) => {
|
||||
return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
|
||||
return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.email.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
|
||||
&& (productFilter.length === 0 || client.products.some((product) => productFilter.includes(product)))
|
||||
&& (channelFilter.length === 0 || client.channels.some((channel) => channelFilter.includes(channel)))
|
||||
&& (!openOnly || client.openDialogs > 0)
|
||||
@@ -31,17 +33,29 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
.sort((left, right) => sortDir === "asc" ? Number(left[key[sortKey]]) - Number(right[key[sortKey]]) : Number(right[key[sortKey]]) - Number(left[key[sortKey]]))
|
||||
.map(toSalesClientRow);
|
||||
}, [salesClients, statusFilter, channelFilter, openOnly, productFilter, query, sortDir, sortKey]);
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const rows = useMemo(
|
||||
() => filteredRows.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filteredRows, page],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount));
|
||||
}, [pageCount]);
|
||||
|
||||
function setQuery(value: string) {
|
||||
setQueryState(value);
|
||||
setPage(1);
|
||||
setMenu(null);
|
||||
}
|
||||
|
||||
function toggleProduct(code: string) {
|
||||
setPage(1);
|
||||
setProductFilter((current) => current.includes(code as ClientProductCode) ? current.filter((item) => item !== code) : [...current, code as ClientProductCode]);
|
||||
}
|
||||
|
||||
function toggleChannel(code: string) {
|
||||
setPage(1);
|
||||
setChannelFilter((current) => current.includes(code as ClientChannelCode) ? current.filter((item) => item !== code) : [...current, code as ClientChannelCode]);
|
||||
}
|
||||
|
||||
@@ -62,12 +76,18 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
setChannelFilter([]);
|
||||
setOpenOnly(false);
|
||||
setStatusFilter(null);
|
||||
setPage(1);
|
||||
setMenu(null);
|
||||
setDropdown(null);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
filteredRows,
|
||||
filteredCount: filteredRows.length,
|
||||
page,
|
||||
pageCount,
|
||||
setPage,
|
||||
query,
|
||||
productFilter,
|
||||
channelFilter,
|
||||
@@ -82,9 +102,9 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
toggleDropdown,
|
||||
setDropdown,
|
||||
closeDropdown: () => setDropdown(null),
|
||||
toggleOpenOnly: () => { setOpenOnly((current) => !current); setMenu(null); setDropdown(null); },
|
||||
toggleOpenOnly: () => { setOpenOnly((current) => !current); setPage(1); setMenu(null); setDropdown(null); },
|
||||
// Повторный клик по активному статусу снимает фильтр (возврат к «все»).
|
||||
toggleStatus: (status: ClientStatus) => { setStatusFilter((current) => (current === status ? null : status)); setMenu(null); setDropdown(null); },
|
||||
toggleStatus: (status: ClientStatus) => { setStatusFilter((current) => (current === status ? null : status)); setPage(1); setMenu(null); setDropdown(null); },
|
||||
sortBy,
|
||||
sortArrow: (key: ClientSortKey) => sortKey === key ? (sortDir === "asc" ? "↑" : "↓") : "",
|
||||
reset,
|
||||
|
||||
@@ -116,24 +116,6 @@
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.sales-message-input button:first-child {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: #8c8c8c;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sales-message-input button:first-child:hover {
|
||||
color: #262626;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.sales-message-input textarea {
|
||||
flex: 1;
|
||||
max-height: 120px;
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem
|
||||
const messageCount = detail?.messages?.length ?? 0;
|
||||
|
||||
const contact = detail?.contact ?? null;
|
||||
const email = contact?.email ?? "";
|
||||
const phone = contact?.phone ?? "";
|
||||
const username = contact?.username ?? "";
|
||||
// Запрос уже отправлен, если в диалоге есть сообщение kind=contact_request (detail поллится каждые 3 с).
|
||||
@@ -58,7 +59,11 @@ export function ClientContext({ dialog, detail }: { dialog: ConversationListItem
|
||||
<div className="sales-client-hero"><span style={{ background: dialog.avatarBg }}>{dialog.initials}</span><strong>{dialog.name}</strong></div>
|
||||
|
||||
<ContextSection title="КОНТАКТ">
|
||||
<FieldRow dot={channel.color} title={username ? `@${username}` : "—"} text={`Логин · ${channel.label}`} />
|
||||
{dialog.channel === "EMAIL" ? (
|
||||
<FieldRow icon="mail" title={email || "—"} text="Email" mono={Boolean(email)} muted={!email} />
|
||||
) : (
|
||||
<FieldRow dot={channel.color} title={username ? `@${username}` : "—"} text={`Логин · ${channel.label}`} />
|
||||
)}
|
||||
<FieldRow icon="phone" title={phone || "—"} text="Телефон" mono={Boolean(phone)} muted={!phone} />
|
||||
{contact && !phone && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ContextSection } from "../../../conversations/ContextSection";
|
||||
import type { ApiConversation } from "../../../conversations/model";
|
||||
import { providerLabel } from "../../../../shared/providers";
|
||||
|
||||
const LIFECYCLE: Record<string, string> = { OPEN: "открыт", CLOSED: "закрыт", SPAM: "спам" };
|
||||
|
||||
@@ -20,7 +21,7 @@ export function HistoryContext({ detail }: { detail: ApiConversation | null }) {
|
||||
{history.map((item) => (
|
||||
<div className="sales-history-card" key={item.id}>
|
||||
<div><strong>{item.channelName}</strong><span>{fmtDate(item.lastActivityAt)}</span></div>
|
||||
<p>{(item.provider ?? "—")} · {LIFECYCLE[item.lifecycle] ?? item.lifecycle}{item.preview ? `. ${item.preview}` : ""}</p>
|
||||
<p>{item.provider ? providerLabel(item.provider) : "—"} · {LIFECYCLE[item.lifecycle] ?? item.lifecycle}{item.preview ? `. ${item.preview}` : ""}</p>
|
||||
</div>
|
||||
))}
|
||||
</ContextSection>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.sales-email-body-frame {
|
||||
display: block;
|
||||
width: min(560px, 58vw);
|
||||
max-width: 100%;
|
||||
height: 260px;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.sales-load-error,
|
||||
.sales-composer-error,
|
||||
.sales-conversation-error {
|
||||
color: #cf1322;
|
||||
background: #fff1f0;
|
||||
border: 1px solid #ffa39e;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sales-load-error {
|
||||
margin: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.sales-composer-error {
|
||||
margin-top: 8px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.sales-conversation-error {
|
||||
margin: 0 18px 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
@@ -2,4 +2,6 @@
|
||||
@import "./list.css";
|
||||
@import "./conversation.css";
|
||||
@import "./composer.css";
|
||||
@import "./email-message.css";
|
||||
@import "./feedback.css";
|
||||
@import "./context.css";
|
||||
@@ -41,7 +41,7 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
|
||||
{route === "profile" && <ProfilePage user={user} onUserUpdated={onUserUpdated} reload={reload} onLogout={onLogout} />}
|
||||
{route === "settings" && <SettingsPage user={user} onUserUpdated={onUserUpdated} reload={reload} />}
|
||||
{route === "salesClients" && <SalesClientsPage openClient={openClient} />}
|
||||
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} setRoute={setRoute} />}
|
||||
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} openConversation={openConversation} />}
|
||||
{route === "salesOverview" && <SalesOverviewPage />}
|
||||
{route === "supportOverview" && <SupportOverviewPage />}
|
||||
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} />}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Провайдеры подключений: MAX / Telegram / Web Chat.
|
||||
/* Провайдеры подключений: MAX / Telegram / Web Chat / Email.
|
||||
|
||||
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
|
||||
и продуктовые экраны каналов зависели от файла с примерами.
|
||||
Провайдеры различаются точкой и коротким лейблом на своих токенах
|
||||
(ADR-HUB-0013), а не бренд-логотипами. */
|
||||
|
||||
export type ProviderKey = "MAX" | "TG" | "WEB";
|
||||
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
|
||||
export const providerMeta: Record<ProviderKey, { label: string; short: string; color: string; bg: string }> = {
|
||||
EMAIL: { label: "Email", short: "Email", color: "#d48806", bg: "#fff7e6" },
|
||||
MAX: { label: "MAX", short: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
|
||||
TG: { label: "Telegram", short: "TG", color: "#2f8fd0", bg: "#eaf6fd" },
|
||||
WEB: { label: "Web Chat", short: "Web", color: "#0f9b8e", bg: "#e8f7f4" },
|
||||
@@ -15,6 +16,7 @@ export const providerMeta: Record<ProviderKey, { label: string; short: string; c
|
||||
|
||||
/** Код провайдера из API → ключ словаря. */
|
||||
const BY_CODE: Record<string, ProviderKey> = {
|
||||
EMAIL: "EMAIL",
|
||||
MAX: "MAX",
|
||||
TELEGRAM: "TG",
|
||||
TG: "TG",
|
||||
|
||||
@@ -45,6 +45,8 @@ type ToneBadgeProps = {
|
||||
|
||||
type TablePaginationProps = {
|
||||
className?: string;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
pageSizeLabel: string;
|
||||
pages: Array<number | "ellipsis">;
|
||||
shown: number;
|
||||
@@ -99,14 +101,16 @@ export function ToneBadge({ bg, className = "", color, children }: ToneBadgeProp
|
||||
return <span className={`ui-tone-badge ${className}`.trim()} style={{ background: bg, color }}>{children}</span>;
|
||||
}
|
||||
|
||||
export function TablePagination({ className = "", pageSizeLabel, pages, shown, total }: TablePaginationProps) {
|
||||
export function TablePagination({ className = "", currentPage = 1, onPageChange, pageSizeLabel, pages, shown, total }: TablePaginationProps) {
|
||||
const numericPages = pages.filter((page): page is number => page !== "ellipsis");
|
||||
const lastPage = Math.max(...numericPages, 1);
|
||||
return (
|
||||
<div className={`ui-table-pagination ${className}`.trim()}>
|
||||
<div>Показано {shown} из {total}</div>
|
||||
<div>
|
||||
<button type="button" disabled>‹</button>
|
||||
{pages.map((page, index) => page === "ellipsis" ? <span key={`ellipsis-${index}`}>…</span> : <button className={page === 1 ? "active" : ""} type="button" key={page}>{page}</button>)}
|
||||
<button type="button">›</button>
|
||||
<button type="button" disabled={!onPageChange || currentPage <= 1} onClick={() => onPageChange?.(currentPage - 1)}>‹</button>
|
||||
{pages.map((page, index) => page === "ellipsis" ? <span key={`ellipsis-${index}`}>…</span> : <button className={page === currentPage ? "active" : ""} type="button" disabled={!onPageChange} onClick={() => onPageChange?.(page)} key={page}>{page}</button>)}
|
||||
<button type="button" disabled={!onPageChange || currentPage >= lastPage} onClick={() => onPageChange?.(currentPage + 1)}>›</button>
|
||||
<i />
|
||||
<em>{pageSizeLabel}</em>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user