mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-08 18:35:00 +03:00
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:
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 }) });
|
||||
Reference in new issue
Block a user