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