From 52adc210a8d27019366af2c2fa7109cdf8b9f4d5 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 31 Jul 2026 13:52:44 +0300 Subject: [PATCH] :sparkles: feat(calls): owner can claim any dialog + audio/video call buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related conversation-header improvements. Owner claim (business rule) - backend: claim_locked_conversation lets EmployeeRole.OWNER take a dialog already assigned to another operator (other roles keep the existing guard) - frontend: route SessionUser.role through ShellRouteContent → sales/support dialogs pages → ConversationWorkspace → ConversationThread; the owner sees a single «Перехватить диалог» button on any open dialog not assigned to them, including ones held by another operator Audio/video call buttons (design-system IconButton) - conversation header uses the approved IconButton component with outline icons; new is-audio/is-video hover modifiers (by the is-danger pattern, success and primary tokens from the design-system base.css): green hover for audio, blue for video - audio-call modal: drop stray white padding so the dark 9:16 scene fills the modal content (no white side bars) --- .../hub_platform/conversations/services.py | 7 ++++++- .../conversations/ConversationThread.tsx | 16 +++++++++++----- .../conversations/ConversationWorkspace.tsx | 5 +++-- .../src/features/conversations/call.css | 17 +++++++++++++++++ .../src/features/sales/SalesDialogsPage.tsx | 4 +++- .../src/features/support/SupportDialogsPage.tsx | 4 +++- .../src/layout/ShellRouteContent.tsx | 4 ++-- apps/internal-ui/src/shared/ui-controls.css | 15 +++++++++++++++ 8 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/backend/hub_platform/conversations/services.py b/apps/backend/hub_platform/conversations/services.py index 9103e2e..8f5d9c2 100644 --- a/apps/backend/hub_platform/conversations/services.py +++ b/apps/backend/hub_platform/conversations/services.py @@ -12,6 +12,7 @@ from hub_platform.conversations.models import ( MessageAuthor, MessageKind, ) +from hub_platform.identity.models import EmployeeRole from hub_platform.integrations.models import IntegrationProvider from hub_platform.tenancy.context import TenantContext @@ -47,8 +48,12 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat if operator is None or conversation.organization_id != context.organization_id: raise ClaimError("Диалог недоступен") _require_open(conversation) + # Владелец (OWNER) может перехватить диалог у любого оператора; прочие сотрудники + # не могут забрать диалог, уже назначенный другому оператору. + is_owner = context.membership is not None and context.membership.role == EmployeeRole.OWNER if ( - conversation.control_mode == ControlMode.HUMAN + not is_owner + and conversation.control_mode == ControlMode.HUMAN and conversation.assigned_operator_id and conversation.assigned_operator_id != operator.id ): diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 9c9c9ed..05a0528 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import { Icon } from "../../shared/icons"; +import { IconButton } from "../../shared/ui-controls"; import { ConversationActions } from "./ConversationActions"; import { ContactAvatar } from "./ContactAvatar"; import { EmailMessageBody } from "./EmailMessageBody"; @@ -13,7 +14,7 @@ function fmtTime(value: string): string { return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } -export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise }) { +export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise }) { const timelineRef = useRef(null); const messages = detail?.messages ?? []; const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; @@ -40,12 +41,17 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal
- {controlMode === "waiting" && } - {controlMode === "ai" && } + {/* Владелец может перехватить любой открытый диалог (в т.ч. у другого оператора); + обычный сотрудник — только забрать из очереди или перехватить у AI. */} + {isOwner && detail?.lifecycle === "OPEN" && !detail?.isAssignedToViewer && ( + + )} + {!isOwner && controlMode === "waiting" && } + {!isOwner && controlMode === "ai" && } {detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && ( <> - - + onCall("AUDIO")} /> + onCall("VIDEO")} /> )} diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index b1b8327..f9b8635 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -25,8 +25,9 @@ import { useIncomingMessageSound } from "./useIncomingMessageSound"; // заголовком/placeholder поиска и правой панелью через render-prop: consumer // получает {dialog, detail} из state (sales рендерит лид-контекст, support — // operator_cards из контракта). -export function ConversationWorkspace({ department, listTitle, searchPlaceholder, renderContextPanel, initialConversationId }: { +export function ConversationWorkspace({ department, isOwner = false, listTitle, searchPlaceholder, renderContextPanel, initialConversationId }: { department: "sales" | "support"; + isOwner?: boolean; listTitle?: string; searchPlaceholder?: string; renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null }) => ReactNode; @@ -153,7 +154,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder setSelectedId={setSelectedId} />
- void callController.start(kind)} onClose={onClose} onSpam={onSpam} /> + void callController.start(kind)} onClose={onClose} onSpam={onSpam} /> {(detailError || actionError) &&
{detailError || actionError}
} ("client"); return ( ( diff --git a/apps/internal-ui/src/features/support/SupportDialogsPage.tsx b/apps/internal-ui/src/features/support/SupportDialogsPage.tsx index 0df9374..fe2fd8c 100644 --- a/apps/internal-ui/src/features/support/SupportDialogsPage.tsx +++ b/apps/internal-ui/src/features/support/SupportDialogsPage.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; import { ConversationWorkspace } from "../conversations/ConversationWorkspace"; +import type { SessionUser } from "../../types"; import { SupportContextPanel, type SupportRightTab } from "./context/SupportContextPanel"; -export function SupportDialogsPage({ initialConversationId }: { initialConversationId?: number | null }) { +export function SupportDialogsPage({ initialConversationId, user }: { initialConversationId?: number | null; user: SessionUser }) { const [rightTab, setRightTab] = useState("client"); return ( } {route === "salesOverview" && } {route === "supportOverview" && } - {route === "supportDialogs" && } - {route === "salesDialogs" && } + {route === "supportDialogs" && } + {route === "salesDialogs" && } {route === "salesOrderDetail" && } {route === "salesOrders" && } {route === "aiAgents" && } diff --git a/apps/internal-ui/src/shared/ui-controls.css b/apps/internal-ui/src/shared/ui-controls.css index c273c12..723549e 100644 --- a/apps/internal-ui/src/shared/ui-controls.css +++ b/apps/internal-ui/src/shared/ui-controls.css @@ -49,6 +49,21 @@ background: #fff2f0; } +/* Цветные hover для кнопок запроса звонка в шапке диалога — по образцу is-danger. + Токены дизайн-системы base.css: success и primary. База ui-icon-button + остаётся белой с outline-иконкой; характерный цвет проявляется только на hover. */ +.ui-icon-button.is-audio:hover { + color: #389e0d; + border-color: #b7eb8f; + background: #f6ffed; +} + +.ui-icon-button.is-video:hover { + color: #1677ff; + border-color: #91caff; + background: #e6f4ff; +} + .primary-button { min-height: 38px; display: inline-flex;