✨ feat(calls): owner can claim any dialog + audio/video call buttons

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)
This commit is contained in:
Andrey committed 2026-07-31 13:52:44 +03:00
1 parent c4d11a8543
commit 52adc210a8
8 files changed
+60 -12

No files matched your search

@@ -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
):
@@ -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<boolean> }) {
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean> }) {
const timelineRef = useRef<HTMLDivElement>(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
</div>
</div>
<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>}
{/* Владелец может перехватить любой открытый диалог (в т.ч. у другого оператора);
обычный сотрудник — только забрать из очереди или перехватить у AI. */}
{isOwner && detail?.lifecycle === "OPEN" && !detail?.isAssignedToViewer && (
<button className="sales-ai-button" onClick={onClaim}>Перехватить диалог</button>
)}
{!isOwner && controlMode === "waiting" && <button className="sales-claim-button" onClick={onClaim}><Icon name="check" size={15} />Забрать</button>}
{!isOwner && controlMode === "ai" && <button className="sales-ai-button" onClick={onClaim}>Перехватить AI</button>}
{detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && (
<>
<button className="sales-more-button" aria-label="Запросить аудиозвонок" title="Запросить аудиозвонок" onClick={() => onCall("AUDIO")}><Icon name="phone" size={17} /></button>
<button className="sales-more-button" aria-label="Запросить видеозвонок" title="Запросить видеозвонок" onClick={() => onCall("VIDEO")}><Icon name="video" size={17} /></button>
<IconButton icon="phone" label="Запросить аудиозвонок" className="is-audio" onClick={() => onCall("AUDIO")} />
<IconButton icon="video" label="Запросить видеозвонок" className="is-video" onClick={() => onCall("VIDEO")} />
</>
)}
<ConversationActions open={detail?.lifecycle === "OPEN"} onClose={onClose} onSpam={onSpam} />
@@ -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}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<CallOverlay
open={callController.open}
@@ -9,6 +9,23 @@
box-shadow: none;
}
/* Аудиозвонок: сцена тёмная и портретная (9:16). Modal-контейнер должен
сливаться со сценой — никаких белых полей/паддингов/рамок вокруг карточки. */
.audio-call-modal .ant-modal-content {
padding: 0;
background: #0d0d16;
border-radius: 24px;
overflow: hidden;
}
.audio-call-modal .hub-audio-view {
max-width: none;
max-height: none;
aspect-ratio: 9 / 16;
border-radius: 0;
box-shadow: none;
}
.call-channel {
display: inline-flex;
align-items: center;
@@ -1,14 +1,16 @@
import { useState } from "react";
import { ConversationWorkspace } from "../../features/conversations/ConversationWorkspace";
import type { SessionUser } from "../../types";
import { SalesContextPanel } from "./dialogs/SalesContextPanel";
import type { SalesRightTab } from "./dialogs/context/SalesContextTabs";
export function SalesDialogsPage({ initialConversationId }: { initialConversationId?: number | null }) {
export function SalesDialogsPage({ initialConversationId, user }: { initialConversationId?: number | null; user: SessionUser }) {
const [rightTab, setRightTab] = useState<SalesRightTab>("client");
return (
<ConversationWorkspace
department="sales"
isOwner={user.role === "OWNER"}
initialConversationId={initialConversationId}
renderContextPanel={({ dialog, detail }) => (
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} dialog={dialog} detail={detail} />
@@ -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<SupportRightTab>("client");
return (
<ConversationWorkspace
department="support"
isOwner={user.role === "OWNER"}
listTitle="Обращения"
searchPlaceholder="Поиск по клиенту, продукту…"
initialConversationId={initialConversationId}
@@ -64,8 +64,8 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "salesClientDetail" && <SalesClientDetailPage contactId={selectedClientId} openConversation={openConversation} />}
{route === "salesOverview" && <SalesOverviewPage openConversation={openConversation} />}
{route === "supportOverview" && <SupportOverviewPage />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "salesOrderDetail" && <SaleDetailPage saleId={selectedOrderId} canCorrect={hasCapability(user, "sales.correct", "sales")} setRoute={setRoute} openDialog={openConversation} />}
{route === "salesOrders" && <SalesRegistryPage products={data.products} setRoute={setRoute} openSale={openOrder} openDialog={openConversation} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} />}
@@ -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;