feat(ui): M2b — operator takeover wired to live conversations

Sales dialogs screen now uses the real conversations API: list (polled),
selected dialog with real messages (polled), atomic take-over (Перехватить AI /
Забрать), operator reply sent back to the same MAX/Telegram chat, and return to
AI. Mock dialog data replaced; context panel left as-is for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-28 13:02:36 +03:00
1 parent fde4f6ac6b
commit 8f7d07bb93
4 files changed
+218 -52

No files matched your search

@@ -1,26 +1,95 @@
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { SalesComposer } from "./dialogs/SalesComposer";
import { SalesContextPanel } from "./dialogs/SalesContextPanel";
import { SalesConversation } from "./dialogs/SalesConversation";
import { SalesDialogList } from "./dialogs/SalesDialogList";
import { dialogs } from "./dialogs/data";
import type { ControlMode, ListTab, RightTab } from "./dialogs/types";
import {
claimConversation,
controlModeOf,
fetchConversation,
fetchConversations,
releaseConversation,
toDialog,
type ApiConversation,
} from "./dialogs/model";
import type { ListTab, RightTab } from "./dialogs/types";
export function SalesDialogsPage() {
const [listTab, setListTab] = useState<ListTab>("all");
const [rightTab, setRightTab] = useState<RightTab>("client");
const [selectedId, setSelectedId] = useState(1);
const [controlMode, setControlMode] = useState<ControlMode>("waiting");
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<ApiConversation | null>(null);
const selected = dialogs.find((dialog) => dialog.id === selectedId) ?? dialogs[0];
const filtered = useMemo(() => dialogs.filter((dialog) => {
if (listTab === "wait") return dialog.mode === "wait";
if (listTab === "ai") return dialog.mode === "ai";
if (listTab === "operator") return dialog.mode === "operator";
if (listTab === "unread") return dialog.unread > 0;
return true;
}), [listTab]);
const loadList = useCallback(async () => {
try {
const items = await fetchConversations();
setConversations(items);
setSelectedId((current) => current ?? items[0]?.id ?? null);
} catch {
/* keep previous list on transient errors */
}
}, []);
const loadDetail = useCallback(async (id: number) => {
try {
setDetail(await fetchConversation(id));
} catch {
/* ignore */
}
}, []);
useEffect(() => {
void loadList();
const timer = setInterval(loadList, 4000);
return () => clearInterval(timer);
}, [loadList]);
useEffect(() => {
if (selectedId == null) return;
void loadDetail(selectedId);
const timer = setInterval(() => loadDetail(selectedId), 3000);
return () => clearInterval(timer);
}, [selectedId, loadDetail]);
const dialogs = useMemo(() => conversations.map(toDialog), [conversations]);
const filtered = useMemo(
() =>
dialogs.filter((dialog) => {
if (listTab === "wait") return dialog.mode === "wait";
if (listTab === "ai") return dialog.mode === "ai";
if (listTab === "operator") return dialog.mode === "operator";
if (listTab === "unread") return dialog.unread > 0;
return true;
}),
[dialogs, listTab],
);
const selectedDialog = dialogs.find((dialog) => dialog.id === selectedId) ?? null;
const controlMode = detail ? controlModeOf(detail) : "ai";
function applyUpdated(updated: ApiConversation) {
setDetail(updated);
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 */
}
};
return (
<div className="sales-dialogs">
@@ -28,13 +97,19 @@ export function SalesDialogsPage() {
dialogs={dialogs}
filtered={filtered}
listTab={listTab}
selectedId={selectedId}
selectedId={selectedId ?? -1}
setListTab={setListTab}
setSelectedId={setSelectedId}
/>
<section className="sales-conversation">
<SalesConversation controlMode={controlMode} selected={selected} setControlMode={setControlMode} />
<SalesComposer mode={controlMode} setMode={setControlMode} />
<SalesConversation controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} />
<SalesComposer
mode={controlMode}
conversationId={selectedId}
onClaim={onClaim}
onRelease={onRelease}
onSent={() => selectedId != null && loadDetail(selectedId)}
/>
</section>
<SalesContextPanel rightTab={rightTab} setRightTab={setRightTab} />
</div>
@@ -1,14 +1,19 @@
import { useState } from "react";
import { Icon } from "../../../shared/icons";
import { sendOperatorMessage } from "./model";
import type { ControlMode } from "./types";
export function SalesComposer({ mode, setMode }: { mode: ControlMode; setMode: (mode: ControlMode) => void }) {
export function SalesComposer({ mode, conversationId, onClaim, onRelease, onSent }: { mode: ControlMode; conversationId: number | null; onClaim: () => void; onRelease: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
if (mode === "waiting") {
return (
<div className="sales-composer"><div className="sales-waiting-composer">
<span><Icon name="lock" size={19} /></span>
<div><strong>Диалог ждёт оператора</strong><p>Возьмите диалог, чтобы ответить клиенту. AI поставлен на паузу.</p></div>
<button className="sales-secondary-action">Вернуть в очередь</button>
<button className="sales-claim-button" onClick={() => setMode("human")}><Icon name="check" size={15} />Забрать диалог</button>
<button className="sales-claim-button" onClick={onClaim}><Icon name="check" size={15} />Забрать диалог</button>
</div></div>
);
}
@@ -18,24 +23,40 @@ export function SalesComposer({ mode, setMode }: { mode: ControlMode; setMode: (
<div className="sales-composer"><div className="sales-ai-composer">
<span><Icon name="robot" size={19} /></span>
<div><strong>AI ведёт диалог</strong><p>Перехватите, чтобы ответить вручную. Контроль перейдёт к вам.</p></div>
<button className="sales-ai-button" onClick={() => setMode("human")}>Перехватить AI</button>
<button className="sales-ai-button" onClick={onClaim}>Перехватить AI</button>
</div></div>
);
}
async function send() {
const value = text.trim();
if (!value || conversationId == null || sending) return;
setSending(true);
try {
await sendOperatorMessage(conversationId, value);
setText("");
onSent();
} finally {
setSending(false);
}
}
return (
<div className="sales-composer">
<div className="sales-human-tools">
<button>Шаблоны ответов</button>
<button>Создать ссылку покупки</button>
<span />
<button className="ai" onClick={() => setMode("ai")}>Вернуть AI</button>
<button>Закрыть</button>
<button className="ai" onClick={onRelease}>Вернуть AI</button>
</div>
<div className="sales-message-input">
<button aria-label="Прикрепить файл"><Icon name="paperclip" size={19} /></button>
<textarea rows={1} placeholder="Введите сообщение…" />
<button>Отправить<Icon name="send" size={15} /></button>
<textarea
rows={1}
placeholder="Введите сообщение…"
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void send(); } }}
/>
<button onClick={() => void send()} disabled={sending}>Отправить<Icon name="send" size={15} /></button>
</div>
</div>
);
@@ -2,45 +2,61 @@ import type { ReactNode } from "react";
import { Icon } from "../../../shared/icons";
import { channelMeta, statusFor } from "./data";
import type { ApiConversation, ApiMessage } from "./model";
import type { ControlMode, SalesDialog, StatusInfo } from "./types";
export function SalesConversation({ controlMode, selected, setControlMode }: { controlMode: ControlMode; selected: SalesDialog; setControlMode: (mode: ControlMode) => void }) {
function fmtTime(value: string): string {
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
export function SalesConversation({ controlMode, dialog, detail, onClaim }: { controlMode: ControlMode; dialog: SalesDialog | null; detail: ApiConversation | null; onClaim: () => void }) {
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 channel = channelMeta[selected.channel];
const channel = channelMeta[dialog.channel];
const messages = detail?.messages ?? [];
return (
<>
<div className="sales-conversation-head">
<div className="sales-conversation-person">
<span style={{ background: selected.avatarBg }}>{selected.initials}</span>
<span style={{ background: dialog.avatarBg }}>{dialog.initials}</span>
<div>
<div><strong>{selected.name}</strong><StatusBadge status={status} /></div>
<p>{selected.product}<i /> <em style={{ background: channel.color }} />{channel.label}<i />{channel.handle}</p>
<div><strong>{dialog.name}</strong><StatusBadge status={status} /></div>
<p>{dialog.product}<i /> <em style={{ background: channel.color }} />{channel.label}</p>
</div>
</div>
<div className="sales-conversation-actions">
{controlMode === "waiting" && <button className="sales-claim-button" onClick={() => setControlMode("human")}><Icon name="check" size={15} />Забрать</button>}
{controlMode === "ai" && <button className="sales-ai-button" onClick={() => setControlMode("human")}>Перехватить AI</button>}
{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>}
<button className="sales-more-button" aria-label="Действия диалога"><Icon name="more" size={18} /></button>
</div>
</div>
<div className="sales-timeline">
<div className="sales-timeline-inner">
<div className="sales-day-divider"><span />Сегодня<span /></div>
<div className="sales-event-chip"><Icon name="clock" size={12} />Диалог начат · канал MAX · 14:02</div>
<Message side="client" initials="МС" avatarBg="#eb6f4b" time="14:02">Здравствуйте! Смотрю готовые сайты FirePage для студии красоты. Как это работает?</Message>
<Message side="ai" actor="AI-агент FirePage" time="14:03">Здравствуйте! FirePage — это готовый сайт под нишу: выбираете вариант, например <b>BeautySoft</b>, оплачиваете разовую лицензию 4 900 ₽ — и сайт ваш, без подписки.</Message>
<Message side="client" initials="МС" avatarBg="#eb6f4b" time="14:06">А мой текущий контент перенесёте? Лучше бы уточнить у менеджера.</Message>
<div className="sales-event-chip warning"><Icon name="team" size={12} />AI передал диалог оператору · причина: запрос человека (перенос контента)</div>
{controlMode === "waiting" && <div className="sales-wait-note">Ожидает оператора · 4 мин</div>}
{controlMode === "ai" && <TypingMessage />}
{controlMode === "human" && <div className="sales-event-chip claimed"><Icon name="check" size={13} />Иван Петров забрал диалог · 14:09</div>}
{controlMode === "human" && <Message side="operator" initials="ИП" avatarBg="#1677ff" actor="Иван · оператор" time="14:10">Здравствуйте, Мария! Я подключился. По переносу — поможем перенести ваши тексты, фото и онлайн-запись на выбранный сайт. Подобрать вариант под вашу нишу?</Message>}
{messages.length === 0 && <div className="sales-wait-note">Пока нет сообщений</div>}
{messages.map((message) => (
<MessageRow key={message.id} message={message} dialog={dialog} />
))}
</div>
</div>
</>
);
}
function MessageRow({ message, dialog }: { message: ApiMessage; dialog: SalesDialog }) {
if (message.author === "SYSTEM") {
return <div className="sales-event-chip"><Icon name="clock" size={12} />{message.text} · {fmtTime(message.createdAt)}</div>;
}
const side = message.author === "CONTACT" ? "client" : message.author === "OPERATOR" ? "operator" : "ai";
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>
);
}
function StatusBadge({ status }: { status: StatusInfo }) {
return <span className="sales-status-badge" style={{ background: status.bg, borderColor: status.border, color: status.color }}><i style={{ background: status.dot }} />{status.label}</span>;
}
@@ -57,12 +73,3 @@ function Message({ side, initials, avatarBg, actor, time, children }: { side: "a
</div>
);
}
function TypingMessage() {
return (
<div className="sales-message ai typing">
<div className="sales-message-avatar"><Icon name="robot" size={16} /></div>
<div className="sales-typing-bubble"><span /><span /><span /></div>
</div>
);
}
@@ -0,0 +1,63 @@
import { api } from "../../../api/client";
import type { ChannelKey, ControlMode, DialogMode, SalesDialog } from "./types";
export type ApiMessage = { id: number; author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; text: string; createdAt: string };
export type ApiConversation = {
id: number;
channel: { code: string; name: string };
connection: { id: number; provider: "MAX" | "TELEGRAM" | "WEB"; name: string } | null;
contact: { id: number; name: string };
lifecycle: "OPEN" | "CLOSED" | "SPAM";
controlMode: "AI" | "HUMAN" | "PAUSED";
expectedResponder: string;
assignedOperatorId: number | null;
lastActivityAt: string;
createdAt: string;
lastMessage: ApiMessage | null;
messages?: ApiMessage[];
};
const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"];
const PROVIDER_CHANNEL: Record<string, ChannelKey> = { MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
export function controlModeOf(conversation: ApiConversation): ControlMode {
if (conversation.controlMode === "HUMAN") return "human";
if (conversation.controlMode === "AI") return "ai";
return "waiting";
}
function dialogMode(conversation: ApiConversation): DialogMode {
if (conversation.lifecycle === "CLOSED") return "closed";
if (conversation.controlMode === "HUMAN") return "operator";
if (conversation.controlMode === "AI") return "ai";
return "wait";
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "Г";
return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
}
export function toDialog(conversation: ApiConversation): SalesDialog {
const name = conversation.contact.name || `Гость ${conversation.contact.id}`;
return {
id: conversation.id,
name,
initials: initialsOf(name),
avatarBg: AVATAR_PALETTE[conversation.contact.id % AVATAR_PALETTE.length],
product: conversation.channel.name,
channel: PROVIDER_CHANNEL[conversation.connection?.provider ?? "WEB"] ?? "WEB",
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" }),
unread: 0,
};
}
export const fetchConversations = () => api<{ items: ApiConversation[] }>("/api/v1/conversations/").then((r) => r.items);
export const fetchConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/`).then((r) => r.conversation);
export const claimConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/claim/`, { method: "POST" }).then((r) => r.conversation);
export const releaseConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/release/`, { method: "POST" }).then((r) => r.conversation);
export const sendOperatorMessage = (id: number, text: string) => api(`/api/v1/conversations/${id}/messages/`, { method: "POST", body: JSON.stringify({ text }) });