);
}
function MessageBubble({ msg, onImageClick, compact = false }) {
// На узких экранах пузырь может занимать больше ширины — иначе текст
// ломается на две-три буквы в строке.
const wide = compact ? "max-w-[82%]" : "max-w-[70%]";
if (msg.kind === "system") {
return (
);
}
return null;
}
// ── Автодополнение шаблонов по слешу ────────────────────────────────────────
// Оператор пишет «/» в начале сообщения → список шаблонов; дальше сужает его
// набором по заголовку либо по началу текста шаблона. Логика поиска вынесена
// отдельно от компонента: её же удобно дёрнуть из проверок.
// Порядок важен: точное попадание должно оказаться первым, чтобы «/» + пара
// букв + Enter закрывали типичный случай.
function rankTemplates(templates, query) {
const list = templates || [];
const q = (query || "").trim().toLowerCase();
if (!q) return list;
const scored = [];
for (const t of list) {
const title = (t.title || "").toLowerCase();
const text = (t.text || "").toLowerCase();
let rank = -1;
if (title.startsWith(q)) rank = 0;
else if (title.includes(q)) rank = 1;
// «Заголовок не помню, помню как начинается ответ».
else if (text.startsWith(q)) rank = 2;
else if (text.includes(q)) rank = 3;
if (rank >= 0) scored.push({ t, rank });
}
// Внутри одного ранга сохраняем исходный порядок (группа, заголовок).
return scored.map((s, i) => ({ ...s, i }))
.sort((a, b) => a.rank - b.rank || a.i - b.i)
.map((s) => s.t);
}
// Запрос автодополнения или null, если панель показывать не нужно: слеш ловим
// только первым символом (как в ТГ), поэтому «/home/user» внутри текста ничего
// не открывает.
function slashQueryOf(draft, mode) {
if (mode !== "message") return null;
if (!draft.startsWith("/")) return null;
if (draft.includes("\n")) return null;
return draft.slice(1);
}
function SlashTemplateList({ items, activeIndex, onPick, onHover, compact = false }) {
const boxRef = useRefD(null);
// Активная строка всегда в видимой области — иначе перебор стрелками
// «уезжает» за границу списка.
useEffectD(() => {
const box = boxRef.current;
const row = box && box.querySelector(`[data-idx="${activeIndex}"]`);
if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
{items.map((t, i) => (
))}
↑↓ выбрать · Enter вставить · Esc закрыть
);
}
function TemplatePickerModal({ onSelect, onClose, templates }) {
// Список приходит готовым из DialogsScreen — он же питает автодополнение по
// слешу, поэтому грузить его второй раз при открытии модалки не нужно.
const [search, setSearch] = useStateD("");
const [group, setGroup] = useStateD("all");
const groups = useMemoD(() => {
if (!templates) return [];
return [...new Set(templates.map(t => t.group_name))];
}, [templates]);
const filtered = useMemoD(() => {
if (!templates) return [];
let list = group === "all" ? templates : templates.filter(t => t.group_name === group);
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(t => t.title.toLowerCase().includes(q) || t.text.toLowerCase().includes(q));
}
return list;
}, [templates, group, search]);
return (
{templates === null ? "Загрузка..." : "Шаблоны не найдены"}
) : filtered.map(t => (
))}
{/* Groups sidebar */}
{["all", ...groups].map(g => (
))}
);
}
function TransferModal({ activeDialog, operators, currentOperator, onTransfer, onClose }) {
// Передать тикет можно только тому, у кого есть доступ к его ВПН-сервису
// (админ обслуживает все) — иначе сервер вернёт 400.
const candidates = (operators || []).filter(op =>
op.name !== activeDialog?.assignedOperator &&
(op.role === "admin" || (op.serviceIds || []).includes(activeDialog?.serviceId))
);
return (
Передать тикет
{candidates.length === 0 && (
Нет доступных операторов
)}
{candidates.map(op => (
))}
);
}
function DialogsScreen({
conversations, setConversations,
activeId, setActiveId,
showToast,
onReply, onToggleAI, onClose, onHandoff, onReopen, onWait,
currentOperator, operators,
showServiceTag = false,
serviceId = null, folders = [],
viewport, mobileChrome, onMobileChatOpen,
}) {
const [searchQ, setSearchQ] = useStateD("");
const [view, setView] = useStateD("my"); // "my" | "all"
const [filter, setFilter] = useStateD("wip");
const [draft, setDraft] = useStateD("");
const [mode, setMode] = useStateD("message"); // "message" | "comment"
const [aiEnabled, setAiEnabled] = useStateD(true);
const [lightboxUrl, setLightboxUrl] = useStateD(null);
const [pendingFile, setPendingFile] = useStateD(null);
const [confirmClose, setConfirmClose] = useStateD(false);
const [showTemplates, setShowTemplates] = useStateD(false);
const [showTransfer, setShowTransfer] = useStateD(false);
const [showFolderPick, setShowFolderPick] = useStateD(false);
const [actionsOpen, setActionsOpen] = useStateD(false);
// Шаблоны нужны сразу: по «/» список должен появляться мгновенно, без похода
// в сеть. Модалка шаблонов берёт этот же массив.
const [templates, setTemplates] = useStateD([]);
const [slashIndex, setSlashIndex] = useStateD(0);
const [slashDismissed, setSlashDismissed] = useStateD(false);
// Телефон: экран показывает либо список, либо переписку.
const [mobileView, setMobileView] = useStateD("list");
const [showSearch, setShowSearch] = useStateD(false);
const [showClientSheet, setShowClientSheet] = useStateD(false);
const [showActionsSheet, setShowActionsSheet] = useStateD(false);
const scrollRef = useRefD(null);
const fileInputRef = useRefD(null);
const composerRef = useRefD(null);
const active = conversations.find((c) => c.id === activeId) || conversations[0];
// Тикет могли открыть снаружи (клик по уведомлению, ссылка ?dialog=) — тогда
// на телефоне сразу показываем переписку, а не список.
useEffectD(() => { if (activeId) setMobileView("chat"); }, [activeId]);
// Шторки принадлежат конкретному тикету: смена тикета их закрывает.
useEffectD(() => { setShowClientSheet(false); setShowActionsSheet(false); setActionsOpen(false); }, [active?.id]);
// Sync AI toggle state when active dialog changes; reset composer mode
useEffectD(() => {
if (active) setAiEnabled(active.aiEnabled ?? true);
setMode("message");
}, [active?.id, active?.aiEnabled]);
useEffectD(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [active?.id, active?.messages?.length]);
// Шаблоны пер-сервисные: при переходе на тикет другого ВПН-а подгружаем его
// набор (плюс общие).
useEffectD(() => {
const sid = active?.serviceId;
if (!sid) { setTemplates([]); return; }
let stale = false;
window.apiFetch("GET", `/api/templates?service_id=${sid}`)
.then((list) => { if (!stale) setTemplates(list || []); })
.catch(() => { if (!stale) setTemplates([]); });
return () => { stale = true; };
}, [active?.serviceId]);
// ── Автодополнение по слешу ──────────────────────────────────────────────
const slashQuery = slashQueryOf(draft, mode);
const slashMatches = useMemoD(
() => (slashQuery === null ? [] : rankTemplates(templates, slashQuery)),
[templates, slashQuery]
);
// Нет совпадений — панель прячется: иначе она мешала бы отправить сообщение,
// которое просто начинается со слеша.
const slashOpen = slashQuery !== null && !slashDismissed && slashMatches.length > 0;
// Смена запроса возвращает выделение на первую строку; уход от слеша снимает
// ручное закрытие, чтобы следующий «/» снова открыл панель.
useEffectD(() => { setSlashIndex(0); }, [slashQuery]);
useEffectD(() => { if (slashQuery === null) setSlashDismissed(false); }, [slashQuery]);
function applyTemplate(t) {
// Слеш ловится только в начале сообщения, поэтому шаблон заменяет весь текст.
setDraft(t.text);
setSlashDismissed(false);
const el = composerRef.current;
if (el) {
requestAnimationFrame(() => {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
});
}
}
// На телефоне модалка шаблонов с боковым списком групп не помещается —
// кнопка просто ставит «/» и открывает ту же панель автодополнения.
function openSlashPanel() {
setDraft("/");
setSlashDismissed(false);
const el = composerRef.current;
if (el) requestAnimationFrame(() => { el.focus(); el.selectionStart = el.selectionEnd = 1; });
}
function onComposerKeyDown(e) {
// Cmd/Ctrl+Enter отправляет всегда — панель при этом просто закрывается.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSlashDismissed(true);
sendMessage();
return;
}
if (!slashOpen) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => (i + 1) % slashMatches.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => (i - 1 + slashMatches.length) % slashMatches.length);
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
applyTemplate(slashMatches[Math.min(slashIndex, slashMatches.length - 1)]);
} else if (e.key === "Escape") {
e.preventDefault();
setSlashDismissed(true); // текст остаётся как есть
}
}
// Sections per view: «Все» — the whole pipeline, «Мои» — only own tickets.
// «Все» shows 3 main tabs + an overflow menu («ещё») with ИИ and Закрытые.
const SECTION_STATUS = { ai: "ai", queue: "queue", wip: "in_progress", waiting: "waiting", closed: "closed" };
const SECTION_META = {
wip: { label: "В работе", icon: "📁" },
waiting: { label: "Ожидание", icon: "⏸️" },
queue: { label: "Очередь", icon: "⌛" },
ai: { label: "ИИ", icon: "🤖" },
closed: { label: "Закрытые", icon: "✅" },
};
const MY_SECTIONS = ["wip", "waiting", "closed"];
const ALL_SECTIONS = ["wip", "waiting", "queue", "ai", "closed"];
// Папки видны только при выбранном ВПН-е: они пер-сервисные, и одноимённые
// папки двух сервисов в одном ряду читались бы как одна.
const visibleFolders = serviceId === null ? [] : folders;
const isFolder = (f) => String(f).startsWith("folder:");
const folderIdOf = (f) => Number(String(f).slice(7));
function switchView(v) {
setView(v);
const valid = v === "my" ? MY_SECTIONS : ALL_SECTIONS;
if (!isFolder(filter) && !valid.includes(filter)) setFilter("wip");
}
const baseList = useMemoD(() =>
view === "my"
? conversations.filter((c) => c.assignedOperator === currentOperator?.name)
: conversations,
[conversations, view, currentOperator]
);
const filtered = useMemoD(() => {
// В папке показываем открытые тикеты: закрытые живут в своём разделе,
// иначе счётчик папки и её содержимое расходились бы.
let list = isFolder(filter)
? baseList.filter((c) => c.folderId === folderIdOf(filter) && c.status !== "closed")
: baseList.filter((c) => c.status === SECTION_STATUS[filter]);
if (searchQ.trim()) {
const q = searchQ.toLowerCase();
list = list.filter(
(c) => c.name.toLowerCase().includes(q)
|| c.username.toLowerCase().includes(q)
|| c.preview.toLowerCase().includes(q)
|| String(c.chatId || "").includes(q)
);
}
return [...list].sort((a, b) => {
const ta = a.updatedAt || "";
const tb = b.updatedAt || "";
return tb.localeCompare(ta);
});
}, [baseList, filter, searchQ]);
const counts = useMemoD(() => {
const res = {};
for (const id of ALL_SECTIONS) {
res[id] = baseList.filter((c) => c.status === SECTION_STATUS[id]).length;
}
for (const f of visibleFolders) {
res["folder:" + f.id] = baseList.filter(
(c) => c.folderId === f.id && c.status !== "closed").length;
}
return res;
}, [baseList, visibleFolders]);
// Один список вкладок на обе раскладки: статусные разделы, разделитель,
// папки. Ряд прокручивается — новые папки не ломают вёрстку.
const sectionTabs = useMemoD(() => {
const statuses = (view === "my" ? MY_SECTIONS : ALL_SECTIONS).map((id) => ({
key: id, icon: SECTION_META[id].icon, label: SECTION_META[id].label,
}));
return statuses.concat(visibleFolders.map((f) => ({
key: "folder:" + f.id, icon: f.emoji, label: f.name, color: f.color,
})));
}, [view, visibleFolders]);
async function setDialogFolder(folderId) {
setShowFolderPick(false);
if (!active) return;
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/folder`, { folder_id: folderId });
const f = folders.find((x) => x.id === folderId);
showToast(f ? `Тикет в папке «${f.name}»` : "Тикет вынут из папки");
} catch (e) {
showToast(e?.status === 423 ? e.detail : "Не удалось изменить папку");
}
}
async function handleTransfer(operatorName) {
setShowTransfer(false);
if (!active) return;
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/transfer`, { operator_name: operatorName });
showToast(`Тикет передан: ${operatorName}`);
} catch { showToast("Ошибка передачи"); }
}
async function handleFileSelect(e) {
const file = e.target.files?.[0];
if (!file || !active) return;
e.target.value = "";
try {
const { url } = await window.apiFetch("UPLOAD", "/api/upload", file);
const type = file.type.startsWith("image/") ? "photo" : file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "document";
setPendingFile({ url, type, name: file.name });
} catch { showToast("Ошибка загрузки файла"); }
}
async function sendMessage() {
const text = draft.trim();
if (!active) return;
if (mode === "comment") {
if (!text) return;
setDraft("");
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/comment`, { text });
} catch (e) {
console.error("Comment error", e);
}
return;
}
if (!text && !pendingFile) return;
setDraft("");
const fileArgs = pendingFile ? { file_url: pendingFile.url, file_type: pendingFile.type } : {};
setPendingFile(null);
if (onReply) onReply(active.id, text, fileArgs);
}
function pickTemplate(text) {
setDraft(prev => prev ? prev + "\n" + text : text);
setShowTemplates(false);
}
function handoffToOperator() {
if (!active) return;
showToast("Диалог взят в работу");
if (onHandoff) onHandoff(active.id);
}
function reopenDialog() {
if (!active) return;
showToast("Диалог возвращён в очередь");
if (onReopen) onReopen(active.id);
}
function waitDialog() {
if (!active) return;
showToast("Тикет переведён в ожидание");
if (onWait) onWait(active.id);
}
function closeDialog() {
if (!active) return;
setConfirmClose(false);
showToast("Диалог закрыт");
if (onClose) onClose(active.id);
}
async function reopenClosed() {
if (!active) return;
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/reopen-closed`);
showToast("Диалог переоткрыт");
} catch (e) {
if (e?.status === 409 && e?.active_dialog_id) {
setActiveId(e.active_dialog_id);
} else {
showToast("Ошибка при переоткрытии");
}
}
}
const activeDialogForSameUser = active?.status === "closed"
? conversations.find((c) => c.chatId === active.chatId && c.id !== active.id && c.status !== "closed")
: null;
async function toggleAI() {
if (!active) return;
if (onToggleAI) {
const result = await onToggleAI(active.id);
if (result && result.ai_enabled !== undefined) {
setAiEnabled(result.ai_enabled);
}
} else {
setAiEnabled((v) => !v);
}
}
// ── Раскладка ────────────────────────────────────────────────────────────
// Куски интерфейса — список, переписка и карточка клиента — одни и те же на
// всех ширинах; меняется только их размещение: телефон — drill-down, планшет
// — две колонки и карточка шторкой, десктоп — три колонки как раньше.
const vp = viewport || { isMobile: false, isTablet: false, isDesktop: true, isCompact: false };
const compact = vp.isCompact;
const chrome = mobileChrome || {};
const chatVisible = vp.isMobile ? (mobileView === "chat" && !!active) : !!active;
const STATUS_LABEL = {
ai: "ИИ", queue: "Очередь", in_progress: "В работе", waiting: "Ожидание", closed: "Закрыт",
};
// Пока тикет в работе у одного оператора, второй такого же уровня его только
// читает: двое, пишущих клиенту разное, хуже любой задержки с ответом.
// Забрать тикет можно кнопкой «Запросить» — владелец передаёт его сам.
// Админ не ограничен: иначе разруливать зависшие тикеты было бы некому.
const lockedFor = (c) =>
currentOperator?.role !== "admin" && !!c?.assignedOperator
&& c.assignedOperator !== currentOperator?.name
&& ["in_progress", "waiting"].includes(c.status);
const locked = lockedFor(active);
const myClaimPending = locked && active?.claimRequestedBy === currentOperator?.name;
// Обратная сторона: у меня просят мой тикет.
const claimOnMine = !!active?.claimRequestedBy
&& (active?.assignedOperator === currentOperator?.name || currentOperator?.role === "admin")
&& active?.claimRequestedBy !== currentOperator?.name;
async function requestClaim() {
if (!active) return;
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/claim`);
showToast(`Запрос отправлен: ${active.assignedOperator}`);
} catch (e) { showToast(e?.detail || "Не удалось отправить запрос"); }
}
async function answerClaim(approve) {
if (!active) return;
try {
await window.apiFetch("POST", `/api/dialogs/${active.id}/claim/${approve ? "approve" : "decline"}`);
showToast(approve ? `Тикет передан: ${active.claimRequestedBy}` : "Запрос отклонён");
} catch (e) { showToast(e?.detail || "Не удалось ответить на запрос"); }
}
// Нижняя навигация уступает место переписке и клавиатуре.
useEffectD(() => {
if (onMobileChatOpen) onMobileChatOpen(!!(vp.isMobile && mobileView === "chat" && active));
}, [vp.isMobile, mobileView, active?.id, onMobileChatOpen]);
function openDialog(id) {
setActiveId(id);
setMobileView("chat");
}
function copyDialogLink() {
if (!active) return;
const url = `${window.location.origin}${window.location.pathname}?dialog=${active.id}`;
navigator.clipboard.writeText(url)
.then(() => showToast("Ссылка скопирована"))
.catch(() => showToast("Не удалось скопировать ссылку"));
}
// Действия над тикетом: на десктопе кнопками в шапке, на узких — шторкой.
const ticketActions = !active ? [] : locked ? [
// Чужой тикет: только попросить его и скопировать ссылку.
...(myClaimPending
? [] : [{ icon: "handRaise", label: "Запросить тикет", run: requestClaim }]),
{ icon: "link", label: "Скопировать ссылку", run: copyDialogLink },
] : [
...(["ai", "queue"].includes(active.status)
? [{ icon: "user", label: "Взять в работу", run: handoffToOperator }] : []),
...(["in_progress", "waiting"].includes(active.status)
? [{ icon: "arrowLeft", label: "Вернуть в очередь", run: reopenDialog }] : []),
...(active.status === "in_progress"
? [{ icon: "clock", label: "В ожидание", run: waitDialog }] : []),
...(active.status !== "closed"
? [{ icon: "arrowRight", label: "Передать оператору", run: () => setShowTransfer(true) }] : []),
...(folders.length > 0
? [{ icon: "grid", label: active.folderId ? "Сменить папку" : "Положить в папку",
run: () => setShowFolderPick(true) }] : []),
{ icon: "link", label: "Скопировать ссылку", run: copyDialogLink },
...(active.status !== "closed"
? [{ icon: "x", label: "Закрыть диалог", danger: true, run: () => setConfirmClose(true) }] : []),
];
const searchField = (
{searchField}
{/* Ряд разделов прокручивается вправо: статусных пять, а папок сколько
угодно, и «ещё»-меню, в которое раньше прятались ИИ и Закрытые,
столько уже не вместит. */}
);
// Шапка переписки на десктопе. Раньше сюда выкладывались все действия сразу,
// и ряд кнопок вытеснял имя клиента за границы шапки уже на 1440px — центр
// это ширина окна минус две боковые колонки, её всегда меньше, чем кажется.
// Теперь на виду главное действие и контекст (владелец, замок, папка),
// остальное — в меню «⋯»; список тот же ticketActions, что и в шторке на
// узких экранах.
const primaryAction = !active ? null
: locked ? "claim"
: ["ai", "queue"].includes(active.status) ? "take"
: null;
const menuActions = ticketActions.filter((a) =>
!(primaryAction === "take" && a.label === "Взять в работу")
&& !(primaryAction === "claim" && a.label === "Запросить тикет"));
const chatTopBar = active && (
);
// Шапка переписки на телефоне и планшете: «назад», имя, статус и две кнопки —
// карточка клиента и меню действий.
const chatTopBarCompact = active && (
setMobileView("list") : null}
leading={}
right={
<>
setShowClientSheet(true)} />
setShowActionsSheet(true)} />
>
}
/>
);
// Владелец видит просьбу прямо над перепиской — иначе она утонет в системных
// сообщениях, а второй оператор будет ждать впустую.
const claimBanner = active && claimOnMine && (
{active.claimRequestedBy} просит передать этот тикет
);
}
// ── Карточка клиента ────────────────────────────────────────────────────────
// Данные и список доступных действий приходят из /api/dialogs/{id}/customer:
// какой источник за ними стоит, панель не знает (см. app/customer.py). Формы
// действий строятся по описанию полей, поэтому новое действие появляется в
// интерфейсе само, без правки этого файла.
function TrafficBar({ used, total }) {
// Безлимит — тоже с подписью: иначе число висит без контекста, в отличие от
// остальных строк карточки.
if (!total) {
return (
>
);
}
function InfoRow({ label, children }) {
return (
{label}{children}
);
}
const TRIAL_LABEL = { none: "не использован", active: "идёт сейчас", used: "использован" };
// Модалка на десктопе, шторка на узком экране — форма одна и та же.
function ActionShell({ open, onClose, title, subtitle, compact, children }) {
if (compact) {
return (
{children}
);
}
if (!open) return null;
return (
{title}
{subtitle &&
{subtitle}
}
{children}
);
}
// Форма действия по описанию полей из ACTIONS (app/customer.py).
function ActionForm({ spec, options, keys, devices, devicesKeyId, preset, busy, onSubmit, onCancel }) {
// Устройства источник отдаёт не по клиенту целиком, а по одному ключу. Значит
// в форме с выбором устройства ключ не выбирается: он тот, чьи устройства в
// списке. Иначе оператор снял бы устройство с ключа, которому оно не
// принадлежит, и запрос ушёл бы с несуществующей парой ключ+устройство.
const deviceKey = spec.fields.some((f) => f.type === "device") ? (devicesKeyId || "") : "";
const initial = {};
for (const f of spec.fields) {
initial[f.name] = deviceKey && f.type === "key"
? deviceKey
: preset && preset[f.name] !== undefined
? preset[f.name]
: (f.default !== null && f.default !== undefined ? f.default : "");
}
const [values, setValues] = useStateD(initial);
const set = (name, v) => setValues((prev) => ({ ...prev, [name]: v }));
const input = "w-full bg-[#0d0d12] border border-[#2a2a3a] rounded-lg px-3 py-2.5 text-sm text-[#f1f1f5] placeholder:text-[#6b7280] focus:outline-none focus:border-[#4F8EF7]/50";
return (
);
}
function ActionButton({ spec, onClick, small = false }) {
return (
);
}
function KeyCard({ item, actions, onAction }) {
const expired = item.expiresAt && new Date(item.expiresAt) < new Date();
return (
{item.name}
{item.expiresAt || "—"}
{[["Сервер", item.server || "—"], ["Тариф", item.plan || "—"],
// null — источник не считал устройства по этому ключу; ноль значит ноль
["Устройств", item.devices ?? "—"]].map(([l, v]) => (
⚠{data.message || (data.isMock ? "Тестовые данные (мок)" : "Данные могут устареть")}
)}
{/* Вкладки. scrollbar-thin, а не no-scrollbar: панель узкая (320px), и
когда вкладки не помещаются, скрытый скроллбар не давал понять, что
часть вкладок обрезана, а не просто отсутствует. */}