diff --git a/apps/backend/hub_platform/conversations/chat_extras_views.py b/apps/backend/hub_platform/conversations/chat_extras_views.py index c68a120..ea80376 100644 --- a/apps/backend/hub_platform/conversations/chat_extras_views.py +++ b/apps/backend/hub_platform/conversations/chat_extras_views.py @@ -25,6 +25,8 @@ from hub_platform.conversations.models import ( from hub_platform.conversations.selectors import apply_conversation_visibility from hub_platform.conversations.serializers import conversation_payload from hub_platform.conversations.view_base import ConversationViewBase +from hub_platform.identity.group_models import EmployeeGroup +from hub_platform.identity.models import OrganizationMembership from hub_platform.identity.policy import can_administer_access @@ -205,6 +207,34 @@ class ConversationCountersView(ConversationViewBase): ) +class ConversationDirectoryView(APIView): + """Справочник блока «Диалог» для оператора (дизайн-базлайн v2, кадр G): + все группы организации — для переноса, активные коллеги — для назначения. + Доступен любому, кто видит чат: менеджерские списки сотрудников и групп + сотруднику закрыты, а переносить и назначать он может.""" + + permission_classes = [HasCapability] + required_capabilities = {"GET": "conversations.view"} + + def get(self, request: Request) -> Response: + organization_id = request.tenant_context.organization_id + groups = EmployeeGroup.objects.filter(organization_id=organization_id).order_by("name") + members = ( + OrganizationMembership.objects.select_related("user") + .filter(organization_id=organization_id, blocked_at__isnull=True, user__is_active=True) + .order_by("user__full_name", "user__email") + ) + return Response( + { + "groups": [{"id": group.id, "name": group.name} for group in groups], + "employees": [ + {"id": member.user_id, "name": member.user.full_name or member.user.email} + for member in members + ], + } + ) + + class LabelListView(APIView): """Словарь меток организации. Создание — лёгкое действие оператора («+ Добавить» в карточке), правка/удаление — администратора.""" diff --git a/apps/backend/hub_platform/conversations/test_chat_extras.py b/apps/backend/hub_platform/conversations/test_chat_extras.py index 74c9cca..0668d78 100644 --- a/apps/backend/hub_platform/conversations/test_chat_extras.py +++ b/apps/backend/hub_platform/conversations/test_chat_extras.py @@ -1,6 +1,7 @@ import json from django.test import TestCase +from django.utils import timezone from hub_platform.testing import TenantAPIClient as APIClient from hub_platform.channels.models import Channel @@ -205,6 +206,25 @@ class ListFilterTests(ChatExtrasTestCase): class CountersTests(ChatExtrasTestCase): + def test_directory_lists_all_groups_and_colleagues_for_employee(self) -> None: + support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + blocked = self._member("blocked@chat.test", EmployeeRole.EMPLOYEE) + blocked.blocked_at = timezone.now() + blocked.save(update_fields=["blocked_at"]) + + payload = self.client.get("/api/v1/conversations/directory/").json() + # Сотрудник видит все группы — иначе не перенести диалог (кадр G). + self.assertEqual( + [group["name"] for group in payload["groups"]], ["Операторы", "Поддержка"] + ) + self.assertEqual(payload["groups"][1]["id"], support.id) + names = {employee["id"]: employee["name"] for employee in payload["employees"]} + self.assertIn(self.owner.user_id, names) + self.assertIn(self.employee.user_id, names) + self.assertNotIn(blocked.user_id, names) + def test_counters_reflect_visibility(self) -> None: support = EmployeeGroup.objects.create( organization=self.organization, name="Поддержка" diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py index b708f2f..44f535d 100644 --- a/apps/backend/hub_platform/conversations/urls.py +++ b/apps/backend/hub_platform/conversations/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ path("/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"), path("/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"), path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"), + path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"), path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"), path("labels//", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"), path("templates/", chat_extras_views.ReplyTemplateListView.as_view(), name="reply-template-list"), diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index 67f39ff..06f750b 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -1,17 +1,18 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { ChatMobileHeader } from "../../layout/ChatMobileHeader"; import { Hint } from "../../shared/Hint"; +import { Icon } from "../../shared/icons"; import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace"; import { DialogControls } from "../conversations/DialogControls"; -import type { ApiConversation, ConversationCounters } from "../conversations/model"; +import { fetchChatDirectory, type ApiConversation, type ChatDirectory, type ConversationCounters } from "../conversations/model"; import type { ConversationListItem } from "../conversations/types"; import { ClientContext } from "../sales/dialogs/context/ClientContext"; import { HistoryContext } from "../sales/dialogs/context/HistoryContext"; import { OperatorCards } from "../support/context/OperatorCards"; import { SupportHistory } from "../support/context/SupportHistory"; -import type { EmployeeGroup, RouteKey, SessionUser } from "../../types"; +import type { EmployeeGroupRef, RouteKey, SessionUser } from "../../types"; // Единый «Чат» (дизайн-базлайн v2 §8.1): один экран для всех диалогов // организации. Контекст-панель сама выбирает представление по источнику @@ -22,27 +23,31 @@ type ChatRightTab = "client" | "history"; export function ChatPage({ initialConversationId, user, - groups = [], - employees = [], scope, setScope, counters, showScopeSwitcher, setRoute, onLogout, + onOpenMenu, }: { initialConversationId?: number | null; user: SessionUser; - groups?: EmployeeGroup[]; - employees?: Array<{ id: number; name: string }>; scope: DialogScope; setScope: (scope: DialogScope) => void; counters: ConversationCounters | null; showScopeSwitcher: boolean; setRoute: (route: RouteKey) => void; onLogout: () => void; + onOpenMenu: () => void; }) { const [rightTab, setRightTab] = useState("client"); + const [directory, setDirectory] = useState({ groups: [], employees: [] }); + useEffect(() => { + let cancelled = false; + fetchChatDirectory().then((payload) => { if (!cancelled) setDirectory(payload); }).catch(() => undefined); + return () => { cancelled = true; }; + }, [user.organizationPublicId]); return ( } + mobileHeader={({ total }) => } hint={showScopeSwitcher ? Вы видите все диалоги организации. Сотрудники видят только диалоги своих групп, без группы и те, где они ответственные. : undefined} viewerId={user.id} - renderContextPanel={({ dialog, detail, applyConversation, startCall }) => ( + renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext }) => ( )} @@ -82,16 +88,18 @@ function ChatContextPanel({ employees, applyConversation, startCall, + closeContext, viewerId, }: { rightTab: ChatRightTab; setRightTab: (tab: ChatRightTab) => void; dialog: ConversationListItem | null; detail: ApiConversation | null; - groups: EmployeeGroup[]; + groups: EmployeeGroupRef[]; employees: Array<{ id: number; name: string }>; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; + closeContext: () => void; viewerId: number; }) { const isSupport = Boolean(detail?.supportIdentitySnapshot); @@ -100,6 +108,7 @@ function ChatContextPanel({
setRightTab("client")}>Контакт setRightTab("history")}>История +
{rightTab === "client" && ( diff --git a/apps/internal-ui/src/features/conversations/Composer.tsx b/apps/internal-ui/src/features/conversations/Composer.tsx index 2738fb3..bfbbf64 100644 --- a/apps/internal-ui/src/features/conversations/Composer.tsx +++ b/apps/internal-ui/src/features/conversations/Composer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../shared/icons"; +import { useMediaQuery } from "../../shared/useMediaQuery"; import { fetchReplyTemplates, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model"; import { formatDuration } from "./VoiceMessage"; import { useVoiceRecorder } from "./useVoiceRecorder"; @@ -8,6 +9,7 @@ import type { ChannelKey, ControlMode } from "./types"; export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) { const [text, setText] = useState(""); + const compact = useMediaQuery("(max-width: 768px)"); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(""); const [templates, setTemplates] = useState([]); @@ -137,7 +139,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c