Files

7457 lines
270 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
user: 'admin',
role: 'admin',
token: null,
authSource: 'local',
fullName: null,
features: {
whatsapp: false,
},
logLines: [],
interactions: [],
oidc: {
enabled: false,
loginPath: '/auth/oidc/start?return_mode=popup',
providerLabel: 'Keycloak',
},
liveCalls: {
items: [],
recentItems: [],
selectedCallId: '',
pollTimer: null,
pendingAction: '',
aiSummaries: {},
aiSummaryPending: {},
nameEditor: {
open: false,
mode: 'panel',
callId: '',
customerId: '',
draft: '',
saving: false,
error: '',
},
},
customers: {
items: [],
page: 1,
pageSize: 7,
selectedCustomerId: '',
leadFormOpen: false,
historyById: {},
pendingHistoryById: {},
historyErrorsById: {},
nameEditor: {
open: false,
customerId: '',
draft: '',
saving: false,
error: '',
flash: '',
},
},
telegram: {
threads: [],
selectedThreadId: '',
messages: [],
selectedThreadSummary: null,
searchQuery: '',
messageSearchQuery: '',
messageSearchOpen: false,
operatorTrayOpen: false,
pendingAction: '',
pollTimer: null,
},
messenger: {
conversations: [],
selectedConversationId: '',
messages: [],
selectedSummary: null,
activeFilter: 'all',
composerText: '',
backendError: '',
pendingAction: '',
pollTimer: null,
},
whatsapp: {
chats: [],
selectedChatId: '',
searchQuery: '',
activeFilter: 'all',
composerText: '',
selectedThreadSummary: null,
pendingAction: '',
pollTimer: null,
mode: 'idle',
backendError: '',
mockFallbackLogged: false,
},
browserPhone: {
config: null,
ua: null,
registerer: null,
session: null,
status: 'Не настроен',
connected: false,
connecting: false,
incoming: false,
muted: false,
micDeviceId: '',
speakerDeviceId: '',
autoClaimCallId: '',
popupCallId: '',
endingCallId: '',
callPhase: '',
autoClaimInFlight: false,
warning: '',
settingsOpen: false,
sessionStartedAt: '',
localStream: null,
localStreamPromise: null,
ringtoneContext: null,
ringtoneTimer: null,
ringtoneActive: false,
},
};
const $ = (id) => document.getElementById(id);
const SESSION_STORAGE_KEY = 'cc_session';
const DEMO_ASSIGNEE = 'operator_a';
const DEMO_QUEUE = 'line2';
const ROLE_LABELS = {
admin: 'Администратор',
supervisor: 'Супервизор',
operator: 'Оператор',
analyst: 'Аналитик',
};
const STATUS_META = {
new: { label: 'новое', className: 'status-new' },
in_progress: { label: 'назначено', className: 'status-in-progress' },
escalated: { label: 'эскалировано', className: 'status-escalated' },
closed: { label: 'закрыто', className: 'status-closed' },
abandoned: { label: 'потеряно', className: 'status-closed' },
};
const CHANNEL_LABELS = {
voice: 'Голос',
telegram: 'Telegram',
whatsapp: 'WhatsApp',
webchat: 'Webchat',
email: 'Email',
};
const TELEGRAM_AVATAR_PALETTES = [
['#5bc7ff', '#288cff'],
['#7a8cff', '#5664f5'],
['#52d6b5', '#0f9f8a'],
['#ffa96e', '#f57b3d'],
['#ff88b6', '#f34b86'],
['#8a7dff', '#5d58ea'],
];
const WHATSAPP_AVATAR_PALETTES = [
['#2dd4bf', '#0f766e'],
['#60a5fa', '#1d4ed8'],
['#f59e0b', '#c2410c'],
['#a78bfa', '#6d28d9'],
['#f472b6', '#be185d'],
['#34d399', '#047857'],
['#fb7185', '#be123c'],
];
const WHATSAPP_MOCK_CHATS = [
{
id: 'wa-amy-chen',
title: 'Amy Chen',
handle: '@amyc',
statusLine: 'в сети',
isGroup: false,
unreadCount: 0,
pinned: true,
muted: false,
lastMessageAt: '2026-03-10T18:43:00+05:00',
lastMessagePreview: 'Отлично, пришлите образцы и счёт.',
messages: [
{
id: 'wa-amy-1',
direction: 'in',
text: 'Здравствуйте! В обновлённое предложение вошли матовые бутылки?',
createdAt: '2026-03-10T18:15:00+05:00',
},
{
id: 'wa-amy-2',
direction: 'out',
text: 'Да, я добавил и матовое покрытие, и вариант с индивидуальной этикеткой.',
createdAt: '2026-03-10T18:18:00+05:00',
deliveryStatus: 'read',
},
{
id: 'wa-amy-3',
direction: 'in',
text: 'Отлично, пришлите образцы и счёт.',
createdAt: '2026-03-10T18:43:00+05:00',
},
],
},
{
id: 'wa-design-ops',
title: 'Design Ops',
handle: '6 participants',
statusLine: 'Анна, Тимур, Лейла и ещё 3 участника',
isGroup: true,
unreadCount: 12,
pinned: true,
muted: true,
lastMessageAt: '2026-03-10T17:58:00+05:00',
lastMessagePreview: 'Лейла: hero-карточки согласованы, остались только иконки.',
messages: [
{
id: 'wa-design-1',
direction: 'system',
text: 'Today',
createdAt: '2026-03-10T09:00:00+05:00',
},
{
id: 'wa-design-2',
direction: 'in',
author: 'Timur',
text: 'Залил обновлённые отступы сайдбара и тёмные токены.',
createdAt: '2026-03-10T17:28:00+05:00',
},
{
id: 'wa-design-3',
direction: 'out',
text: 'Отлично. Оставьте rail компактным, а действия с чатом перенесите в header.',
createdAt: '2026-03-10T17:35:00+05:00',
deliveryStatus: 'read',
},
{
id: 'wa-design-4',
direction: 'in',
author: 'Leila',
text: 'Hero-карточки согласованы, остались только иконки.',
createdAt: '2026-03-10T17:58:00+05:00',
},
],
},
{
id: 'wa-marat-logistics',
title: 'Marat Logistics',
handle: '+7 707 555 2001',
statusLine: 'был в сети сегодня в 16:42',
isGroup: false,
unreadCount: 0,
pinned: false,
muted: false,
lastMessageAt: '2026-03-10T16:41:00+05:00',
lastMessagePreview: 'Грузовик будет у ворот склада через 20 минут.',
messages: [
{
id: 'wa-marat-1',
direction: 'in',
text: 'Грузовик будет у ворот склада через 20 минут.',
createdAt: '2026-03-10T16:41:00+05:00',
},
{
id: 'wa-marat-2',
direction: 'out',
text: 'Принято. На пост охраны уже передали обновлённую накладную.',
createdAt: '2026-03-10T16:44:00+05:00',
deliveryStatus: 'delivered',
},
],
},
{
id: 'wa-leila',
title: 'Leila',
handle: '@leila.studio',
statusLine: 'печатает...',
isGroup: false,
unreadCount: 1,
pinned: false,
muted: false,
lastMessageAt: '2026-03-10T15:26:00+05:00',
lastMessagePreview: 'Можно перенести ревью на 18:30?',
messages: [
{
id: 'wa-leila-1',
direction: 'out',
text: 'Могу быстро посмотреть в 18:00, если так удобнее.',
createdAt: '2026-03-10T15:20:00+05:00',
deliveryStatus: 'read',
},
{
id: 'wa-leila-2',
direction: 'in',
text: 'Можно перенести ревью на 18:30?',
createdAt: '2026-03-10T15:26:00+05:00',
},
],
},
{
id: 'wa-family-trip',
title: 'Family Trip 2026',
handle: '8 participants',
statusLine: 'Закреплённая группа',
isGroup: true,
unreadCount: 0,
pinned: false,
muted: false,
lastMessageAt: '2026-03-10T13:08:00+05:00',
lastMessagePreview: 'Арман: я забронировал отель на пятницу.',
messages: [
{
id: 'wa-family-1',
direction: 'in',
author: 'Arman',
text: 'Я забронировал отель на пятницу.',
createdAt: '2026-03-10T13:08:00+05:00',
},
{
id: 'wa-family-2',
direction: 'out',
text: 'Отлично. Позже отправлю сюда маршрут и детали заселения.',
createdAt: '2026-03-10T13:11:00+05:00',
deliveryStatus: 'read',
},
],
},
{
id: 'wa-studio-intake',
title: 'Studio Intake',
handle: '+7 701 222 8811',
statusLine: 'был в сети вчера в 22:10',
isGroup: false,
unreadCount: 4,
pinned: false,
muted: true,
lastMessageAt: '2026-03-09T22:08:00+05:00',
lastMessagePreview: 'Можете ещё раз прислать референсы по упаковке?',
messages: [
{
id: 'wa-studio-1',
direction: 'in',
text: 'Можете ещё раз прислать референсы по упаковке?',
createdAt: '2026-03-09T22:08:00+05:00',
},
{
id: 'wa-studio-2',
direction: 'out',
text: 'Да, сейчас отправлю свежий PDF и подборку образцов.',
createdAt: '2026-03-09T22:12:00+05:00',
deliveryStatus: 'read',
},
],
},
{
id: 'wa-oleg-voice',
title: 'Oleg Voice',
handle: '+7 705 123 4490',
statusLine: 'был в сети вчера в 20:31',
isGroup: false,
unreadCount: 0,
pinned: false,
muted: false,
lastMessageAt: '2026-03-09T19:18:00+05:00',
lastMessagePreview: 'Спасибо, заметки для handoff хватило, чтобы продолжить.',
messages: [
{
id: 'wa-oleg-1',
direction: 'out',
text: 'Оставил handoff-сводку в карточке задачи и отправил заметки по звонку.',
createdAt: '2026-03-09T19:08:00+05:00',
deliveryStatus: 'read',
},
{
id: 'wa-oleg-2',
direction: 'in',
text: 'Спасибо, заметки для handoff хватило, чтобы продолжить.',
createdAt: '2026-03-09T19:18:00+05:00',
},
],
},
];
const BOARD_COLUMNS = [
{ key: 'new', title: 'Новые' },
{ key: 'in_progress', title: 'В работе' },
{ key: 'escalated', title: '2 линия' },
{ key: 'closed', title: 'Закрытые' },
];
const UNIFIED_INBOX_COLUMNS = [
{ key: 'new', title: 'Новые', emptyMessage: 'Новых задач сейчас нет.' },
{ key: 'mine', title: 'Мои', emptyMessage: 'За вами пока ничего не закреплено.' },
{ key: 'ai', title: 'AI handoff', emptyMessage: 'Передач от AI сейчас нет.' },
{ key: 'escalated', title: 'Эскалации', emptyMessage: 'Эскалаций сейчас нет.' },
{ key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' },
];
const MESSENGER_FILTERS = {
all: 'Все',
new: 'Новые',
mine: 'Мои',
ai: 'AI handoff',
};
const LIVE_TELEPHONY_LABELS = {
ringing: 'Звонит',
claimed: 'Взято',
connected: 'Соединено',
ended: 'Завершено',
failed: 'Ошибка',
};
const VOICE_AI_STATE_META = {
greeting: { label: 'AI активен', className: 'ai-active' },
listening: { label: 'AI активен', className: 'ai-active' },
thinking: { label: 'AI думает', className: 'ai-thinking' },
speaking: { label: 'AI активен', className: 'ai-active' },
active: { label: 'AI активен', className: 'ai-active' },
handoff_requested: { label: 'Ждёт человека', className: 'ai-handoff' },
handoff_required: { label: 'Ждёт человека', className: 'ai-handoff' },
human_owned: { label: 'У оператора', className: 'ai-human' },
error: { label: 'Ошибка AI', className: 'ai-error' },
closed: { label: 'AI завершён', className: 'ai-muted' },
};
const OPERATOR_VIEW_IDS = {
workspace: 'workspaceView',
messages: 'messagesView',
customers: 'customersView',
'customer-profile': 'customerProfileView',
telegram: 'telegramView',
whatsapp: 'whatsappView',
calls: 'callsView',
};
function featureEnabled(feature) {
return Boolean(state.features?.[feature]);
}
function isOperatorViewEnabled(view) {
if (view === 'whatsapp') {
return featureEnabled('whatsapp');
}
return true;
}
function stopWhatsappPolling() {
if (state.whatsapp.pollTimer) {
window.clearInterval(state.whatsapp.pollTimer);
state.whatsapp.pollTimer = null;
}
}
function resetWhatsappState(mode = 'idle') {
state.whatsapp.chats = [];
state.whatsapp.selectedChatId = '';
state.whatsapp.searchQuery = '';
state.whatsapp.activeFilter = 'all';
state.whatsapp.composerText = '';
state.whatsapp.selectedThreadSummary = null;
state.whatsapp.pendingAction = '';
state.whatsapp.backendError = '';
state.whatsapp.mockFallbackLogged = false;
state.whatsapp.mode = mode;
}
function syncWhatsappFeatureVisibility() {
const enabled = featureEnabled('whatsapp');
document.querySelectorAll('[data-feature="whatsapp"]').forEach((element) => {
element.hidden = !enabled;
});
document.querySelectorAll('[data-feature-option="whatsapp"]').forEach((element) => {
element.hidden = !enabled;
element.disabled = !enabled;
});
if (!enabled) {
stopWhatsappPolling();
resetWhatsappState('disabled');
if ($('interactionChannel')?.value === 'whatsapp') {
$('interactionChannel').value = 'voice';
}
return;
}
if (state.whatsapp.mode === 'disabled') {
resetWhatsappState('idle');
}
}
function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'admin';
state.role = $('sessionRole').value || 'admin';
}
function destinationForRole(role) {
if (role === 'admin') {
return '/admin';
}
if (role === 'supervisor') {
return '/supervisor';
}
if (role === 'analyst') {
return '/analyst';
}
return '/operator';
}
function applyRoleNavigation() {
document.querySelectorAll('[data-roles]').forEach((link) => {
const allowed = (link.dataset.roles || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none';
});
document.querySelectorAll('.nav-group').forEach((group) => {
const shellLinks = [...group.querySelectorAll('[data-shell]')];
if (!shellLinks.length) {
group.style.display = '';
return;
}
const hasVisibleShellLink = shellLinks.some((link) => link.style.display !== 'none');
group.style.display = hasVisibleShellLink ? '' : 'none';
});
}
function persistSession() {
window.localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
access_token: state.token,
user: state.user,
role: state.role,
auth_source: state.authSource,
full_name: state.fullName,
}),
);
}
function clearStoredSession() {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
function restoreStoredSession() {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
if (!payload?.access_token) {
clearStoredSession();
return false;
}
state.token = payload.access_token;
state.user = payload.user || state.user;
state.role = payload.role || state.role;
state.authSource = payload.auth_source || 'local';
state.fullName = payload.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = state.role;
if ($('authPanel')) {
$('authPanel').style.display = 'none';
}
updateProfileMeta();
applyRoleNavigation();
return true;
} catch {
clearStoredSession();
return false;
}
}
function logout() {
if (state.liveCalls.pollTimer) {
window.clearInterval(state.liveCalls.pollTimer);
state.liveCalls.pollTimer = null;
}
if (state.telegram.pollTimer) {
window.clearInterval(state.telegram.pollTimer);
state.telegram.pollTimer = null;
}
stopMessengerPolling();
stopWhatsappPolling();
clearStoredSession();
window.location.href = '/';
}
function shortJson(payload) {
if (!payload || typeof payload !== 'object') {
return '';
}
const parts = Object.entries(payload)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.slice(0, 3)
.map(([key, value]) => {
const printable = typeof value === 'object' ? JSON.stringify(value) : value;
return `${key}=${printable}`;
});
return parts.join(', ');
}
function log(message, payload) {
const ts = new Date().toLocaleTimeString();
const suffix = shortJson(payload);
const line = suffix ? `[${ts}] ${message} (${suffix})` : `[${ts}] ${message}`;
state.logLines.unshift(line);
state.logLines = state.logLines.slice(0, 14);
const target = $('eventLog');
if (target) {
target.textContent = state.logLines.join('\n');
}
}
function setOutput(targetId, payload) {
$(targetId).textContent = JSON.stringify(payload, null, 2);
}
function setEmptyList(targetId, message) {
$(targetId).innerHTML = `<li class="empty-state">${message}</li>`;
}
function setEmptyBlock(targetId, message) {
$(targetId).innerHTML = `<div class="empty-state">${message}</div>`;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeRegExp(value) {
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function customerHashSeed(item, fallback = 0) {
const raw = `${item?.customer_id || ''}|${item?.display_name || ''}|${(item?.phones || []).join(',') || ''}`;
return [...raw].reduce((acc, char, index) => (acc + char.charCodeAt(0) * (index + 1)) % 997, fallback);
}
function normalizedCustomerTags(item) {
return Array.isArray(item?.tags)
? item.tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean)
: [];
}
function isFacebookCustomer(item) {
const tags = normalizedCustomerTags(item);
return tags.includes('facebook') || tags.includes('facebook-lead');
}
function isFacebookLeadInteraction(item) {
const subject = String(item?.subject || item?.title || '').trim().toLowerCase();
if (subject.startsWith('[facebook]')) {
return true;
}
const customerId = item?.customer_id || item?.customerId || '';
if (!customerId) {
return false;
}
const customer = state.customers.items.find((entry) => entry.customer_id === customerId) || null;
return isFacebookCustomer(customer);
}
function customerInitials(name) {
const parts = String(name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) {
return 'LD';
}
return parts.map((part) => part[0].toUpperCase()).join('');
}
function customerLeadSourceMeta(item, index) {
if (isFacebookCustomer(item)) {
return { label: 'Facebook', className: 'lead-badge source-facebook' };
}
const variants = [
{ label: 'Instagram', className: 'lead-badge source-instagram' },
{ label: 'WhatsApp', className: 'lead-badge source-whatsapp' },
{ label: 'Site', className: 'lead-badge source-site' },
{ label: 'Referral', className: 'lead-badge source-referral' },
];
return variants[customerHashSeed(item, index) % variants.length];
}
function customerLeadStatusMeta(item, index) {
if (isFacebookCustomer(item)) {
return { label: 'Новый', className: 'lead-badge status-new' };
}
const variants = [
{ label: 'Переговоры', className: 'lead-badge status-negotiation' },
{ label: 'Квалификация', className: 'lead-badge status-qualification' },
{ label: 'Предложение', className: 'lead-badge status-proposal' },
{ label: 'Первичный контакт', className: 'lead-badge status-initial' },
{ label: 'Успех', className: 'lead-badge status-success' },
{ label: 'Новый', className: 'lead-badge status-new' },
{ label: 'Отказ', className: 'lead-badge status-lost' },
];
return variants[customerHashSeed(item, index + 17) % variants.length];
}
function customerLeadScore(item, index) {
return 12 + (customerHashSeed(item, index + 29) % 81);
}
function customerLeadScoreClass(score) {
if (score >= 80) {
return 'lead-score score-strong';
}
if (score >= 50) {
return 'lead-score score-medium';
}
return 'lead-score score-weak';
}
function customerLeadCompany(item) {
const tags = (Array.isArray(item?.tags) ? item.tags : [])
.map((tag) => String(tag || '').trim())
.filter((tag) => tag && !['facebook', 'facebook-lead', 'hot-lead'].includes(tag.toLowerCase()));
if (tags.length) {
return tags[0];
}
const suffixes = ['Tech', 'Group', 'Retail', 'Logistics', 'Studio', 'Services'];
const seed = customerHashSeed(item, 41);
return `${String(item?.display_name || 'Lead').split(' ')[0]} ${suffixes[seed % suffixes.length]}`;
}
function customerLeadCreatedAtLabel(item, index) {
if (item?.created_at) {
return formatIsoShort(item.created_at);
}
const seed = customerHashSeed(item, index + 59);
const shifted = new Date(Date.now() - ((seed % 9) * 86400000 + (seed % 18) * 3600000));
return shifted.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' });
}
function customerLeadRows() {
const start = (state.customers.page - 1) * state.customers.pageSize;
return state.customers.items.slice(start, start + state.customers.pageSize);
}
function selectedCustomer() {
return state.customers.items.find((item) => item.customer_id === state.customers.selectedCustomerId) || state.customers.items[0] || null;
}
function customerProfileHash(customerId = state.customers.selectedCustomerId) {
return customerId ? `#customer-profile/${encodeURIComponent(customerId)}` : '#customer-profile';
}
function openCustomerProfile(customerId) {
selectCustomer(customerId || '');
ensureCustomerHistoryLoaded(customerId || state.customers.selectedCustomerId, { force: true }).catch(() => {});
window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId);
}
function interactionById(interactionId) {
return state.interactions.find((item) => item.interaction_id === interactionId) || null;
}
function customerIdForInteractionId(interactionId) {
return interactionById(interactionId)?.customer_id || '';
}
function customerDisplayName(customerId) {
if (!customerId) {
return 'не привязан';
}
const customer = state.customers.items.find((item) => item.customer_id === customerId) || null;
return customer?.display_name || customerId;
}
function customerIndex(customer) {
if (!customer) {
return -1;
}
return state.customers.items.findIndex((item) => item.customer_id === customer.customer_id);
}
function customerPhones(customer) {
const phones = [];
if (Array.isArray(customer?.phones)) {
phones.push(...customer.phones);
}
if (customer?.phone) {
phones.push(customer.phone);
}
if (customer?.preferred_phone) {
phones.unshift(customer.preferred_phone);
}
return [...new Set(phones.map((value) => String(value || '').trim()).filter(Boolean))];
}
function customerInteractions(customer) {
if (!customer) {
return [];
}
return state.interactions.filter((item) => item.customer_id === customer.customer_id);
}
function customerInteractionIds(customer) {
return new Set(customerInteractions(customer).map((item) => item.interaction_id).filter(Boolean));
}
function customerTelegramThreads(customer) {
const interactionIds = customerInteractionIds(customer);
return state.telegram.threads.filter((thread) => interactionIds.has(thread.interaction_id));
}
function customerLiveCalls(customer) {
const interactionIds = customerInteractionIds(customer);
const phones = new Set(customerPhones(customer));
return uniqueLiveCalls([...state.liveCalls.items, ...state.liveCalls.recentItems]).filter((item) => {
if (interactionIds.has(item.interaction_id)) {
return true;
}
return phones.has(String(item.caller_number || '').trim());
});
}
function customerHistoryPayload(customerId) {
if (!customerId) {
return null;
}
return state.customers.historyById[customerId] || null;
}
function customerHistoryPending(customerId) {
return Boolean(customerId && state.customers.pendingHistoryById[customerId]);
}
async function ensureCustomerHistoryLoaded(customerId, options = {}) {
const { force = false } = options;
if (!customerId) {
return null;
}
if (customerHistoryPending(customerId)) {
return customerHistoryPayload(customerId);
}
if (!force && customerHistoryPayload(customerId)) {
return customerHistoryPayload(customerId);
}
state.customers.pendingHistoryById[customerId] = true;
delete state.customers.historyErrorsById[customerId];
try {
const data = await api('customer', `customers/${encodeURIComponent(customerId)}/history`);
state.customers.historyById[customerId] = data || null;
return state.customers.historyById[customerId];
} catch (err) {
state.customers.historyErrorsById[customerId] = err.message;
return null;
} finally {
delete state.customers.pendingHistoryById[customerId];
renderCustomerProfilePage();
}
}
function customerPreferredChannel(customer) {
const counters = new Map();
customerInteractions(customer).forEach((item) => {
const key = item.channel || 'voice';
counters.set(key, (counters.get(key) || 0) + 1);
});
if (customerTelegramThreads(customer).length) {
counters.set('telegram', (counters.get('telegram') || 0) + customerTelegramThreads(customer).length);
}
if (customerLiveCalls(customer).length) {
counters.set('voice', (counters.get('voice') || 0) + customerLiveCalls(customer).length);
}
const [winner] = [...counters.entries()].sort((left, right) => right[1] - left[1]);
return winner?.[0] || 'voice';
}
function customerHistoryTimeValue(value) {
if (!value) {
return 0;
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return 0;
}
return dt.getTime();
}
function customerHistoryMeta(kind) {
if (kind === 'voice') {
return { token: 'VC', className: 'voice' };
}
if (kind === 'telegram') {
return { token: 'TG', className: 'telegram' };
}
if (kind === 'email') {
return { token: 'EM', className: 'email' };
}
if (kind === 'webchat') {
return { token: 'WC', className: 'webchat' };
}
if (kind === 'profile') {
return { token: 'ID', className: 'profile' };
}
return { token: 'CS', className: 'case' };
}
function buildCustomerHistoryEvents(customer) {
if (!customer) {
return [];
}
const company = customerLeadCompany(customer);
const phones = customerPhones(customer);
const interactions = customerInteractions(customer);
const interactionIds = new Set(interactions.map((item) => item.interaction_id).filter(Boolean));
const threads = customerTelegramThreads(customer);
const liveCalls = customerLiveCalls(customer);
const events = [];
events.push({
timestamp: customer.created_at || '',
kind: 'profile',
title: 'Профиль клиента активен',
body: phones.length
? `Контакт доступен для оператора по номеру ${phones[0]}.`
: 'Контакт добавлен в клиентскую базу и готов к работе.',
note: [customer.customer_id, company].filter(Boolean).join(' • '),
});
interactions.forEach((item) => {
const meta = statusMeta(item.status);
events.push({
timestamp: item.updated_at || item.created_at || '',
kind: item.channel || 'case',
title: `${channelLabel(item.channel)}${meta.label}`,
body: item.subject || 'Обращение без темы',
note: [
item.interaction_id,
item.assigned_to ? `владелец ${item.assigned_to}` : '',
item.queue_id ? `queue ${item.queue_id}` : '',
].filter(Boolean).join(' • '),
interaction_id: item.interaction_id,
});
});
threads.forEach((thread) => {
events.push({
timestamp: thread.last_message_at || '',
kind: 'telegram',
title: 'Telegram диалог',
body: thread.last_message_preview || 'Последнее сообщение недоступно.',
note: [
thread.display_name || thread.username || `chat ${thread.chat_id}`,
thread.claimed_by_user ? `владелец ${thread.claimed_by_user}` : '',
thread.status ? statusMeta(thread.status).label : '',
].filter(Boolean).join(' • '),
interaction_id: thread.interaction_id,
thread_id: thread.thread_id,
});
});
const activeThread = selectedTelegramThread();
if (activeThread && interactionIds.has(activeThread.interaction_id) && state.telegram.messages.length) {
state.telegram.messages.slice(-3).forEach((message) => {
const direction = message.direction === 'outbound' ? 'Исходящее сообщение' : 'Входящее сообщение';
events.push({
timestamp: message.created_at || '',
kind: 'telegram',
title: direction,
body: message.text || 'Сообщение без текста',
note: [
activeThread.display_name || activeThread.username || `chat ${activeThread.chat_id}`,
message.operator_user ? `оператор ${message.operator_user}` : '',
message.delivery_status || '',
].filter(Boolean).join(' • '),
interaction_id: activeThread.interaction_id,
thread_id: activeThread.thread_id,
});
});
}
liveCalls.forEach((item) => {
const completed = item.telephony_status === 'ended' || item.ended_at;
events.push({
timestamp: item.last_transition_at || item.ended_at || item.connected_at || item.started_at || '',
kind: 'voice',
title: completed ? `Звонок • ${terminalActionLabel(item)}` : `Звонок • ${telephonyLabel(item.telephony_status)}`,
body: `Номер клиента: ${item.caller_number || 'не определён'}`,
note: [
item.call_id,
item.operator_extension ? `внутр. ${item.operator_extension}` : '',
item.claimed_by_user ? `владелец ${item.claimed_by_user}` : '',
].filter(Boolean).join(' • '),
interaction_id: item.interaction_id,
call_id: item.call_id,
});
const aiMeta = voiceAiStatusMeta(item);
if (aiMeta) {
events.push({
timestamp: item.ai_last_model_at || item.last_transition_at || item.updated_at || '',
kind: 'voice',
title: `Голосовой AI • ${aiMeta.label}`,
body: item.ai_handoff_reason || 'AI участвовал в обработке звонка и обновил контекст для оператора.',
note: [
item.call_id,
item.voice_session_id ? `голос ${item.voice_session_id}` : '',
item.ai_session_id ? `ai ${item.ai_session_id}` : '',
].filter(Boolean).join(' • '),
interaction_id: item.interaction_id,
call_id: item.call_id,
});
}
});
return events
.sort((left, right) => {
const timeDelta = customerHistoryTimeValue(right.timestamp) - customerHistoryTimeValue(left.timestamp);
if (timeDelta !== 0) {
return timeDelta;
}
return String(right.note || '').localeCompare(String(left.note || ''));
})
.slice(0, 9);
}
function latestItemByTime(items, fields = []) {
const source = Array.isArray(items) ? items.filter(Boolean) : [];
if (!source.length) {
return null;
}
return [...source].sort((left, right) => {
const rightTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(right?.[field])), 0);
const leftTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(left?.[field])), 0);
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return String(right?.interaction_id || right?.thread_id || right?.call_id || '').localeCompare(
String(left?.interaction_id || left?.thread_id || left?.call_id || ''),
);
})[0] || null;
}
function customerHistoryEventActions(event, context = {}) {
const threadIds = context.threadIds instanceof Set ? context.threadIds : new Set();
const callIds = context.callIds instanceof Set ? context.callIds : new Set();
const actions = [];
if (event?.thread_id && threadIds.has(event.thread_id)) {
actions.push({
action: 'telegram',
label: 'К Telegram',
threadId: event.thread_id,
});
}
if (event?.call_id && callIds.has(event.call_id)) {
actions.push({
action: 'call',
label: 'К звонку',
callId: event.call_id,
});
}
if (event?.interaction_id) {
actions.push({
action: 'interaction',
label: 'К кейсу',
interactionId: event.interaction_id,
});
}
return actions;
}
function renderCustomerHistoryActions(event, context = {}) {
const actions = customerHistoryEventActions(event, context);
if (!actions.length) {
return '';
}
return `
<div class="customer-history-actions">
${actions.map((action) => `
<button
class="btn ghost customer-history-link"
type="button"
data-customer-action="${escapeHtml(action.action)}"
data-thread-id="${escapeHtml(action.threadId || '')}"
data-call-id="${escapeHtml(action.callId || '')}"
data-interaction-id="${escapeHtml(action.interactionId || '')}"
>${escapeHtml(action.label)}</button>
`).join('')}
</div>
`;
}
function emptyCustomerSpotlightMarkup(title, description) {
return `
<div class="customer-spotlight-empty">
<strong>${escapeHtml(title)}</strong>
<p>${escapeHtml(description)}</p>
</div>
`;
}
function customerProfileVoiceNameContext(liveCalls = []) {
const latestCall = latestItemByTime(liveCalls, ['last_transition_at', 'ended_at', 'connected_at', 'started_at', 'updated_at']);
if (!latestCall) {
return {
statusMeta: null,
sourceLabel: '',
};
}
const voiceContext = voiceSummaryForItem(latestCall) || latestCall || {};
return {
statusMeta: voiceCustomerNameStatusMeta(voiceContext?.customer_name_status),
sourceLabel: formatVoiceCustomerNameSource(voiceContext?.customer_name_source),
};
}
function customerProfileNameEditorMarkup(customer, context = {}) {
const editor = state.customers.nameEditor;
const isOpen = editor.open && editor.customerId === customer.customer_id;
const statusMeta = context.statusMeta || null;
const sourceLabel = String(context.sourceLabel || '').trim();
const flash = editor.flash && editor.customerId === customer.customer_id ? editor.flash : '';
const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : 'Имя сохранится в профиле клиента и voice-контуре.');
return `
<div class="customer-profile-name-row">
<div class="customer-profile-name-stack">
<h3>${escapeHtml(customer.display_name || 'Без имени')}</h3>
<p>${escapeHtml(customerLeadCompany(customer))}</p>
${(statusMeta || sourceLabel || flash) ? `
<div class="customer-profile-name-meta">
${statusMeta ? `<span class="micro-badge ${escapeHtml(statusMeta.className)}">${escapeHtml(statusMeta.shortLabel)}</span>` : ''}
${sourceLabel ? `<span class="customer-profile-name-note">Источник: ${escapeHtml(sourceLabel)}</span>` : ''}
${flash ? `<span class="customer-profile-name-flash">${escapeHtml(flash)}</span>` : ''}
</div>
` : ''}
</div>
<button class="btn ghost customer-profile-name-trigger" type="button" data-customer-action="edit-name">
${isOpen ? 'Редактирование имени' : 'Изменить имя'}
</button>
</div>
${isOpen ? `
<form class="customer-profile-name-editor" data-customer-name-form>
<div class="customer-profile-name-editor-label">Как сохранить имя клиента в профиле</div>
<div class="customer-profile-name-editor-form">
<input
type="text"
value="${escapeHtml(editor.draft)}"
placeholder="Введите имя клиента"
data-customer-name-input
${editor.saving ? 'disabled' : ''}
/>
<button class="btn" type="submit" ${editor.saving ? 'disabled' : ''}>${editor.saving ? 'Сохраняем...' : 'Сохранить имя'}</button>
<button class="btn ghost" type="button" data-customer-action="cancel-name-edit" ${editor.saving ? 'disabled' : ''}>Отмена</button>
</div>
<p class="hint${editor.error ? ' error' : ''}" data-customer-name-status>${escapeHtml(statusText)}</p>
</form>
` : ''}
`;
}
function customerSpotlightMarkup(customer, options = {}) {
const { pageView = false } = options;
const index = Math.max(customerIndex(customer), 0);
const phones = customerPhones(customer);
const source = customerLeadSourceMeta(customer, index);
const leadStatus = customerLeadStatusMeta(customer, index);
const score = customerLeadScore(customer, index);
const historyPayload = customerHistoryPayload(customer.customer_id);
const historySummary = historyPayload?.summary || null;
const currentThreads = customerTelegramThreads(customer);
const currentLiveCalls = customerLiveCalls(customer);
const interactions = Array.isArray(historyPayload?.interactions)
? historyPayload.interactions
: customerInteractions(customer);
const threads = Array.isArray(historyPayload?.telegram_threads)
? historyPayload.telegram_threads
: customerTelegramThreads(customer);
const liveCalls = Array.isArray(historyPayload?.live_calls)
? historyPayload.live_calls
: customerLiveCalls(customer);
const profileNameContext = customerProfileVoiceNameContext(liveCalls);
const historyEvents = Array.isArray(historyPayload?.history) && historyPayload.history.length
? historyPayload.history
: buildCustomerHistoryEvents(customer);
const latestEvent = historyEvents[0] || null;
const openCases = Number.isFinite(Number(historySummary?.open_cases))
? Number(historySummary.open_cases)
: interactions.filter((item) => item.status !== 'closed').length;
const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length
? historySummary.active_channels
: [...new Set([
...interactions.map((item) => item.channel).filter(Boolean),
...(threads.length ? ['telegram'] : []),
...(liveCalls.length ? ['voice'] : []),
])];
const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : [];
const latestInteraction = latestItemByTime(interactions, ['updated_at', 'created_at']);
const primaryThread = historySummary?.primary_telegram_thread_id
? currentThreads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null
: latestItemByTime(currentThreads, ['last_message_at', 'updated_at', 'created_at']);
const primaryCall = latestItemByTime(currentLiveCalls, ['last_transition_at', 'ended_at', 'connected_at', 'started_at', 'updated_at']);
const historyActionContext = {
threadIds: new Set(currentThreads.map((item) => item.thread_id).filter(Boolean)),
callIds: new Set(currentLiveCalls.map((item) => item.call_id).filter(Boolean)),
};
const historyStatusText = customerHistoryPending(customer.customer_id)
? 'Обновляем ленту клиента из backend...'
: state.customers.historyErrorsById[customer.customer_id]
? 'Показываем локальную историю, пока backend недоступен.'
: (latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения');
const summaryCards = [
renderSummaryCard(
'Контакты',
String(Number.isFinite(Number(historySummary?.contact_points)) ? Number(historySummary.contact_points) : (interactions.length + threads.length + liveCalls.length)),
'Все точки касания клиента',
),
renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'),
renderSummaryCard(
'Каналы',
String(activeChannels.length || (phones.length ? 1 : 0)),
activeChannels.length ? activeChannels.map((item) => channelLabel(item)).join(' • ') : 'Пока только профиль',
),
renderSummaryCard(
'Последний контакт',
historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'),
historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована',
),
].join('');
const historyMarkup = historyEvents.length
? historyEvents.map((event) => {
const meta = customerHistoryMeta(event.kind);
return `
<article class="customer-history-event ${meta.className}">
<div class="customer-history-mark">${meta.token}</div>
<div class="customer-history-body">
<div class="customer-history-head">
<div class="customer-history-title">${escapeHtml(event.title)}</div>
<time class="customer-history-time">${escapeHtml(formatIsoShort(event.timestamp))}</time>
</div>
<div class="customer-history-copy">${escapeHtml(event.body)}</div>
${event.note ? `<div class="customer-history-note">${escapeHtml(event.note)}</div>` : ''}
${renderCustomerHistoryActions(event, historyActionContext)}
</div>
</article>
`;
}).join('')
: '<div class="empty-state">Пока нет событий по этому клиенту.</div>';
return `
<div class="customer-spotlight-grid${pageView ? ' page' : ''}">
<section class="customer-profile-card">
<div class="customer-profile-hero">
<div class="customer-profile-avatar">${escapeHtml(customerInitials(customer.display_name))}</div>
<div class="customer-profile-copy">
<div class="customer-profile-kicker">Клиент ${escapeHtml(customer.customer_id)}</div>
${customerProfileNameEditorMarkup(customer, profileNameContext)}
</div>
<div class="customer-profile-badges">
<span class="${source.className}">${escapeHtml(source.label)}</span>
<span class="${leadStatus.className}">${escapeHtml(leadStatus.label)}</span>
<span class="customer-score-pill ${customerLeadScoreClass(score)}">AI ${score}</span>
</div>
</div>
<div class="customer-profile-summary-grid">
${summaryCards}
</div>
<div class="customer-profile-meta-grid">
<article class="customer-meta-card">
<div class="customer-meta-label">Основной номер</div>
<div class="customer-meta-value">${escapeHtml(historySummary?.primary_phone || phones[0] || 'Не указан')}</div>
<div class="customer-meta-note">${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}</div>
</article>
<article class="customer-meta-card">
<div class="customer-meta-label">Предпочтительный канал</div>
<div class="customer-meta-value">${escapeHtml(channelLabel(customerPreferredChannel(customer)))}</div>
<div class="customer-meta-note">${escapeHtml(openCases ? 'Есть активные обращения' : 'Сейчас без открытых кейсов')}</div>
</article>
<article class="customer-meta-card">
<div class="customer-meta-label">Теги профиля</div>
<div class="customer-chip-row">
${(tags.length ? tags : [customerLeadCompany(customer)]).slice(0, 4).map((tag) => `<span class="customer-chip">${escapeHtml(tag)}</span>`).join('')}
</div>
</article>
<article class="customer-meta-card">
<div class="customer-meta-label">Активные каналы</div>
<div class="customer-chip-row">
${(activeChannels.length ? activeChannels : ['profile']).map((channel) => `<span class="customer-channel-pill">${escapeHtml(channel === 'profile' ? 'Профиль' : channelLabel(channel))}</span>`).join('')}
</div>
</article>
</div>
<div class="customer-profile-actions">
<button class="btn" type="button" data-customer-action="workspace">Открыть рабочий стол</button>
<button class="btn ghost" type="button" data-customer-action="interaction" data-interaction-id="${escapeHtml(latestInteraction?.interaction_id || '')}" ${latestInteraction ? '' : 'disabled'}>Открыть кейс</button>
<button class="btn ghost" type="button" data-customer-action="telegram" data-thread-id="${escapeHtml(primaryThread?.thread_id || '')}" ${primaryThread ? '' : 'disabled'}>Открыть Telegram</button>
<button class="btn ghost" type="button" data-customer-action="call" data-call-id="${escapeHtml(primaryCall?.call_id || '')}" ${primaryCall ? '' : 'disabled'}>Открыть звонок</button>
</div>
</section>
<section class="customer-history-card">
<div class="customer-history-header">
<div>
<div class="customer-profile-kicker">Единая история клиента</div>
<h3>Все контакты в одной ленте</h3>
</div>
<div class="customer-history-caption">${escapeHtml(historyStatusText)}</div>
</div>
<div class="customer-history-list">
${historyMarkup}
</div>
</section>
</div>
`;
}
function renderCustomerSpotlight() {
const target = $('customerSpotlight');
const customer = selectedCustomer();
if (target && !customer) {
target.innerHTML = emptyCustomerSpotlightMarkup(
'Выберите клиента',
'Здесь появятся профиль клиента и единая история контактов по всем каналам.',
);
}
if (target && customer) {
const listEl = target.querySelector('.customer-history-list');
const scrollPos = listEl ? listEl.scrollTop : 0;
const h = target.offsetHeight;
if (h > 0) target.style.minHeight = h + 'px';
target.innerHTML = customerSpotlightMarkup(customer);
if (h > 0) target.style.minHeight = '';
const newListEl = target.querySelector('.customer-history-list');
if (newListEl && scrollPos > 0) {
newListEl.scrollTop = scrollPos;
}
}
renderCustomerProfilePage();
}
function renderCustomerProfilePage() {
const target = $('customerProfileContent');
if (!target) {
return;
}
const customer = selectedCustomer();
const title = $('customerProfileTitle');
const hint = $('customerProfileHint');
if (!customer) {
if (title) {
title.textContent = 'Профиль клиента';
}
if (hint) {
hint.textContent = 'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.';
}
target.innerHTML = emptyCustomerSpotlightMarkup(
'Выберите клиента',
'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.',
);
return;
}
if (title) {
title.textContent = customer.display_name || 'Профиль клиента';
}
const historyPayload = customerHistoryPayload(customer.customer_id);
ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});
if (hint) {
const phones = customerPhones(customer);
const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || '';
hint.textContent = primaryPhone
? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}`
: `Клиент ${customer.customer_id} • омниканальный профиль`;
}
const listEl = target.querySelector('.customer-history-list');
const scrollPos = listEl ? listEl.scrollTop : 0;
const pageScroll = window.scrollY || document.documentElement.scrollTop;
const h = target.offsetHeight;
if (h > 0) target.style.minHeight = h + 'px';
target.innerHTML = customerSpotlightMarkup(customer, { pageView: true });
if (h > 0) target.style.minHeight = '';
const newListEl = target.querySelector('.customer-history-list');
if (newListEl && scrollPos > 0) {
newListEl.scrollTop = scrollPos;
}
if (pageScroll > 0 && pageScroll > window.scrollY) {
window.scrollTo(window.scrollX, pageScroll);
}
}
function syncCustomerFormUi() {
$('customerLeadForm').classList.toggle('hidden', !state.customers.leadFormOpen);
$('toggleCustomerLeadFormBtn').textContent = state.customers.leadFormOpen ? 'Закрыть форму' : '+ Добавить лид';
}
function updateCustomerPagerUi() {
const total = state.customers.items.length;
const pageCount = Math.max(1, Math.ceil(total / state.customers.pageSize));
const start = total ? (state.customers.page - 1) * state.customers.pageSize + 1 : 0;
const end = total ? Math.min(state.customers.page * state.customers.pageSize, total) : 0;
$('customerPageSummary').textContent = total ? `Показано ${start}-${end} из ${total}` : 'Пока нет лидов.';
$('customerPrevPageBtn').disabled = state.customers.page <= 1;
$('customerNextPageBtn').disabled = state.customers.page >= pageCount;
}
function renderCustomerList() {
const rows = customerLeadRows();
if (!rows.length) {
$('customerList').innerHTML = `
<div class="lead-empty-state">
<strong>Лиды пока не найдены.</strong>
<p>Попробуйте другой запрос или добавьте новый лид вручную.</p>
</div>
`;
renderCustomerSpotlight();
updateCustomerPagerUi();
return;
}
$('customerList').innerHTML = `
<div class="lead-table">
<div class="lead-table-head">
<div class="lead-cell checkbox-cell"><input type="checkbox" aria-label="Выбрать все лиды" /></div>
<div class="lead-cell contact-cell">ИМЯ КОНТАКТА</div>
<div class="lead-cell">ИСТОЧНИК</div>
<div class="lead-cell">ДАТА СОЗДАНИЯ</div>
<div class="lead-cell">СТАТУС</div>
<div class="lead-cell score-cell">AI SCORE</div>
</div>
${rows.map((item, index) => {
const source = customerLeadSourceMeta(item, index);
const status = customerLeadStatusMeta(item, index);
const score = customerLeadScore(item, index);
const selected = item.customer_id === state.customers.selectedCustomerId ? ' selected' : '';
return `
<div class="lead-table-row${selected}" data-customer-id="${escapeHtml(item.customer_id)}" role="button" tabindex="0">
<div class="lead-cell checkbox-cell"><input type="checkbox" tabindex="-1" aria-hidden="true" /></div>
<div class="lead-cell contact-cell">
<div class="lead-avatar">${escapeHtml(customerInitials(item.display_name))}</div>
<div class="lead-contact-copy">
<div class="lead-contact-name">${escapeHtml(item.display_name)}</div>
<div class="lead-contact-company">${escapeHtml(customerLeadCompany(item))}</div>
</div>
</div>
<div class="lead-cell"><span class="${source.className}">${escapeHtml(source.label)}</span></div>
<div class="lead-cell">${escapeHtml(customerLeadCreatedAtLabel(item, index))}</div>
<div class="lead-cell"><span class="${status.className}">${escapeHtml(status.label)}</span></div>
<div class="lead-cell score-cell"><span class="${customerLeadScoreClass(score)}">${score}</span></div>
</div>
`;
}).join('')}
</div>
`;
renderCustomerSpotlight();
updateCustomerPagerUi();
}
function selectCustomer(customerId) {
if (state.customers.nameEditor.customerId && state.customers.nameEditor.customerId !== customerId) {
state.customers.nameEditor.open = false;
state.customers.nameEditor.customerId = '';
state.customers.nameEditor.draft = '';
state.customers.nameEditor.saving = false;
state.customers.nameEditor.error = '';
state.customers.nameEditor.flash = '';
}
state.customers.selectedCustomerId = customerId || '';
if (customerId) {
$('interactionCustomerId').value = customerId;
}
renderCustomerList();
}
function setCustomerPage(page) {
const totalPages = Math.max(1, Math.ceil(state.customers.items.length / state.customers.pageSize));
state.customers.page = Math.min(Math.max(page, 1), totalPages);
renderCustomerList();
}
function selectedTelegramThread() {
return state.telegram.threads.find((thread) => thread.thread_id === state.telegram.selectedThreadId) || null;
}
function telegramThreadCanClaim(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
return !thread.claimed_by_user;
}
function telegramThreadCanReply(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return thread.claimed_by_user === state.user;
}
function telegramThreadCanManage(thread) {
if (!thread) {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return thread.claimed_by_user === state.user;
}
function telegramThreadCanReturnToAi(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
if (String(thread.ai_state || '') !== 'human_owned') {
return false;
}
return telegramThreadCanManage(thread);
}
function telegramThreadDisplayName(thread) {
return thread?.display_name || thread?.username || thread?.chat_id || 'Telegram';
}
function telegramThreadInitials(thread) {
return customerInitials(telegramThreadDisplayName(thread));
}
function telegramThreadTimeLabel(value) {
if (!value) {
return 'сейчас';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return String(value);
}
const now = new Date();
const sameDay = dt.toDateString() === now.toDateString();
if (sameDay) {
return dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
return dt.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' });
}
function telegramHashValue(value) {
let hash = 0;
const text = String(value || 'telegram');
for (let index = 0; index < text.length; index += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(index)) >>> 0;
}
return hash;
}
function telegramAvatarPalette(seed) {
return TELEGRAM_AVATAR_PALETTES[telegramHashValue(seed) % TELEGRAM_AVATAR_PALETTES.length];
}
function telegramAvatarStyle(seed) {
const [start, end] = telegramAvatarPalette(seed);
return `style="--telegram-avatar-start:${start};--telegram-avatar-end:${end};"`;
}
function whatsappAvatarPalette(seed) {
return WHATSAPP_AVATAR_PALETTES[telegramHashValue(seed || 'whatsapp') % WHATSAPP_AVATAR_PALETTES.length];
}
function cloneWhatsappChats() {
return WHATSAPP_MOCK_CHATS.map((chat) => ({
...chat,
messages: (chat.messages || []).map((message) => ({ ...message })),
}));
}
function sortWhatsappChats(chats) {
return [...(chats || [])].sort((left, right) => {
const pinnedDelta = Number(Boolean(right.pinned)) - Number(Boolean(left.pinned));
if (pinnedDelta !== 0) {
return pinnedDelta;
}
return String(right.lastMessageAt || '').localeCompare(String(left.lastMessageAt || ''));
});
}
function seedWhatsappChats() {
if (state.whatsapp.chats.length) {
return;
}
state.whatsapp.mode = 'mock';
state.whatsapp.chats = sortWhatsappChats(cloneWhatsappChats());
state.whatsapp.selectedChatId = state.whatsapp.chats[0]?.id || '';
}
function whatsappChatThreadId(chat) {
return chat?.threadId || chat?.id || '';
}
function whatsappThreadCanClaim(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
return !thread.claimedByUser;
}
function whatsappThreadCanReply(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return !thread.claimedByUser || thread.claimedByUser === state.user;
}
function whatsappThreadCanManage(thread) {
if (!thread) {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return thread.claimedByUser === state.user;
}
function whatsappThreadCanReturnToAi(thread) {
if (!thread || thread.status === 'closed') {
return false;
}
if (String(thread.aiState || '').trim() !== 'human_owned') {
return false;
}
return whatsappThreadCanManage(thread);
}
function whatsappThreadDisplayName(thread) {
return thread?.title || thread?.display_name || thread?.phoneNumber || thread?.phone_number || thread?.chatId || thread?.chat_id || 'WhatsApp';
}
function whatsappThreadAiStateMeta(thread) {
const stateKey = String(thread?.aiState || thread?.ai_state || '').trim();
if (!stateKey) {
return null;
}
const meta = {
queued: { label: 'AI готов', tone: 'queued' },
thinking: { label: 'AI думает', tone: 'thinking' },
active: { label: 'AI активен', tone: 'active' },
handoff_required: { label: 'Нужен оператор', tone: 'handoff' },
human_owned: { label: 'Operator', tone: 'human' },
closed: { label: 'AI завершён', tone: 'closed' },
error: { label: 'Ошибка AI', tone: 'error' },
};
return meta[stateKey] || { label: stateKey, tone: 'queued' };
}
function whatsappThreadAiClaimable(thread) {
return Boolean(thread && !thread.claimedByUser && ['queued', 'thinking', 'active', 'handoff_required'].includes(String(thread.aiState || '')));
}
function whatsappThreadUnreadCount(thread) {
const raw = Number(thread?.unreadCount ?? thread?.unread_count ?? 0);
return Number.isFinite(raw) && raw > 0 ? raw : 0;
}
function whatsappThreadHandle(thread) {
if (!thread) {
return 'Direct message';
}
if (thread.handle) {
return thread.handle;
}
if (thread.phoneNumber || thread.phone_number) {
return thread.phoneNumber || thread.phone_number;
}
if (thread.whatsappUserId || thread.whatsapp_user_id) {
return thread.whatsappUserId || thread.whatsapp_user_id;
}
if (thread.isGroup || thread.is_group) {
return 'Групповой чат';
}
const chatId = thread.chatId || thread.chat_id;
return chatId ? `chat ${chatId}` : 'Direct message';
}
function whatsappThreadPresenceText(thread) {
if (!thread) {
return 'Выберите чат';
}
if (thread.status === 'closed') {
return 'Закрыт';
}
const aiMeta = whatsappThreadAiStateMeta(thread);
if (aiMeta && String(thread.aiState || '').trim() !== 'human_owned') {
return aiMeta.label;
}
if (thread.claimedByUser) {
return `в работе у ${thread.claimedByUser}`;
}
return thread.statusLine || whatsappThreadHandle(thread);
}
function whatsappThreadOwnerText(thread) {
if (whatsappThreadAiClaimable(thread)) {
return 'владелец AI';
}
return thread?.claimedByUser ? `владелец ${thread.claimedByUser}` : 'владелец -';
}
function whatsappThreadSummaryAccessible(thread) {
if (!thread) {
return false;
}
if (!['handoff_required', 'human_owned'].includes(String(thread.aiState || '').trim())) {
return false;
}
if (String(state.role || '') !== 'operator') {
return true;
}
const claimedByUser = String(thread.claimedByUser || '').trim();
return !claimedByUser || claimedByUser === String(state.user || '').trim();
}
function whatsappSelectedSummaryVisible(thread) {
if (!whatsappThreadSummaryAccessible(thread)) {
return false;
}
const summary = state.whatsapp.selectedThreadSummary;
return Boolean(summary && summary.thread_id === whatsappChatThreadId(thread));
}
function whatsappSummaryFieldMarkup(label, value) {
return `
<article class="whatsapp-summary-field">
<div class="whatsapp-summary-label">${escapeHtml(label)}</div>
<div class="whatsapp-summary-value">${escapeHtml(value || '-')}</div>
</article>
`;
}
function whatsappSummaryStatusMarkup(summary) {
if (!summary?.status_label) {
return '';
}
const tone = String(summary.status_tone || 'handoff').trim() || 'handoff';
return `<span class="whatsapp-summary-status ${escapeHtml(tone)}">${escapeHtml(summary.status_label)}</span>`;
}
function normalizeWhatsappDeliveryStatus(status) {
const raw = String(status || '').trim().toLowerCase();
if (!raw) {
return '';
}
if (raw.includes('read')) {
return 'read';
}
if (raw.includes('deliver') || raw.includes('sent')) {
return 'delivered';
}
return raw;
}
function whatsappAuthorLabel(message, chat = null) {
if (message.author) {
return message.author;
}
const payload = message.payload || {};
if (message.author_type === 'customer') {
return payload.display_name || payload.profile_name || chat?.title || '';
}
if (message.author_type === 'human') {
return message.operator_user || payload.operator_user || 'Оператор';
}
if (message.author_type === 'ai') {
return 'AI';
}
return '';
}
function normalizeWhatsappMessage(message, chat = null) {
const direction = message.direction === 'outbound'
? 'out'
: message.direction === 'inbound'
? 'in'
: (message.direction || 'system');
return {
id: message.message_id || message.id,
direction,
text: message.text || '',
createdAt: message.created_at || message.createdAt || '',
deliveryStatus: normalizeWhatsappDeliveryStatus(message.delivery_status || message.deliveryStatus),
author: whatsappAuthorLabel(message, chat),
authorType: message.author_type || message.authorType || '',
operatorUser: message.operator_user || '',
payload: message.payload || {},
};
}
function whatsappThreadStatusLine(thread) {
const aiMeta = whatsappThreadAiStateMeta(thread);
if (aiMeta && String(thread.ai_state || '').trim() !== 'human_owned') {
return aiMeta.label;
}
if (thread.claimed_by_user) {
return `owned by ${thread.claimed_by_user}`;
}
return thread.phone_number || thread.whatsapp_user_id || (thread.is_group ? 'Групповой чат' : 'Чат WhatsApp');
}
function normalizeWhatsappThread(thread, existingChat = null) {
return {
id: thread.thread_id,
threadId: thread.thread_id,
interactionId: thread.interaction_id,
chatId: thread.chat_id,
whatsappUserId: thread.whatsapp_user_id || '',
phoneNumber: thread.phone_number || '',
title: thread.display_name || thread.phone_number || thread.chat_id || 'WhatsApp',
handle: thread.phone_number || thread.whatsapp_user_id || (thread.is_group ? 'Групповой чат' : `чат ${thread.chat_id}`),
statusLine: whatsappThreadStatusLine(thread),
isGroup: Boolean(thread.is_group),
unreadCount: whatsappThreadUnreadCount(thread),
pinned: Boolean(existingChat?.pinned),
muted: Boolean(existingChat?.muted),
lastMessageAt: thread.last_message_at || '',
lastMessagePreview: thread.last_message_preview || '',
claimedByUser: thread.claimed_by_user || '',
claimedAt: thread.claimed_at || '',
status: thread.status || '',
queueId: thread.queue_id || '',
aiSessionId: thread.ai_session_id || '',
aiState: thread.ai_state || '',
aiHandoffReason: thread.ai_handoff_reason || '',
aiLastModelAt: thread.ai_last_model_at || '',
createdAt: thread.created_at || '',
updatedAt: thread.updated_at || '',
source: 'live',
messages: Array.isArray(existingChat?.messages) ? existingChat.messages.map((message) => ({ ...message })) : [],
};
}
function applyWhatsappChats(chats, options = {}) {
const { preserveSelection = true } = options;
const sorted = sortWhatsappChats(chats);
const previous = preserveSelection ? state.whatsapp.selectedChatId : '';
const stillExists = sorted.some((chat) => chat.id === previous);
const nextSelected = stillExists ? previous : (sorted[0]?.id || '');
state.whatsapp.chats = sorted;
state.whatsapp.selectedChatId = nextSelected;
if (nextSelected !== previous) {
state.whatsapp.composerText = '';
state.whatsapp.selectedThreadSummary = null;
}
if (!nextSelected) {
state.whatsapp.selectedThreadSummary = null;
}
renderWhatsappWorkspace();
return nextSelected;
}
function upsertWhatsappThreadData(thread, options = {}) {
const existing = state.whatsapp.chats.find((item) => item.id === thread.thread_id) || null;
const normalized = normalizeWhatsappThread(thread, existing);
const nextChats = existing
? state.whatsapp.chats.map((item) => (item.id === normalized.id ? normalized : item))
: [...state.whatsapp.chats, normalized];
applyWhatsappChats(nextChats, options);
return normalized;
}
function whatsappEstimateUnreadCount(messages) {
let lastOutboundIndex = -1;
(messages || []).forEach((message, index) => {
if (message.direction === 'out') {
lastOutboundIndex = index;
}
});
return (messages || [])
.slice(lastOutboundIndex + 1)
.filter((message) => message.direction === 'in')
.length;
}
function updateWhatsappChatMessages(threadId, messages, options = {}) {
const { preserveUnread = false } = options;
const nextChats = state.whatsapp.chats.map((chat) => {
if (chat.id !== threadId) {
return chat;
}
return {
...chat,
messages,
unreadCount: preserveUnread ? chat.unreadCount : (threadId === state.whatsapp.selectedChatId ? 0 : whatsappEstimateUnreadCount(messages)),
};
});
applyWhatsappChats(nextChats);
}
function renderWhatsappSummaryCard() {
const card = $('whatsappAiSummaryCard');
const content = $('whatsappAiSummaryContent');
const meta = $('whatsappAiSummaryMeta');
const status = $('whatsappAiSummaryStatus');
if (!card || !content) {
return;
}
const thread = selectedWhatsappChat();
if (!whatsappSelectedSummaryVisible(thread)) {
card.classList.add('hidden');
content.innerHTML = '';
if (meta) {
meta.textContent = '';
}
if (status) {
status.innerHTML = '';
}
return;
}
const summary = state.whatsapp.selectedThreadSummary;
const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(whatsappChatTimeLabel(summary.generated_at))}` : '';
content.innerHTML = [
whatsappSummaryFieldMarkup('Запрос клиента', summary.customer_request_text),
whatsappSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text),
whatsappSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason),
whatsappSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step),
].join('');
if (meta) {
meta.textContent = generatedAt;
}
if (status) {
status.innerHTML = whatsappSummaryStatusMarkup(summary);
}
card.classList.remove('hidden');
}
function telegramThreadSummary(thread) {
return thread.username ? `@${thread.username}` : `chat ${thread.chat_id}`;
}
function telegramThreadStatusText(thread) {
return statusMeta(thread?.status || '').label;
}
function telegramThreadAiStateMeta(thread) {
const stateKey = String(thread?.ai_state || '').trim();
if (!stateKey) {
return null;
}
const meta = {
queued: { label: 'AI готов', tone: 'queued' },
thinking: { label: 'AI думает', tone: 'thinking' },
active: { label: 'AI активен', tone: 'active' },
handoff_required: { label: 'Ждёт человека', tone: 'handoff' },
human_owned: { label: 'Человек', tone: 'human' },
closed: { label: 'AI завершён', tone: 'closed' },
error: { label: 'Ошибка AI', tone: 'error' },
};
return meta[stateKey] || { label: stateKey, tone: 'queued' };
}
function telegramThreadAiClaimable(thread) {
return Boolean(thread && !thread.claimed_by_user && ['queued', 'thinking', 'active', 'handoff_required'].includes(String(thread.ai_state || '')));
}
function telegramClaimButtonLabel(thread) {
return telegramThreadAiClaimable(thread) ? 'Забрать у AI' : 'В работу';
}
function telegramThreadUnreadCount(thread) {
const raw = Number(thread?.unread_count ?? 0);
return Number.isFinite(raw) && raw > 0 ? raw : 0;
}
function telegramThreadPresenceText(thread) {
if (!thread) {
return 'нет диалога';
}
const aiMeta = telegramThreadAiStateMeta(thread);
if (aiMeta) {
return aiMeta.label;
}
return thread.claimed_by_user ? 'в работе' : 'свободен';
}
function telegramThreadOwnerText(thread) {
if (telegramThreadAiClaimable(thread)) {
return 'владелец AI';
}
return thread?.claimed_by_user ? `владелец ${thread.claimed_by_user}` : 'владелец —';
}
function telegramThreadHeaderPresence(thread) {
if (!thread) {
return 'Выберите диалог слева';
}
if (thread.status === 'closed') {
return 'был(а) недавно';
}
const aiMeta = telegramThreadAiStateMeta(thread);
if (aiMeta && thread.ai_state !== 'human_owned') {
return aiMeta.label;
}
return thread.claimed_by_user ? 'в работе' : 'в сети';
}
function telegramThreadNeedsSummary(thread) {
if (!thread) {
return false;
}
return ['handoff_required', 'human_owned'].includes(String(thread.ai_state || ''));
}
function telegramThreadSummaryAccessible(thread) {
if (!telegramThreadNeedsSummary(thread)) {
return false;
}
if (String(state.role || '') !== 'operator') {
return true;
}
const claimedByUser = String(thread?.claimed_by_user || '').trim();
return !claimedByUser || claimedByUser === String(state.user || '').trim();
}
function telegramSelectedSummaryVisible(thread) {
if (!telegramThreadSummaryAccessible(thread)) {
return false;
}
const summary = state.telegram.selectedThreadSummary;
return Boolean(summary && summary.thread_id === thread?.thread_id);
}
function telegramSummaryFieldMarkup(label, value) {
return `
<article class="telegram-summary-field">
<div class="telegram-summary-label">${escapeHtml(label)}</div>
<div class="telegram-summary-value">${escapeHtml(value || '—')}</div>
</article>
`;
}
function telegramSummaryStatusMarkup(summary) {
if (!summary?.status_label) {
return '';
}
const tone = String(summary.status_tone || 'handoff').trim() || 'handoff';
return `<span class="telegram-summary-status ${escapeHtml(tone)}">${escapeHtml(summary.status_label)}</span>`;
}
function renderTelegramSummaryCard() {
const card = $('telegramAiSummaryCard');
const content = $('telegramAiSummaryContent');
const meta = $('telegramAiSummaryMeta');
const status = $('telegramAiSummaryStatus');
if (!card || !content) {
return;
}
const thread = selectedTelegramThread();
if (!telegramSelectedSummaryVisible(thread)) {
card.classList.add('hidden');
content.innerHTML = '';
if (meta) {
meta.textContent = '';
}
if (status) {
status.innerHTML = '';
}
return;
}
const summary = state.telegram.selectedThreadSummary;
const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(telegramThreadTimeLabel(summary.generated_at))}` : '';
content.innerHTML = [
telegramSummaryFieldMarkup('Запрос клиента', summary.customer_request_text),
telegramSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text),
telegramSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason),
telegramSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step),
].join('');
if (meta) {
meta.textContent = generatedAt;
}
if (status) {
status.innerHTML = telegramSummaryStatusMarkup(summary);
}
card.classList.remove('hidden');
}
function telegramThreadCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return `${count} диалог`;
}
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) {
return `${count} диалога`;
}
return `${count} диалогов`;
}
function telegramVisibleThreads() {
const query = String(state.telegram.searchQuery || '').trim().toLowerCase();
return state.telegram.threads.filter((thread) => {
if (!query) {
return true;
}
return [
telegramThreadDisplayName(thread),
thread.username ? `@${thread.username}` : '',
thread.chat_id,
thread.last_message_preview,
].some((value) => String(value || '').toLowerCase().includes(query));
});
}
function resetTelegramMessageSearch() {
state.telegram.messageSearchQuery = '';
state.telegram.messageSearchOpen = false;
}
function telegramMessageSearchQuery() {
return String(state.telegram.messageSearchQuery || '').trim();
}
function telegramMessageMatchesSearch(message) {
const query = telegramMessageSearchQuery().toLowerCase();
if (!query) {
return true;
}
return String(message?.text || '').toLowerCase().includes(query);
}
function telegramVisibleMessages() {
return state.telegram.messages.filter((message) => telegramMessageMatchesSearch(message));
}
function telegramMessageSearchMeta(thread) {
if (!thread) {
return 'Выберите чат для поиска';
}
const query = telegramMessageSearchQuery();
if (!query) {
return 'Поиск по текущему чату';
}
const matches = telegramVisibleMessages().length;
if (matches === 0) {
return 'Ничего не найдено';
}
if (matches === 1) {
return '1 совпадение';
}
return `${matches} совпадений`;
}
function highlightTelegramMessageText(value) {
const source = String(value || 'Сообщение без текста.');
const query = telegramMessageSearchQuery();
if (!query) {
return escapeHtml(source);
}
const matcher = new RegExp(`(${escapeRegExp(query)})`, 'gi');
return source
.split(matcher)
.map((part) => (part.toLowerCase() === query.toLowerCase()
? `<mark class="telegram-message-highlight">${escapeHtml(part)}</mark>`
: escapeHtml(part)))
.join('');
}
function toggleTelegramMessageSearch(force = null) {
const hasThread = Boolean(selectedTelegramThread());
const nextOpen = force === null ? !state.telegram.messageSearchOpen : Boolean(force);
state.telegram.messageSearchOpen = hasThread && nextOpen;
if (!state.telegram.messageSearchOpen) {
state.telegram.messageSearchQuery = '';
}
renderTelegramWorkspace();
if (state.telegram.messageSearchOpen) {
window.requestAnimationFrame(() => {
$('telegramMessageSearch')?.focus();
$('telegramMessageSearch')?.select();
});
}
}
function telegramMessageDayKey(value) {
if (!value) {
return '';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
}
function telegramMessageDayLabel(value) {
if (!value) {
return 'Недавно';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return String(value);
}
const now = new Date();
const sameDay = dt.toDateString() === now.toDateString();
if (sameDay) {
return 'Сегодня';
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (dt.toDateString() === yesterday.toDateString()) {
return 'Вчера';
}
return dt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' });
}
function telegramMessageStatusMarkup(message) {
if (message.direction !== 'outbound' || !message.delivery_status) {
return '';
}
const status = String(message.delivery_status || '').toLowerCase();
const checks = status.includes('deliver') || status.includes('read') || status.includes('sent') ? '✓✓' : '✓';
return `<span class="telegram-message-checks" title="${escapeHtml(message.delivery_status)}">${checks}</span>`;
}
function syncTelegramComposerHeight() {
const input = $('telegramReplyText');
if (!input) {
return;
}
input.style.height = '0px';
const nextHeight = Math.min(Math.max(input.scrollHeight, 54), 164);
input.style.height = `${nextHeight}px`;
}
function renderTelegramThreadsList() {
const target = $('telegramThreadsList');
const threads = telegramVisibleThreads();
if (!threads.length) {
target.innerHTML = '<div class="empty-state telegram-empty-state">Пока нет Telegram диалогов.</div>';
return;
}
target.innerHTML = threads
.map((thread) => {
const selected = thread.thread_id === state.telegram.selectedThreadId ? ' selected' : '';
const preview = thread.last_message_preview || 'Нет сообщений.';
const unread = telegramThreadUnreadCount(thread);
const aiMeta = telegramThreadAiStateMeta(thread);
return `
<button class="telegram-thread-card${selected}" data-telegram-thread-id="${escapeHtml(thread.thread_id)}" type="button">
<div class="telegram-thread-avatar${selected ? ' selected' : ''}" ${telegramAvatarStyle(telegramThreadDisplayName(thread))}>${escapeHtml(telegramThreadInitials(thread))}</div>
<div class="telegram-thread-body">
<div class="telegram-thread-card-head">
<div class="telegram-thread-name">${escapeHtml(telegramThreadDisplayName(thread))}</div>
<div class="telegram-thread-time">${escapeHtml(telegramThreadTimeLabel(thread.last_message_at))}</div>
</div>
<div class="telegram-thread-preview-row">
<div class="telegram-thread-preview">${escapeHtml(preview)}</div>
${unread > 0 && !selected ? `<span class="telegram-thread-unread">${unread}</span>` : ''}
</div>
<div class="telegram-thread-status-row">
<div class="telegram-thread-summary">${escapeHtml(telegramThreadSummary(thread))}</div>
${aiMeta ? `<span class="telegram-thread-ai-chip ${aiMeta.tone}">${escapeHtml(aiMeta.label)}</span>` : ''}
</div>
</div>
</button>
`;
})
.join('');
}
function renderTelegramMessagesTimeline() {
const target = $('telegramMessagesTimeline');
const thread = selectedTelegramThread();
if (!thread) {
target.innerHTML = '<div class="empty-state">Выберите диалог слева, чтобы увидеть историю сообщений.</div>';
return;
}
if (!state.telegram.messages.length) {
target.innerHTML = '<div class="empty-state">Сообщений пока нет.</div>';
return;
}
const messages = telegramVisibleMessages();
if (!messages.length) {
target.innerHTML = '<div class="empty-state telegram-empty-state">Ничего не найдено в этом чате.</div>';
return;
}
const fragments = messages.map((message) => {
const direction = message.direction || 'system';
if (direction === 'system') {
return `
<article class="telegram-message system">
<div class="telegram-message-system-pill">${highlightTelegramMessageText(message.text || 'Системное сообщение')}</div>
</article>
`;
}
const mine = direction === 'outbound';
const aiBadge = message.author_type === 'ai'
? '<span class="telegram-message-badge ai">AI</span>'
: '';
return `
<article class="telegram-message ${mine ? 'me' : 'other'}">
<div class="telegram-message-bubble ${mine ? 'me' : 'other'}${message.author_type === 'ai' ? ' ai' : ''}">
${aiBadge}
<div class="telegram-message-body">${highlightTelegramMessageText(message.text || 'Сообщение без текста.')}</div>
<div class="telegram-message-meta">
<span class="telegram-message-time">${escapeHtml(telegramThreadTimeLabel(message.created_at))}</span>
${mine ? telegramMessageStatusMarkup(message) : ''}
</div>
</div>
</article>
`;
});
target.innerHTML = fragments.join('');
target.scrollTop = telegramMessageSearchQuery() ? 0 : target.scrollHeight;
}
function syncTelegramActionButtons() {
const thread = selectedTelegramThread();
const pending = state.telegram.pendingAction;
const replyText = $('telegramReplyText').value.trim();
$('telegramClaimThreadBtn').textContent = telegramClaimButtonLabel(thread);
$('telegramClaimThreadBtn').disabled = pending !== '' || !telegramThreadCanClaim(thread);
$('telegramReturnToAiBtn').disabled = pending !== '' || !telegramThreadCanReturnToAi(thread);
$('telegramCloseThreadBtn').disabled = pending !== '' || !telegramThreadCanManage(thread) || thread?.status === 'closed';
$('telegramEscalateThreadBtn').disabled = pending !== '' || !telegramThreadCanManage(thread);
$('telegramSendReplyBtn').disabled = pending !== '' || !telegramThreadCanReply(thread) || !replyText;
syncTelegramComposerHeight();
}
function syncTelegramOperatorTray() {
$('telegramOperatorTray').classList.toggle('hidden', !state.telegram.operatorTrayOpen);
}
function toggleTelegramOperatorTray(force = null) {
state.telegram.operatorTrayOpen = force === null ? !state.telegram.operatorTrayOpen : Boolean(force);
syncTelegramOperatorTray();
}
function renderTelegramWorkspace() {
renderTelegramThreadsList();
renderTelegramSummaryCard();
renderTelegramMessagesTimeline();
const thread = selectedTelegramThread();
const avatar = $('telegramThreadAvatar');
const presence = $('telegramThreadPresenceChip');
const owner = $('telegramThreadOwnerChip');
const messageSearchButton = $('telegramHeaderSearchBtn');
const messageSearchBar = $('telegramMessageSearchBar');
const messageSearchInput = $('telegramMessageSearch');
const messageSearchMeta = $('telegramMessageSearchMeta');
const messageSearchClearBtn = $('telegramMessageSearchClearBtn');
const aiChip = $('telegramThreadAiChip');
const aiMeta = telegramThreadAiStateMeta(thread);
$('telegramThreadDisplayName').textContent = thread
? telegramThreadDisplayName(thread)
: 'Выберите диалог';
$('telegramThreadMeta').textContent = thread
? telegramThreadHeaderPresence(thread)
: 'Выберите диалог слева.';
if (avatar) {
avatar.textContent = thread ? telegramThreadInitials(thread) : 'TG';
const [start, end] = telegramAvatarPalette(thread ? telegramThreadDisplayName(thread) : 'Telegram');
avatar.style.setProperty('--telegram-avatar-start', start);
avatar.style.setProperty('--telegram-avatar-end', end);
}
if (presence) {
presence.textContent = thread ? telegramThreadPresenceText(thread) : 'нет диалога';
const presenceTone = aiMeta
? aiMeta.tone
: (thread?.claimed_by_user ? 'claimed' : 'free');
presence.className = `telegram-operator-chip ${presenceTone}`;
}
if (owner) {
owner.textContent = thread ? telegramThreadOwnerText(thread) : 'владелец —';
owner.className = 'telegram-operator-chip subtle';
}
if (aiChip) {
if (aiMeta) {
aiChip.textContent = aiMeta.label;
aiChip.className = `telegram-thread-ai-chip ${aiMeta.tone}`;
} else {
aiChip.textContent = '';
aiChip.className = 'telegram-thread-ai-chip hidden';
}
}
if (messageSearchButton) {
messageSearchButton.disabled = !thread;
messageSearchButton.classList.toggle('active', Boolean(thread) && state.telegram.messageSearchOpen);
}
if (messageSearchBar) {
messageSearchBar.classList.toggle('hidden', !thread || !state.telegram.messageSearchOpen);
}
if (messageSearchInput) {
messageSearchInput.value = state.telegram.messageSearchQuery || '';
messageSearchInput.disabled = !thread;
}
if (messageSearchMeta) {
messageSearchMeta.textContent = telegramMessageSearchMeta(thread);
}
if (messageSearchClearBtn) {
messageSearchClearBtn.textContent = telegramMessageSearchQuery() ? 'Очистить' : 'Закрыть';
}
$('telegramComposerHint').textContent = thread
? telegramThreadCanReply(thread)
? 'Reply уйдёт через Telegram Bot API и сохранится в thread history.'
: telegramThreadCanReturnToAi(thread)
? 'Диалог сейчас у человека. Нажмите «Вернуть AI», чтобы снова включить автоответ.'
: telegramThreadAiClaimable(thread)
? 'Диалог сейчас у AI. Заберите его в работу, чтобы ответить вручную.'
: 'Reply доступен после claim или для admin/supervisor.'
: 'Выберите thread, чтобы отвечать в Telegram.';
syncTelegramOperatorTray();
syncTelegramComposerHeight();
syncTelegramActionButtons();
}
function applyTelegramCollections(threads, options = {}) {
const { preserveSelection = true } = options;
const sorted = [...(threads || [])].sort((left, right) => {
return String(right.last_message_at || '').localeCompare(String(left.last_message_at || ''));
});
state.telegram.threads = sorted;
const previous = preserveSelection ? state.telegram.selectedThreadId : '';
const stillExists = sorted.some((thread) => thread.thread_id === previous);
state.telegram.selectedThreadId = stillExists ? previous : (sorted[0]?.thread_id || '');
if (state.telegram.selectedThreadId !== previous) {
resetTelegramMessageSearch();
state.telegram.selectedThreadSummary = null;
}
if (!state.telegram.selectedThreadId) {
state.telegram.selectedThreadSummary = null;
}
syncMessengerConversations({ preserveSelection: true });
renderTelegramWorkspace();
renderMessengerWorkspace();
renderCustomerSpotlight();
renderUnifiedInbox();
}
async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) {
if (!threadId) {
state.telegram.messages = [];
state.telegram.selectedThreadSummary = null;
resetTelegramMessageSearch();
renderTelegramWorkspace();
return [];
}
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/messages`);
if (threadId === state.telegram.selectedThreadId) {
state.telegram.messages = Array.isArray(data) ? data : [];
renderTelegramWorkspace();
renderCustomerSpotlight();
}
if (logResult) {
log('Сообщения Telegram-диалога обновлены', { thread_id: threadId, messages: Array.isArray(data) ? data.length : 0 });
}
return Array.isArray(data) ? data : [];
}
async function loadTelegramThreadSummary(threadId = state.telegram.selectedThreadId, options = {}) {
const { logResult = false } = options;
if (!threadId) {
state.telegram.selectedThreadSummary = null;
renderTelegramWorkspace();
return null;
}
const thread = state.telegram.threads.find((item) => item.thread_id === threadId) || null;
if (!telegramThreadSummaryAccessible(thread)) {
if (threadId === state.telegram.selectedThreadId) {
state.telegram.selectedThreadSummary = null;
renderTelegramWorkspace();
}
return null;
}
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/ai-summary`);
if (threadId === state.telegram.selectedThreadId) {
state.telegram.selectedThreadSummary = data || null;
renderTelegramWorkspace();
}
if (logResult && data) {
log('AI-сводка загружена', { thread_id: threadId, session_id: data.session_id });
}
return data || null;
} catch (err) {
if (threadId === state.telegram.selectedThreadId) {
state.telegram.selectedThreadSummary = null;
renderTelegramWorkspace();
}
if (logResult) {
log('Не удалось загрузить AI-сводку', { thread_id: threadId, error: err.message });
}
return null;
}
}
async function loadTelegramThreads(logResult = true, options = {}) {
const { preserveSelection = true, preserveOnError = false } = options;
try {
const data = await api('telegram', 'integrations/telegram/threads');
applyTelegramCollections(Array.isArray(data) ? data : [], { preserveSelection });
await loadTelegramThreadMessages(state.telegram.selectedThreadId, false);
await loadTelegramThreadSummary(state.telegram.selectedThreadId, { logResult: false });
if (logResult) {
log('Telegram-диалоги обновлены', { threads: state.telegram.threads.length });
}
} catch (err) {
if (!preserveOnError) {
state.telegram.threads = [];
state.telegram.selectedThreadId = '';
state.telegram.messages = [];
state.telegram.selectedThreadSummary = null;
renderTelegramWorkspace();
}
if (logResult) {
log('Не удалось загрузить Telegram-диалоги', { error: err.message });
}
}
}
async function selectTelegramThread(threadId) {
if ((threadId || '') !== state.telegram.selectedThreadId) {
resetTelegramMessageSearch();
}
state.telegram.selectedThreadId = threadId || '';
state.telegram.selectedThreadSummary = null;
state.telegram.operatorTrayOpen = false;
renderTelegramWorkspace();
await loadTelegramThreadMessages(threadId, false);
await loadTelegramThreadSummary(threadId, { logResult: false });
}
async function claimTelegramThread() {
const thread = selectedTelegramThread();
if (!thread) {
return;
}
state.telegram.pendingAction = 'claim';
renderTelegramWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/claim`, {
method: 'POST',
});
const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item));
applyTelegramCollections(nextThreads);
await loadTelegramThreadSummary(data.thread_id, { logResult: false });
refreshLiveCallsInBackground();
loadInteractions().catch(() => {});
log('Telegram-диалог взят в работу', { thread_id: data.thread_id, interaction_id: data.interaction_id });
} catch (err) {
log('Не удалось взять Telegram-диалог в работу', { error: err.message });
} finally {
state.telegram.pendingAction = '';
renderTelegramWorkspace();
}
}
async function returnTelegramThreadToAi() {
const thread = selectedTelegramThread();
if (!thread) {
return;
}
state.telegram.pendingAction = 'return-ai';
renderTelegramWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/return-to-ai`, {
method: 'POST',
});
const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item));
applyTelegramCollections(nextThreads);
await loadTelegramThreadSummary(data.thread_id, { logResult: false });
loadInteractions().catch(() => {});
log('Telegram-диалог возвращён AI', { thread_id: data.thread_id, interaction_id: data.interaction_id });
} catch (err) {
log('Не удалось вернуть Telegram-диалог AI', { error: err.message });
} finally {
state.telegram.pendingAction = '';
renderTelegramWorkspace();
}
}
async function sendTelegramReply() {
const thread = selectedTelegramThread();
const text = $('telegramReplyText').value.trim();
if (!thread || !text) {
return;
}
state.telegram.pendingAction = 'reply';
renderTelegramWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
});
$('telegramReplyText').value = '';
state.telegram.messages = [...state.telegram.messages, data];
const nextThreads = state.telegram.threads.map((item) => {
if (item.thread_id !== thread.thread_id) {
return item;
}
return {
...item,
last_message_at: data.created_at,
last_message_preview: data.text,
};
});
applyTelegramCollections(nextThreads);
renderTelegramWorkspace();
log('Ответ в Telegram поставлен в очередь', { thread_id: thread.thread_id, delivery_status: data.delivery_status || 'pending' });
window.setTimeout(() => loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }), 0);
} catch (err) {
log('Не удалось отправить ответ в Telegram', { error: err.message });
} finally {
state.telegram.pendingAction = '';
renderTelegramWorkspace();
}
}
async function closeTelegramThread() {
const thread = selectedTelegramThread();
if (!thread) {
return;
}
state.telegram.pendingAction = 'close';
renderTelegramWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/close`, {
method: 'POST',
});
const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item));
applyTelegramCollections(nextThreads);
await loadTelegramThreadSummary(data.thread_id, { logResult: false });
loadInteractions().catch(() => {});
log('Telegram-диалог закрыт', { thread_id: data.thread_id });
} catch (err) {
log('Не удалось закрыть Telegram-диалог', { error: err.message });
} finally {
state.telegram.pendingAction = '';
renderTelegramWorkspace();
}
}
async function escalateTelegramThread() {
const thread = selectedTelegramThread();
const targetQueueId = $('telegramEscalationQueue').value.trim() || DEMO_QUEUE;
if (!thread) {
return;
}
state.telegram.pendingAction = 'escalate';
renderTelegramWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/escalate`, {
method: 'POST',
body: JSON.stringify({ target_queue_id: targetQueueId }),
});
const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item));
applyTelegramCollections(nextThreads);
await loadTelegramThreadSummary(data.thread_id, { logResult: false });
loadInteractions().catch(() => {});
log('Telegram-диалог эскалирован', { thread_id: data.thread_id, queue_id: data.queue_id });
} catch (err) {
log('Не удалось эскалировать Telegram-диалог', { error: err.message });
} finally {
state.telegram.pendingAction = '';
renderTelegramWorkspace();
}
}
function startTelegramPolling() {
if (state.telegram.pollTimer) {
window.clearInterval(state.telegram.pollTimer);
state.telegram.pollTimer = null;
}
state.telegram.pollTimer = window.setInterval(() => {
if (operatorHashState().view === 'messages') {
return;
}
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
}, 5000);
}
function messengerConversationId(channel, threadId) {
return `${channel}:${threadId || ''}`;
}
function messengerConversationFromTelegramThread(thread) {
const customerId = thread?.customer_id || customerIdForInteractionId(thread?.interaction_id) || '';
const unread = telegramThreadUnreadCount(thread);
return {
conversation_id: messengerConversationId('telegram', thread?.thread_id),
channel: 'telegram',
channel_label: 'Telegram',
thread_id: thread?.thread_id || '',
customer_id: customerId,
interaction_id: thread?.interaction_id || '',
display_name: telegramThreadDisplayName(thread),
last_message_preview: thread?.last_message_preview || '',
last_message_at: thread?.last_message_at || thread?.updated_at || thread?.created_at || '',
status: thread?.status || '',
claimed_by_user: thread?.claimed_by_user || '',
ai_state: thread?.ai_state || '',
unread,
attention: unread > 0 || ['handoff_required', 'handoff_requested'].includes(String(thread?.ai_state || '')),
source_thread: thread,
};
}
function buildMessengerConversations() {
return state.telegram.threads
.map((thread) => messengerConversationFromTelegramThread(thread))
.sort((left, right) => {
return String(right.last_message_at || '').localeCompare(String(left.last_message_at || ''));
});
}
function syncMessengerConversations(options = {}) {
const { preserveSelection = true } = options;
const previous = preserveSelection ? state.messenger.selectedConversationId : '';
const conversations = buildMessengerConversations();
const stillExists = conversations.some((item) => item.conversation_id === previous);
state.messenger.conversations = conversations;
if (!stillExists) {
state.messenger.selectedConversationId = '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
}
}
function selectedMessengerConversation() {
return state.messenger.conversations.find((item) => item.conversation_id === state.messenger.selectedConversationId) || null;
}
function selectedMessengerThread() {
const conversation = selectedMessengerConversation();
if (!conversation || conversation.channel !== 'telegram') {
return null;
}
return state.telegram.threads.find((thread) => thread.thread_id === conversation.thread_id) || conversation.source_thread || null;
}
function messengerConversationMatchesFilter(conversation) {
const filter = state.messenger.activeFilter || 'all';
if (filter === 'new') {
return conversation.status === 'new' || conversation.unread > 0 || !conversation.claimed_by_user;
}
if (filter === 'mine') {
return String(conversation.claimed_by_user || '') === String(state.user || '');
}
if (filter === 'ai') {
return ['handoff_required', 'handoff_requested', 'human_owned'].includes(String(conversation.ai_state || ''));
}
return true;
}
function messengerVisibleConversations() {
return state.messenger.conversations.filter((conversation) => messengerConversationMatchesFilter(conversation));
}
function messengerConversationCanClaim(conversation) {
return Boolean(conversation && conversation.status !== 'closed' && !conversation.claimed_by_user);
}
function messengerConversationCanReply(conversation) {
return Boolean(conversation && conversation.status !== 'closed' && String(conversation.claimed_by_user || '') === String(state.user || ''));
}
function messengerConversationCanManage(conversation) {
if (!conversation || conversation.status === 'closed') {
return false;
}
if (state.role === 'admin' || state.role === 'supervisor') {
return true;
}
return String(conversation.claimed_by_user || '') === String(state.user || '');
}
function messengerAiStateMeta(conversation) {
return telegramThreadAiStateMeta(conversation?.source_thread || selectedMessengerThread());
}
function messengerContextCustomerId(conversation = selectedMessengerConversation()) {
return conversation?.customer_id || customerIdForInteractionId(conversation?.interaction_id) || '';
}
function messengerCustomerProfileAvailable(conversation = selectedMessengerConversation()) {
const customerId = messengerContextCustomerId(conversation);
return Boolean(customerId && state.customers.items.some((item) => item.customer_id === customerId));
}
function messengerSummaryVisible(conversation) {
if (!conversation || conversation.channel !== 'telegram') {
return false;
}
const summary = state.messenger.selectedSummary;
return Boolean(summary && summary.thread_id === conversation.thread_id);
}
function renderMessengerConversations() {
const target = $('messengerConversationList');
if (!target) {
return;
}
if (state.messenger.backendError && !state.messenger.conversations.length) {
target.innerHTML = `
<div class="empty-state messenger-empty-state">
<strong>Backend недоступен</strong>
<span>${escapeHtml(state.messenger.backendError)}</span>
</div>
`;
return;
}
const conversations = messengerVisibleConversations();
if (!state.messenger.conversations.length) {
target.innerHTML = '<div class="empty-state messenger-empty-state">Новых сообщений пока нет</div>';
return;
}
if (!conversations.length) {
target.innerHTML = `<div class="empty-state messenger-empty-state">Нет диалогов в фильтре «${escapeHtml(MESSENGER_FILTERS[state.messenger.activeFilter] || 'Все')}»</div>`;
return;
}
target.innerHTML = conversations
.map((conversation) => {
const selected = conversation.conversation_id === state.messenger.selectedConversationId ? ' selected' : '';
const preview = conversation.last_message_preview || 'Нет сообщений.';
const aiMeta = messengerAiStateMeta(conversation);
return `
<button class="messenger-conversation-card${selected}" data-messenger-conversation-id="${escapeHtml(conversation.conversation_id)}" type="button">
<div class="telegram-thread-avatar${selected ? ' selected' : ''}" ${telegramAvatarStyle(conversation.display_name)}>${escapeHtml(customerInitials(conversation.display_name))}</div>
<div class="messenger-conversation-body">
<div class="messenger-conversation-head">
<span class="messenger-conversation-name">${escapeHtml(conversation.display_name)}</span>
<span class="messenger-conversation-time">${escapeHtml(telegramThreadTimeLabel(conversation.last_message_at))}</span>
</div>
<div class="messenger-conversation-preview-row">
<span class="messenger-conversation-preview">${escapeHtml(preview)}</span>
${conversation.unread > 0 && !selected ? `<span class="telegram-thread-unread">${conversation.unread}</span>` : ''}
</div>
<div class="messenger-conversation-badges">
<span class="messenger-channel-badge compact">Telegram</span>
<span class="messenger-status-badge">${escapeHtml(statusMeta(conversation.status).label)}</span>
${conversation.claimed_by_user ? `<span class="messenger-status-badge owner">${escapeHtml(conversation.claimed_by_user)}</span>` : ''}
${aiMeta ? `<span class="telegram-thread-ai-chip ${aiMeta.tone}">${escapeHtml(aiMeta.label)}</span>` : ''}
</div>
</div>
</button>
`;
})
.join('');
}
function renderMessengerMessages() {
const target = $('messengerMessagesTimeline');
const conversation = selectedMessengerConversation();
if (!target) {
return;
}
if (!conversation) {
target.innerHTML = '<div class="empty-state">Выберите диалог слева</div>';
return;
}
if (!state.messenger.messages.length) {
target.innerHTML = '<div class="empty-state">Сообщений пока нет.</div>';
return;
}
target.innerHTML = state.messenger.messages
.map((message) => {
const direction = message.direction || 'system';
if (direction === 'system') {
return `
<article class="telegram-message system">
<div class="telegram-message-system-pill">${escapeHtml(message.text || 'Системное сообщение')}</div>
</article>
`;
}
const mine = direction === 'outbound';
const aiBadge = message.author_type === 'ai'
? '<span class="telegram-message-badge ai">AI</span>'
: '';
return `
<article class="telegram-message ${mine ? 'me' : 'other'}">
<div class="telegram-message-bubble ${mine ? 'me' : 'other'}${message.author_type === 'ai' ? ' ai' : ''}">
${aiBadge}
<div class="telegram-message-body">${escapeHtml(message.text || 'Сообщение без текста.')}</div>
<div class="telegram-message-meta">
<span class="telegram-message-time">${escapeHtml(telegramThreadTimeLabel(message.created_at))}</span>
${mine ? telegramMessageStatusMarkup(message) : ''}
</div>
</div>
</article>
`;
})
.join('');
target.scrollTop = target.scrollHeight;
}
function renderMessengerSummaryCard() {
const card = $('messengerAiSummaryCard');
const content = $('messengerAiSummaryContent');
const meta = $('messengerAiSummaryMeta');
const status = $('messengerAiSummaryStatus');
const conversation = selectedMessengerConversation();
if (!card || !content) {
return;
}
if (!messengerSummaryVisible(conversation)) {
card.classList.add('hidden');
content.innerHTML = '';
if (meta) {
meta.textContent = '';
}
if (status) {
status.innerHTML = '';
}
return;
}
const summary = state.messenger.selectedSummary;
const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(telegramThreadTimeLabel(summary.generated_at))}` : '';
content.innerHTML = [
telegramSummaryFieldMarkup('Запрос клиента', summary.customer_request_text),
telegramSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text),
telegramSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason),
telegramSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step),
].join('');
if (meta) {
meta.textContent = generatedAt;
}
if (status) {
status.innerHTML = telegramSummaryStatusMarkup(summary);
}
card.classList.remove('hidden');
}
function renderMessengerContext() {
const conversation = selectedMessengerConversation();
const customerId = messengerContextCustomerId(conversation);
const interactionId = conversation?.interaction_id || '';
const aiMeta = messengerAiStateMeta(conversation);
const profileAvailable = messengerCustomerProfileAvailable(conversation);
const preview = conversation?.last_message_preview || '';
const summary = messengerSummaryVisible(conversation) ? state.messenger.selectedSummary : null;
const summaryText = summary
? [summary.customer_request_text, summary.recommended_next_step].filter(Boolean).join(' · ')
: '';
if ($('messengerContextName')) {
$('messengerContextName').textContent = conversation ? conversation.display_name : 'Диалог не выбран';
}
if ($('messengerContextChannel')) {
$('messengerContextChannel').textContent = conversation?.channel_label || 'Telegram';
}
if ($('messengerContextStatus')) {
$('messengerContextStatus').textContent = conversation
? [statusMeta(conversation.status).label, aiMeta?.label].filter(Boolean).join(' · ')
: '—';
}
if ($('messengerContextCustomer')) {
$('messengerContextCustomer').textContent = customerId ? customerDisplayName(customerId) : '—';
}
if ($('messengerContextInteraction')) {
$('messengerContextInteraction').textContent = interactionId || '—';
}
if ($('messengerContextPreview')) {
$('messengerContextPreview').textContent = preview || (conversation ? 'Нет сообщений.' : 'Выберите диалог слева.');
}
if ($('messengerContextAiSummary')) {
$('messengerContextAiSummary').textContent = summaryText || (conversation ? 'AI summary пока недоступен.' : 'Сводка появится после AI handoff.');
}
['messengerOpenProfileBtn', 'messengerContextOpenProfileBtn'].forEach((id) => {
const button = $(id);
if (button) {
button.disabled = !profileAvailable;
}
});
}
function syncMessengerActionButtons() {
const conversation = selectedMessengerConversation();
const pending = state.messenger.pendingAction;
const replyText = String($('messengerReplyText')?.value || '').trim();
const canClaim = messengerConversationCanClaim(conversation);
const canReply = messengerConversationCanReply(conversation);
const canManage = messengerConversationCanManage(conversation);
const claimButton = $('messengerClaimBtn');
const closeButton = $('messengerCloseBtn');
const sendButton = $('messengerSendReplyBtn');
const composer = $('messengerReplyText');
if (claimButton) {
claimButton.disabled = pending !== '' || !canClaim;
claimButton.textContent = telegramThreadAiClaimable(selectedMessengerThread()) ? 'Забрать у AI' : 'Взять в работу';
}
if (closeButton) {
closeButton.disabled = pending !== '' || !canManage;
}
if (sendButton) {
sendButton.disabled = pending !== '' || !canReply || !replyText;
}
if (composer) {
composer.disabled = pending !== '' || !canReply;
}
if ($('messengerComposerHint')) {
$('messengerComposerHint').textContent = conversation
? canReply
? 'Ответ уйдёт через Telegram endpoint выбранного диалога.'
: canClaim
? 'Сначала нажмите «Взять в работу», после этого будет доступен ответ.'
: 'Ответ недоступен: диалог закрыт или закреплён за другим оператором.'
: 'Выберите диалог слева.';
}
}
function syncMessengerComposerHeight() {
const input = $('messengerReplyText');
if (!input) {
return;
}
input.style.height = '0px';
const nextHeight = Math.min(Math.max(input.scrollHeight, 54), 148);
input.style.height = `${nextHeight}px`;
}
function renderMessengerWorkspace() {
if (!$('messengerWorkspace')) {
return;
}
const conversation = selectedMessengerConversation();
const aiMeta = messengerAiStateMeta(conversation);
document.querySelectorAll('[data-messenger-filter]').forEach((button) => {
button.classList.toggle('active', button.dataset.messengerFilter === state.messenger.activeFilter);
});
renderMessengerConversations();
renderMessengerSummaryCard();
renderMessengerMessages();
renderMessengerContext();
if ($('messengerConversationAvatar')) {
$('messengerConversationAvatar').textContent = conversation ? customerInitials(conversation.display_name) : 'TG';
const [start, end] = telegramAvatarPalette(conversation ? conversation.display_name : 'Telegram');
$('messengerConversationAvatar').style.setProperty('--telegram-avatar-start', start);
$('messengerConversationAvatar').style.setProperty('--telegram-avatar-end', end);
}
if ($('messengerConversationTitle')) {
$('messengerConversationTitle').textContent = conversation ? conversation.display_name : 'Выберите диалог';
}
if ($('messengerConversationMeta')) {
$('messengerConversationMeta').textContent = conversation
? [conversation.channel_label, statusMeta(conversation.status).label, aiMeta?.label, conversation.claimed_by_user ? `оператор ${conversation.claimed_by_user}` : 'не закреплён']
.filter(Boolean)
.join(' · ')
: 'Выберите диалог слева.';
}
if ($('messengerChannelBadge')) {
$('messengerChannelBadge').textContent = conversation?.channel_label || 'Telegram';
}
if ($('messengerBackendError')) {
$('messengerBackendError').classList.toggle('hidden', !state.messenger.backendError);
const errorText = $('messengerBackendError').querySelector('span');
if (errorText) {
errorText.textContent = state.messenger.backendError || 'Backend сообщений недоступен.';
}
}
syncMessengerComposerHeight();
syncMessengerActionButtons();
}
function setMessengerFilter(filter) {
state.messenger.activeFilter = MESSENGER_FILTERS[filter] ? filter : 'all';
renderMessengerWorkspace();
}
async function loadMessengerMessages(threadId) {
if (!threadId) {
state.messenger.messages = [];
renderMessengerWorkspace();
return [];
}
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/messages`);
const messages = Array.isArray(data) ? data : [];
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.messages = messages;
if (state.telegram.selectedThreadId === threadId) {
state.telegram.messages = messages;
}
renderMessengerWorkspace();
}
return messages;
}
async function loadMessengerSummary(threadId) {
if (!threadId) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
return null;
}
const thread = state.telegram.threads.find((item) => item.thread_id === threadId) || null;
if (!telegramThreadSummaryAccessible(thread)) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
return null;
}
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/ai-summary`);
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.selectedSummary = data || null;
renderMessengerWorkspace();
}
return data || null;
} catch {
const conversation = selectedMessengerConversation();
if (conversation?.thread_id === threadId) {
state.messenger.selectedSummary = null;
renderMessengerWorkspace();
}
return null;
}
}
async function loadMessengerThreads(logResult = false, options = {}) {
const { preserveSelection = true, preserveOnError = false } = options;
try {
const data = await api('telegram', 'integrations/telegram/threads');
applyTelegramCollections(Array.isArray(data) ? data : [], { preserveSelection: true });
syncMessengerConversations({ preserveSelection });
state.messenger.backendError = '';
renderMessengerWorkspace();
const conversation = selectedMessengerConversation();
if (conversation?.thread_id) {
await Promise.all([
loadMessengerMessages(conversation.thread_id),
loadMessengerSummary(conversation.thread_id),
]);
}
if (logResult) {
log('Сообщения обновлены', { conversations: state.messenger.conversations.length });
}
} catch (err) {
state.messenger.backendError = err.message || 'Backend сообщений недоступен.';
if (!preserveOnError) {
state.messenger.conversations = [];
state.messenger.selectedConversationId = '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
}
renderMessengerWorkspace();
if (logResult) {
log('Не удалось загрузить сообщения', { error: err.message });
}
throw err;
}
}
async function selectMessengerConversation(conversationId) {
state.messenger.selectedConversationId = conversationId || '';
state.messenger.messages = [];
state.messenger.selectedSummary = null;
state.messenger.backendError = '';
const conversation = selectedMessengerConversation();
if (conversation?.channel === 'telegram') {
state.telegram.selectedThreadId = conversation.thread_id;
}
const customerId = messengerContextCustomerId(conversation);
if (customerId && state.customers.items.some((item) => item.customer_id === customerId)) {
selectCustomer(customerId);
ensureCustomerHistoryLoaded(customerId, { force: false }).catch(() => {});
}
renderMessengerWorkspace();
if (conversation?.thread_id) {
await Promise.all([
loadMessengerMessages(conversation.thread_id),
loadMessengerSummary(conversation.thread_id),
]);
}
}
function upsertMessengerTelegramThread(thread, options = {}) {
const exists = state.telegram.threads.some((item) => item.thread_id === thread.thread_id);
const nextThreads = exists
? state.telegram.threads.map((item) => (item.thread_id === thread.thread_id ? thread : item))
: [thread, ...state.telegram.threads];
applyTelegramCollections(nextThreads, { preserveSelection: true });
syncMessengerConversations({ preserveSelection: true, ...options });
}
async function claimMessengerConversation() {
const conversation = selectedMessengerConversation();
if (!conversation?.thread_id || !messengerConversationCanClaim(conversation)) {
return;
}
state.messenger.pendingAction = 'claim';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/claim`, {
method: 'POST',
});
upsertMessengerTelegramThread(data);
state.messenger.selectedConversationId = messengerConversationId('telegram', data.thread_id);
await loadMessengerSummary(data.thread_id);
loadInteractions().catch(() => {});
log('Диалог взят в работу из Сообщений', { thread_id: data.thread_id, interaction_id: data.interaction_id });
} catch (err) {
log('Не удалось взять диалог в работу', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
async function sendMessengerReply() {
const conversation = selectedMessengerConversation();
const text = String($('messengerReplyText')?.value || '').trim();
if (!conversation?.thread_id || !text || !messengerConversationCanReply(conversation)) {
syncMessengerActionButtons();
return;
}
state.messenger.pendingAction = 'reply';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
});
$('messengerReplyText').value = '';
state.messenger.composerText = '';
state.messenger.messages = [...state.messenger.messages, data];
if (state.telegram.selectedThreadId === conversation.thread_id) {
state.telegram.messages = [...state.telegram.messages, data];
}
const nextThreads = state.telegram.threads.map((item) => {
if (item.thread_id !== conversation.thread_id) {
return item;
}
return {
...item,
last_message_at: data.created_at,
last_message_preview: data.text,
};
});
applyTelegramCollections(nextThreads, { preserveSelection: true });
renderMessengerWorkspace();
log('Ответ из Сообщений отправлен через Telegram', { thread_id: conversation.thread_id, delivery_status: data.delivery_status || 'pending' });
window.setTimeout(() => loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch(() => {}), 0);
} catch (err) {
log('Не удалось отправить ответ из Сообщений', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
async function closeMessengerConversation() {
const conversation = selectedMessengerConversation();
if (!conversation?.thread_id || !messengerConversationCanManage(conversation)) {
return;
}
state.messenger.pendingAction = 'close';
renderMessengerWorkspace();
try {
const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/close`, {
method: 'POST',
});
upsertMessengerTelegramThread(data);
await loadMessengerSummary(data.thread_id);
loadInteractions().catch(() => {});
log('Диалог закрыт из Сообщений', { thread_id: data.thread_id });
} catch (err) {
log('Не удалось закрыть диалог из Сообщений', { error: err.message });
} finally {
state.messenger.pendingAction = '';
renderMessengerWorkspace();
}
}
function openMessengerCustomerProfile() {
const customerId = messengerContextCustomerId();
if (!customerId || !messengerCustomerProfileAvailable()) {
return;
}
openCustomerProfile(customerId);
}
function stopMessengerPolling() {
if (state.messenger.pollTimer) {
window.clearInterval(state.messenger.pollTimer);
state.messenger.pollTimer = null;
}
}
function startMessengerPolling() {
stopMessengerPolling();
state.messenger.pollTimer = window.setInterval(() => {
if (operatorHashState().view !== 'messages') {
return;
}
loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch(() => {});
}, 5000);
}
function selectedWhatsappChat() {
return state.whatsapp.chats.find((chat) => chat.id === state.whatsapp.selectedChatId) || null;
}
function ensureWhatsappSelection() {
if (!state.whatsapp.chats.length) {
state.whatsapp.selectedChatId = '';
return;
}
const exists = state.whatsapp.chats.some((chat) => chat.id === state.whatsapp.selectedChatId);
if (!exists) {
state.whatsapp.selectedChatId = state.whatsapp.chats[0].id;
}
}
function whatsappChatInitials(chat) {
const parts = String(chat?.title || 'WA')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) {
return 'WA';
}
return parts.map((part) => part[0].toUpperCase()).join('');
}
function whatsappChatTimeLabel(value) {
if (!value) {
return '';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
const now = new Date();
const sameDay = dt.toDateString() === now.toDateString();
if (sameDay) {
return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return dt.toLocaleDateString([], { day: '2-digit', month: 'short' });
}
function whatsappMessageTimeLabel(value) {
if (!value) {
return '';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function whatsappMessageDayKey(value) {
if (!value) {
return '';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
function whatsappMessageDayLabel(value) {
if (!value) {
return '';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (dt.toDateString() === today.toDateString()) {
return 'Today';
}
if (dt.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
}
return dt.toLocaleDateString([], { day: 'numeric', month: 'long' });
}
function whatsappVisibleChats() {
const query = String(state.whatsapp.searchQuery || '').trim().toLowerCase();
return state.whatsapp.chats.filter((chat) => {
if (state.whatsapp.activeFilter === 'unread' && !(whatsappThreadUnreadCount(chat) > 0)) {
return false;
}
if (state.whatsapp.activeFilter === 'groups' && !chat.isGroup) {
return false;
}
if (!query) {
return true;
}
return [
chat.title,
chat.handle,
chat.statusLine,
chat.lastMessagePreview,
...(chat.messages || []).map((message) => message.text || ''),
]
.join(' ')
.toLowerCase()
.includes(query);
});
}
function whatsappMessageStatusMarkup(message) {
if (message.direction !== 'out') {
return '';
}
if (message.deliveryStatus === 'read') {
return '<span class="whatsapp-message-checks read" aria-hidden="true">&#10003;&#10003;</span>';
}
if (message.deliveryStatus === 'delivered') {
return '<span class="whatsapp-message-checks delivered" aria-hidden="true">&#10003;&#10003;</span>';
}
return '<span class="whatsapp-message-checks" aria-hidden="true">&#10003;</span>';
}
function whatsappComposerBlockedReason(chat) {
if (!chat) {
return 'Выберите чат';
}
if (chat.status === 'closed') {
return 'Thread is closed';
}
if (state.role === 'operator' && chat.claimedByUser && chat.claimedByUser !== state.user) {
return `Claimed by ${chat.claimedByUser}`;
}
return '';
}
function syncWhatsappComposerHeight() {
const input = $('whatsappComposerInput');
if (!input) {
return;
}
input.style.height = 'auto';
input.style.height = `${Math.min(Math.max(input.scrollHeight, 24), 120)}px`;
}
function syncWhatsappComposerUi() {
const input = $('whatsappComposerInput');
const sendBtn = $('whatsappSendBtn');
if (!input || !sendBtn) {
return;
}
sendBtn.disabled = Boolean(whatsappComposerBlockedReason(selectedWhatsappChat()))
|| !String(state.whatsapp.composerText || '').trim()
|| Boolean(state.whatsapp.pendingAction);
syncWhatsappComposerHeight();
}
function renderWhatsappChatList() {
const target = $('whatsappChatList');
if (!target) {
return;
}
const chats = whatsappVisibleChats();
if (!chats.length) {
target.innerHTML = `
<div class="whatsapp-chat-list-empty">
<strong>Чаты не найдены</strong>
<p>Попробуйте другой запрос или переключите активный фильтр.</p>
</div>
`;
return;
}
target.innerHTML = chats.map((chat) => {
const [start, end] = whatsappAvatarPalette(chat.title);
const selected = chat.id === state.whatsapp.selectedChatId;
const aiMeta = whatsappThreadAiStateMeta(chat);
return `
<button
class="whatsapp-chat-card${selected ? ' selected' : ''}"
type="button"
data-whatsapp-chat-id="${escapeHtml(chat.id)}"
style="--whatsapp-avatar-start:${start};--whatsapp-avatar-end:${end};"
>
<div class="whatsapp-chat-card-avatar">${escapeHtml(whatsappChatInitials(chat))}</div>
<div class="whatsapp-chat-card-copy">
<div class="whatsapp-chat-card-head">
<span class="whatsapp-chat-card-title">${escapeHtml(chat.title)}</span>
<span class="whatsapp-chat-card-time">${escapeHtml(whatsappChatTimeLabel(chat.lastMessageAt))}</span>
</div>
<div class="whatsapp-chat-card-preview-row">
<span class="whatsapp-chat-card-preview">${escapeHtml(chat.lastMessagePreview || '')}</span>
${whatsappThreadUnreadCount(chat) > 0 ? `<span class="whatsapp-chat-unread">${whatsappThreadUnreadCount(chat)}</span>` : ''}
</div>
<div class="whatsapp-chat-card-meta">
<span>${escapeHtml(whatsappThreadHandle(chat))}</span>
${aiMeta ? `<span class="whatsapp-chat-tag ai ${aiMeta.tone}">${escapeHtml(aiMeta.label)}</span>` : ''}
${chat.claimedByUser ? `<span class="whatsapp-chat-tag owner">${escapeHtml(chat.claimedByUser === state.user ? 'Мой чат' : `Владелец ${chat.claimedByUser}`)}</span>` : ''}
${chat.muted ? '<span class="whatsapp-chat-tag">Без звука</span>' : ''}
${chat.isGroup ? '<span class="whatsapp-chat-tag">Группа</span>' : ''}
</div>
</div>
</button>
`;
}).join('');
}
function renderWhatsappContextPanel() {
const panel = $('whatsappContextPanel');
const aiChip = $('whatsappAiStateChip');
const ownerChip = $('whatsappOwnerChip');
const claimBtn = $('whatsappClaimBtn');
const returnBtn = $('whatsappReturnToAiBtn');
const chat = selectedWhatsappChat();
if (!panel || !aiChip || !ownerChip || !claimBtn || !returnBtn) {
return;
}
const aiMeta = whatsappThreadAiStateMeta(chat);
const ownerText = whatsappThreadOwnerText(chat);
const showPanel = Boolean(chat && (aiMeta || chat.claimedByUser || whatsappSelectedSummaryVisible(chat)));
panel.classList.toggle('hidden', !showPanel);
if (aiMeta) {
aiChip.textContent = aiMeta.label;
aiChip.className = `whatsapp-state-chip ${aiMeta.tone}`;
} else {
aiChip.textContent = '';
aiChip.className = 'whatsapp-state-chip hidden';
}
if (chat) {
ownerChip.textContent = ownerText;
ownerChip.className = 'whatsapp-state-chip subtle';
} else {
ownerChip.textContent = '';
ownerChip.className = 'whatsapp-state-chip subtle hidden';
}
claimBtn.disabled = Boolean(state.whatsapp.pendingAction) || !whatsappThreadCanClaim(chat);
claimBtn.classList.toggle('hidden', !whatsappThreadCanClaim(chat));
returnBtn.disabled = Boolean(state.whatsapp.pendingAction) || !whatsappThreadCanReturnToAi(chat);
returnBtn.classList.toggle('hidden', !whatsappThreadCanReturnToAi(chat));
renderWhatsappSummaryCard();
}
function renderWhatsappMessagesTimeline() {
const target = $('whatsappMessageTimeline');
if (!target) {
return;
}
const chat = selectedWhatsappChat();
if (!chat) {
target.innerHTML = `
<div class="whatsapp-chat-empty">
<strong>Выберите чат</strong>
<p>${state.whatsapp.mode === 'live' ? 'Здесь появятся живые WhatsApp-диалоги.' : 'Демо-режим остаётся доступным, пока backend недоступен.'}</p>
</div>
`;
return;
}
if (!chat.messages?.length) {
target.innerHTML = `
<div class="whatsapp-chat-empty">
<strong>Пока нет сообщений</strong>
<p>${state.whatsapp.mode === 'live' ? 'Ждём входящую активность WhatsApp в этом чате.' : 'Демо-рабочее место готово к первому сообщению.'}</p>
</div>
`;
return;
}
const fragments = [
'<div class="whatsapp-encryption-pill">Сообщения и звонки защищены сквозным шифрованием. Никто вне этого чата не может их прочитать или прослушать.</div>',
];
let lastDayKey = '';
(chat.messages || []).forEach((message) => {
const dayKey = whatsappMessageDayKey(message.createdAt);
if (message.direction === 'system') {
fragments.push(`<div class="whatsapp-day-pill">${escapeHtml(message.text || whatsappMessageDayLabel(message.createdAt))}</div>`);
lastDayKey = dayKey || lastDayKey;
return;
}
if (dayKey && dayKey !== lastDayKey) {
lastDayKey = dayKey;
fragments.push(`<div class="whatsapp-day-pill">${escapeHtml(whatsappMessageDayLabel(message.createdAt))}</div>`);
}
const mine = message.direction === 'out';
const aiBadge = mine && message.authorType === 'ai'
? '<span class="whatsapp-message-badge ai">AI</span>'
: '';
const author = !mine && chat.isGroup && message.author
? `<div class="whatsapp-message-author">${escapeHtml(message.author)}</div>`
: '';
fragments.push(`
<article class="whatsapp-message-row ${mine ? 'me' : 'other'}">
<div class="whatsapp-message-bubble ${mine ? 'me' : 'other'}">
${aiBadge}
${author}
<div class="whatsapp-message-body">${escapeHtml(message.text || '')}</div>
<div class="whatsapp-message-meta">
<span class="whatsapp-message-time">${escapeHtml(whatsappMessageTimeLabel(message.createdAt))}</span>
${whatsappMessageStatusMarkup(message)}
</div>
</div>
</article>
`);
});
target.innerHTML = fragments.join('');
target.scrollTop = target.scrollHeight;
}
function renderWhatsappWorkspace() {
if (!featureEnabled('whatsapp')) {
return;
}
ensureWhatsappSelection();
renderWhatsappChatList();
renderWhatsappContextPanel();
renderWhatsappMessagesTimeline();
const chat = selectedWhatsappChat();
const searchInput = $('whatsappSearchInput');
const composerInput = $('whatsappComposerInput');
const avatar = $('whatsappChatAvatar');
const paneCaption = $('whatsappPaneCaption');
const blockedReason = whatsappComposerBlockedReason(chat);
const [start, end] = whatsappAvatarPalette(chat?.title || 'WhatsApp');
if (searchInput && searchInput.value !== state.whatsapp.searchQuery) {
searchInput.value = state.whatsapp.searchQuery;
}
if (composerInput && composerInput.value !== state.whatsapp.composerText) {
composerInput.value = state.whatsapp.composerText;
}
if (composerInput) {
composerInput.disabled = !chat || Boolean(blockedReason) || Boolean(state.whatsapp.pendingAction);
composerInput.placeholder = !chat
? 'Выберите чат'
: blockedReason || 'Написать сообщение';
}
if (avatar) {
avatar.textContent = chat ? whatsappChatInitials(chat) : 'WA';
avatar.style.setProperty('--whatsapp-avatar-start', start);
avatar.style.setProperty('--whatsapp-avatar-end', end);
}
if (paneCaption) {
paneCaption.textContent = state.whatsapp.mode === 'live'
? 'Живая очередь с поддержкой AI'
: state.whatsapp.mode === 'mock'
? 'Демо-режим, пока backend недоступен'
: 'Подключаемся к backend WhatsApp';
}
$('whatsappChatTitle').textContent = chat ? chat.title : 'WhatsApp';
$('whatsappChatMeta').textContent = chat
? [whatsappThreadHandle(chat), whatsappThreadPresenceText(chat)].filter(Boolean).join(' • ')
: 'Выберите чат для начала переписки.';
document.querySelectorAll('[data-whatsapp-filter]').forEach((button) => {
button.classList.toggle('active', button.dataset.whatsappFilter === state.whatsapp.activeFilter);
});
syncWhatsappComposerUi();
}
async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) {
if (!featureEnabled('whatsapp')) {
return [];
}
if (!threadId) {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
return [];
}
if (state.whatsapp.mode !== 'live') {
return selectedWhatsappChat()?.messages || [];
}
const chat = state.whatsapp.chats.find((item) => item.id === threadId) || null;
const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(threadId)}/messages`);
const messages = Array.isArray(data)
? data.map((message) => normalizeWhatsappMessage(message, chat))
: [];
updateWhatsappChatMessages(threadId, messages);
if (logResult) {
log('WhatsApp thread messages обновлены', { thread_id: threadId, messages: messages.length });
}
return messages;
}
async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) {
if (!featureEnabled('whatsapp')) {
return null;
}
const { logResult = false } = options;
if (!threadId || state.whatsapp.mode !== 'live') {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
return null;
}
const thread = state.whatsapp.chats.find((item) => item.id === threadId) || null;
if (!whatsappThreadSummaryAccessible(thread)) {
if (threadId === state.whatsapp.selectedChatId) {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
}
return null;
}
try {
const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(threadId)}/ai-summary`);
if (threadId === state.whatsapp.selectedChatId) {
state.whatsapp.selectedThreadSummary = data || null;
renderWhatsappWorkspace();
}
if (logResult && data) {
log('AI-сводка WhatsApp загружена', { thread_id: threadId, session_id: data.session_id });
}
return data || null;
} catch (err) {
if (threadId === state.whatsapp.selectedChatId) {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
}
if (logResult) {
log('Не удалось загрузить AI-сводку WhatsApp', { thread_id: threadId, error: err.message });
}
return null;
}
}
async function loadWhatsappThreads(logResult = true, options = {}) {
if (!featureEnabled('whatsapp')) {
return [];
}
const {
preserveSelection = true,
preserveOnError = false,
allowMockFallback = true,
} = options;
try {
const data = await api('whatsapp', 'integrations/whatsapp/threads');
const existingById = new Map(state.whatsapp.chats.map((chat) => [chat.id, chat]));
const chats = (Array.isArray(data) ? data : []).map((thread) => normalizeWhatsappThread(thread, existingById.get(thread.thread_id)));
state.whatsapp.mode = 'live';
state.whatsapp.backendError = '';
applyWhatsappChats(chats, { preserveSelection });
await loadWhatsappThreadMessages(state.whatsapp.selectedChatId, false);
await loadWhatsappThreadSummary(state.whatsapp.selectedChatId, { logResult: false });
if (logResult) {
log('WhatsApp threads обновлены', {
threads: state.whatsapp.chats.length,
ai_threads: state.whatsapp.chats.filter((chat) => chat.aiState).length,
});
}
} catch (err) {
state.whatsapp.backendError = err.message;
if (!state.whatsapp.chats.length && allowMockFallback) {
seedWhatsappChats();
renderWhatsappWorkspace();
if (logResult || !state.whatsapp.mockFallbackLogged) {
log('WhatsApp backend недоступен, включён demo fallback', { error: err.message });
state.whatsapp.mockFallbackLogged = true;
}
return;
}
if (!preserveOnError) {
state.whatsapp.chats = [];
state.whatsapp.selectedChatId = '';
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
}
if (logResult) {
log('Не удалось загрузить WhatsApp threads', { error: err.message });
}
}
}
async function selectWhatsappChat(chatId) {
if (!chatId) {
return;
}
if (chatId !== state.whatsapp.selectedChatId) {
state.whatsapp.selectedThreadSummary = null;
}
state.whatsapp.selectedChatId = chatId;
state.whatsapp.composerText = '';
state.whatsapp.chats = state.whatsapp.chats.map((chat) => ({
...chat,
unreadCount: chat.id === chatId ? 0 : chat.unreadCount,
}));
renderWhatsappWorkspace();
if (state.whatsapp.mode === 'live') {
await loadWhatsappThreadMessages(chatId, false);
await loadWhatsappThreadSummary(chatId, { logResult: false });
}
window.requestAnimationFrame(() => {
$('whatsappComposerInput')?.focus();
});
}
function setWhatsappFilter(filter) {
state.whatsapp.activeFilter = filter || 'all';
renderWhatsappWorkspace();
}
async function claimWhatsappThread(options = {}) {
const { silent = false } = options;
const chat = selectedWhatsappChat();
if (!chat) {
return null;
}
const shouldManagePending = !state.whatsapp.pendingAction;
if (shouldManagePending) {
state.whatsapp.pendingAction = 'claim';
renderWhatsappWorkspace();
}
try {
const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(chat))}/claim`, {
method: 'POST',
});
upsertWhatsappThreadData(data);
await loadWhatsappThreadSummary(data.thread_id, { logResult: false });
loadInteractions().catch(() => {});
if (!silent) {
log('WhatsApp thread взят в работу', { thread_id: data.thread_id, interaction_id: data.interaction_id });
}
return selectedWhatsappChat();
} catch (err) {
if (!silent) {
log('Не удалось взять WhatsApp thread в работу', { error: err.message });
}
throw err;
} finally {
if (shouldManagePending) {
state.whatsapp.pendingAction = '';
renderWhatsappWorkspace();
}
}
}
async function returnWhatsappThreadToAi() {
const chat = selectedWhatsappChat();
if (!chat) {
return;
}
state.whatsapp.pendingAction = 'return-ai';
renderWhatsappWorkspace();
try {
const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(chat))}/return-to-ai`, {
method: 'POST',
});
upsertWhatsappThreadData(data);
await loadWhatsappThreadSummary(data.thread_id, { logResult: false });
loadInteractions().catch(() => {});
log('WhatsApp thread возвращён AI', { thread_id: data.thread_id, interaction_id: data.interaction_id });
} catch (err) {
log('Не удалось вернуть WhatsApp thread AI', { error: err.message });
} finally {
state.whatsapp.pendingAction = '';
renderWhatsappWorkspace();
}
}
async function sendWhatsappMessage() {
const chat = selectedWhatsappChat();
const text = String(state.whatsapp.composerText || '').trim();
if (!chat || !text) {
return;
}
const blockedReason = whatsappComposerBlockedReason(chat);
if (blockedReason) {
log('Нельзя отправить WhatsApp reply', { reason: blockedReason, thread_id: whatsappChatThreadId(chat) });
return;
}
if (state.whatsapp.mode !== 'live') {
const now = new Date().toISOString();
const outgoing = {
id: `wa-local-${Date.now()}`,
direction: 'out',
text,
createdAt: now,
deliveryStatus: 'read',
authorType: 'human',
};
state.whatsapp.chats = sortWhatsappChats(state.whatsapp.chats.map((item) => {
if (item.id !== chat.id) {
return item;
}
return {
...item,
unreadCount: 0,
lastMessageAt: now,
lastMessagePreview: text,
messages: [...(item.messages || []), outgoing],
};
}));
state.whatsapp.selectedChatId = chat.id;
state.whatsapp.composerText = '';
renderWhatsappWorkspace();
window.requestAnimationFrame(() => {
$('whatsappComposerInput')?.focus();
});
log('WhatsApp local reply saved', { chat_id: chat.id });
return;
}
state.whatsapp.pendingAction = 'reply';
renderWhatsappWorkspace();
try {
let activeChat = selectedWhatsappChat() || chat;
if (state.role === 'operator' && !activeChat.claimedByUser) {
const claimedChat = await claimWhatsappThread({ silent: true });
activeChat = claimedChat || selectedWhatsappChat() || activeChat;
}
const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(activeChat))}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
});
const outgoing = normalizeWhatsappMessage(data, activeChat);
state.whatsapp.composerText = '';
const nextChats = state.whatsapp.chats.map((item) => {
if (item.id !== activeChat.id) {
return item;
}
return {
...item,
claimedByUser: item.claimedByUser || (state.role === 'operator' ? state.user : ''),
aiState: 'human_owned',
unreadCount: 0,
lastMessageAt: outgoing.createdAt,
lastMessagePreview: outgoing.text,
messages: [...(item.messages || []), outgoing],
};
});
applyWhatsappChats(nextChats);
await loadWhatsappThreadSummary(whatsappChatThreadId(activeChat), { logResult: false });
log('WhatsApp reply поставлен в очередь', {
thread_id: whatsappChatThreadId(activeChat),
delivery_status: data.delivery_status || 'pending',
});
window.setTimeout(() => {
loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true, allowMockFallback: false });
}, 0);
} catch (err) {
log('Не удалось отправить WhatsApp reply', { error: err.message });
} finally {
state.whatsapp.pendingAction = '';
renderWhatsappWorkspace();
window.requestAnimationFrame(() => {
$('whatsappComposerInput')?.focus();
});
}
}
function startWhatsappPolling() {
if (!featureEnabled('whatsapp')) {
stopWhatsappPolling();
return;
}
stopWhatsappPolling();
state.whatsapp.pollTimer = window.setInterval(() => {
loadWhatsappThreads(false, {
preserveSelection: true,
preserveOnError: true,
allowMockFallback: state.whatsapp.mode !== 'live',
});
}, 5000);
}
function handleWhatsappUiAction(event) {
if (!featureEnabled('whatsapp')) {
return;
}
const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно';
if (action === 'chats') {
return;
}
if (action === 'search-chat') {
$('whatsappSearchInput')?.focus();
return;
}
if (action === 'new-chat') {
$('whatsappSearchInput')?.focus();
log('Создание нового WhatsApp чата пока не подключено');
return;
}
log('WhatsApp UI action пока без backend-привязки', { action, mode: state.whatsapp.mode });
}
function toggleCustomerLeadForm(forceOpen = null) {
state.customers.leadFormOpen = forceOpen === null ? !state.customers.leadFormOpen : Boolean(forceOpen);
syncCustomerFormUi();
}
function exportCustomersCsv() {
if (!state.customers.items.length) {
log('Нечего экспортировать: список лидов пуст.');
return;
}
const csv = [
['customer_id', 'display_name', 'phone', 'source', 'status', 'score'],
...state.customers.items.map((item, index) => [
item.customer_id,
item.display_name,
(item.phones || []).join(', '),
customerLeadSourceMeta(item, index).label,
customerLeadStatusMeta(item, index).label,
customerLeadScore(item, index),
]),
]
.map((row) => row.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'leads.csv';
link.click();
URL.revokeObjectURL(url);
log('Экспорт лидов подготовлен', { rows: state.customers.items.length });
}
function statusMeta(status) {
return STATUS_META[status] || { label: status || 'неизвестно', className: 'status-closed' };
}
function channelLabel(channel) {
return CHANNEL_LABELS[channel] || channel || 'Канал не указан';
}
function formatIsoShort(value) {
if (!value) {
return 'н/д';
}
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return value;
}
return dt.toLocaleString();
}
function renderSummaryCard(label, value, note = '') {
return `
<div class="summary-card">
<div class="summary-label">${label}</div>
<div class="summary-value">${value}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
</div>
`;
}
function updateProfileMeta() {
const profileName = $('profileName');
const profileRole = $('profileRole');
if (!profileName || !profileRole) {
return;
}
profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC';
profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь';
}
function operatorHashState(hash = window.location.hash) {
const raw = String(hash || '').replace(/^#/, '');
if (raw.startsWith('customer-profile/')) {
return { view: 'customer-profile', anchor: '', customerId: decodeURIComponent(raw.slice('customer-profile/'.length)) };
}
if (raw === 'customer-profile') {
return { view: 'customer-profile', anchor: '', customerId: '' };
}
if (!raw || raw === 'workspace') {
return { view: 'workspace', anchor: '', customerId: '' };
}
if (raw === 'customers-page' || raw === 'customers') {
return { view: 'customers', anchor: '', customerId: '' };
}
if (raw === 'messages-page' || raw === 'messages') {
return { view: 'messages', anchor: '', customerId: '' };
}
if (raw === 'telegram-page' || raw === 'telegram') {
return { view: 'telegram', anchor: '', customerId: '' };
}
if (raw === 'whatsapp-page' || raw === 'whatsapp') {
return { view: 'whatsapp', anchor: '', customerId: '' };
}
if (raw === 'calls' || raw === 'calls-page' || raw === 'voice-debug' || raw === 'voice-debug-page') {
return { view: 'calls', anchor: '', customerId: '' };
}
if (raw === 'interactions' || raw === 'integrations') {
return { view: 'workspace', anchor: raw, customerId: '' };
}
return { view: 'workspace', anchor: '', customerId: '' };
}
function syncOperatorViewNav(view, anchor = '') {
document.querySelectorAll('[data-operator-view-link]').forEach((link) => {
const linkView = link.dataset.operatorViewLink;
const isCustomers = view === 'customer-profile' && linkView === 'customers';
link.classList.toggle('active', linkView === view || isCustomers);
});
document.querySelectorAll('[data-operator-anchor-link]').forEach((link) => {
link.classList.toggle('active', view === 'workspace' && link.dataset.operatorAnchorLink === anchor);
});
}
function applyOperatorViewFromHash(options = {}) {
const { scroll = false } = options;
let { view, anchor, customerId } = operatorHashState();
const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`);
if (!isOperatorViewEnabled(view) || (viewLink && (viewLink.hidden || viewLink.style.display === 'none'))) {
view = 'workspace';
anchor = '';
window.history.replaceState(null, '', '#workspace');
} else if (view === 'calls' && (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page')) {
window.history.replaceState(null, '', '#calls');
}
if (customerId) {
state.customers.selectedCustomerId = customerId;
}
if (view === 'customer-profile' && !selectedCustomer()) {
view = 'customers';
window.history.replaceState(null, '', '#customers-page');
}
Object.entries(OPERATOR_VIEW_IDS).forEach(([key, id]) => {
$(id)?.classList.toggle('hidden', key !== view);
});
syncOperatorViewNav(view, anchor);
if (view === 'messages') {
renderMessengerWorkspace();
startMessengerPolling();
loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true }).catch((err) => {
state.messenger.backendError = err.message || 'Не удалось загрузить сообщения.';
renderMessengerWorkspace();
});
} else {
stopMessengerPolling();
}
const targetId = view === 'workspace' ? anchor : '';
window.requestAnimationFrame(() => {
if (targetId && $(targetId)) {
$(targetId).scrollIntoView({ behavior: scroll ? 'smooth' : 'auto', block: 'start' });
return;
}
window.scrollTo({ top: 0, behavior: scroll ? 'smooth' : 'auto' });
});
}
function handleOperatorHashChange() {
applyOperatorViewFromHash({ scroll: true });
}
function renderSupervisorSummary(data) {
const agents = data?.agents || {};
const byState = agents.by_state || {};
const firstQueue = (data?.queues || [])[0] || {};
const total = Number(agents.total || 0);
const ready = Number(byState.READY || 0);
const busy = Number(byState.BUSY || 0);
const queueId = firstQueue.queue_id || 'нет очереди';
const inQueue = Number(firstQueue.in_queue || 0);
$('supervisorSummary').innerHTML = [
renderSummaryCard('Агентов', total, 'Текущее состояние смены'),
renderSummaryCard('Готовы', ready, 'Могут взять следующий контакт'),
renderSummaryCard('Заняты', busy, 'Сейчас в обработке'),
renderSummaryCard('Очередь', queueId, `В очереди: ${inQueue}`),
].join('');
}
function renderKpiSummary(data) {
const kpi = data?.kpi || {};
$('kpiSummary').innerHTML = [
renderSummaryCard('SL', `${Number(kpi.SL || 0).toFixed(2)}%`, 'Доля контактов в SLA'),
renderSummaryCard('ASA', `${Number(kpi.ASA || 0).toFixed(2)} с`, 'Среднее время ожидания'),
renderSummaryCard('AHT', `${Number(kpi.AHT || 0).toFixed(2)} с`, 'Среднее время обработки'),
renderSummaryCard('Abandon', `${Number(kpi.Abandon || 0).toFixed(2)}%`, 'Потерянные обращения'),
renderSummaryCard('FCR', `${Number(kpi.FCR || 0).toFixed(2)}%`, 'Решено с первого контакта'),
].join('');
}
function getDefaultAssignee() {
return $('defaultAssignee').value.trim() || DEMO_ASSIGNEE;
}
function getDefaultEscalationQueue() {
return $('defaultEscalationQueue').value.trim() || DEMO_QUEUE;
}
function updateSessionInfo(extra = '') {
if (!state.token) {
$('sessionInfo').textContent = 'Токен: отсутствует';
return;
}
const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный';
$('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`;
}
function applyAuthenticatedSession(data) {
state.token = data.access_token;
state.role = data.role;
state.user = data.username || state.user;
state.authSource = data.auth_source || 'local';
state.fullName = data.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = data.role;
updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : '');
updateProfileMeta();
persistSession();
applyRoleNavigation();
}
async function api(service, path, options = {}) {
syncSessionFromInputs();
const headers = {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/${service}/${path}`, {
...options,
headers,
});
let data;
try {
data = await response.json();
} catch {
data = { error: 'Сервис вернул ответ не в JSON-формате' };
}
if (!response.ok) {
if (response.status === 401 && state.token) {
state.token = null;
state.authSource = 'local';
clearStoredSession();
updateSessionInfo('требуется повторный вход');
window.location.href = '/';
}
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function checkGateway() {
try {
const res = await fetch('/health');
const data = await res.json();
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
} catch {
$('gatewayStatus').textContent = 'Шлюз: недоступен';
}
}
async function loadOperatorConfig() {
try {
const response = await fetch('/operator/config');
const data = await response.json();
state.features.whatsapp = Boolean(data?.features?.whatsapp);
} catch {
state.features.whatsapp = false;
}
syncWhatsappFeatureVisibility();
}
async function loadOidcConfig() {
try {
const response = await fetch('/proxy/auth/auth/oidc/config');
const data = await response.json();
state.oidc.enabled = Boolean(data.enabled);
state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup';
state.oidc.providerLabel = data.provider_label || 'Keycloak';
$('corporateLoginBtn').disabled = !state.oidc.enabled;
$('corporateLoginBtn').textContent = state.oidc.enabled
? `Корпоративный вход (${state.oidc.providerLabel})`
: 'Корпоративный вход недоступен';
} catch {
$('corporateLoginBtn').disabled = true;
$('corporateLoginBtn').textContent = 'Корпоративный вход недоступен';
}
}
function startCorporateLogin() {
if (!state.oidc.enabled) {
log('Корпоративный вход недоступен');
return;
}
const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
if (!popup) {
log('Не удалось открыть окно корпоративного входа');
return;
}
log('Открыт корпоративный вход', { провайдер: state.oidc.providerLabel });
}
function handleOidcMessage(event) {
if (!event?.data || typeof event.data !== 'object') {
return;
}
if (event.data.type === 'oidc-login' && event.data.access_token) {
applyAuthenticatedSession(event.data);
log('Корпоративный вход выполнен', { user: state.user, role: state.role });
return;
}
if (event.data.type === 'oidc-error') {
log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' });
updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`);
}
}
async function login() {
syncSessionFromInputs();
const password = $('sessionPassword').value;
try {
const data = await api('auth', 'auth/login', {
method: 'POST',
body: JSON.stringify({ username: state.user, password }),
});
applyAuthenticatedSession({
...data,
username: state.user,
auth_source: data.auth_source || 'local',
});
log('Вход выполнен', { user: state.user, role: data.role });
} catch (err) {
$('sessionInfo').textContent = `Ошибка входа: ${err.message}`;
log('Вход не выполнен', { error: err.message });
}
}
async function createCustomer() {
const displayName = $('customerName').value.trim();
const phone = $('customerPhone').value.trim();
if (!displayName) {
return;
}
const payload = {
display_name: displayName,
phones: phone ? [phone] : [],
preferred_phone: phone || null,
tags: [],
};
try {
const data = await api('customer', 'customers', {
method: 'POST',
body: JSON.stringify(payload),
});
$('interactionCustomerId').value = data.customer_id;
$('customerName').value = '';
$('customerPhone').value = '';
toggleCustomerLeadForm(false);
log('Клиент создан', { customer_id: data.customer_id, name: data.display_name });
await searchCustomers();
} catch (err) {
log('Не удалось создать клиента', { error: err.message });
}
}
async function searchCustomers() {
const rawQuery = $('customerQuery').value.trim();
const q = encodeURIComponent(rawQuery);
try {
const data = await api('customer', `customers${q ? `?query=${q}` : ''}`);
if (!data.length) {
state.customers.items = [];
state.customers.page = 1;
state.customers.selectedCustomerId = '';
renderCustomerList();
renderUnifiedInbox();
return;
}
if (!$('interactionCustomerId').value.trim()) {
$('interactionCustomerId').value = data[0].customer_id;
}
state.customers.items = data;
state.customers.page = 1;
const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId);
state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id;
renderCustomerList();
renderUnifiedInbox();
} catch (err) {
state.customers.items = [];
state.customers.page = 1;
state.customers.selectedCustomerId = '';
renderCustomerList();
renderUnifiedInbox();
log('Не удалось загрузить клиентов', { error: err.message });
}
}
async function createInteraction() {
const payload = {
channel: $('interactionChannel').value,
subject: $('interactionSubject').value.trim() || 'Операционный контакт',
customer_id: $('interactionCustomerId').value.trim() || null,
queue_id: 'q_main',
priority: 3,
};
try {
const data = await api('interaction', 'interactions', {
method: 'POST',
body: JSON.stringify(payload),
});
log('Обращение создано', { interaction_id: data.interaction_id, channel: data.channel });
await loadInteractions();
} catch (err) {
log('Не удалось создать обращение', { error: err.message });
}
}
function renderInteractionCard(item) {
const meta = statusMeta(item.status);
const facebookLead = isFacebookLeadInteraction(item);
const cardClass = item.status === 'escalated'
? 'pipeline-card escalated'
: item.status === 'closed'
? 'pipeline-card closed'
: 'pipeline-card';
const badges = [
facebookLead ? '<span class="micro-badge facebook">Facebook</span>' : '',
`<span class="micro-badge channel">${escapeHtml(channelLabel(item.channel))}</span>`,
item.queue_id ? `<span class="micro-badge queue">${escapeHtml(item.queue_id)}</span>` : '',
item.assigned_to ? `<span class="micro-badge assignee">${escapeHtml(item.assigned_to)}</span>` : '',
].filter(Boolean).join('');
return `
<article class="${cardClass}">
<div class="card-badges">${badges}</div>
<div class="row-item-head">
<h3 class="card-title">${escapeHtml(item.subject || 'Без темы')}</h3>
<span class="status-chip ${meta.className}">${escapeHtml(meta.label)}</span>
</div>
<p class="card-subtitle">${escapeHtml(item.interaction_id)}</p>
<p class="card-meta-line">Клиент: ${escapeHtml(item.customer_id || 'не привязан')}</p>
<p class="card-meta-line">Обновлено: ${escapeHtml(formatIsoShort(item.updated_at))}</p>
<div class="card-divider"></div>
<div class="row-actions">
${facebookLead ? `<button type="button" data-reply-action="facebook" data-interaction-id="${escapeHtml(item.interaction_id)}">Ответить</button>` : ''}
<button onclick="assignInteraction('${item.interaction_id}')">Назначить</button>
<button onclick="escalateInteraction('${item.interaction_id}')">Передать на 2 линию</button>
<button onclick="closeInteraction('${item.interaction_id}')">Закрыть</button>
</div>
</article>
`;
}
function renderInteractionBoard(items) {
const groups = {
new: [],
in_progress: [],
escalated: [],
closed: [],
};
items.forEach((item) => {
const bucket = groups[item.status] ? item.status : 'closed';
groups[bucket].push(item);
});
return `
<div class="pipeline-board">
${BOARD_COLUMNS.map((column) => {
const cards = groups[column.key];
const content = cards.length
? cards.map((item) => renderInteractionCard(item)).join('')
: '<div class="empty-state">Пока пусто.</div>';
return `
<section class="pipeline-column">
<div class="pipeline-head">
<div class="pipeline-title">${column.title}</div>
<div class="pipeline-count">${cards.length}</div>
</div>
<div class="pipeline-stack">
${content}
</div>
</section>
`;
}).join('')}
</div>
`;
}
function unifiedInboxSortValue(...values) {
for (const value of values) {
const parsed = new Date(value || '').getTime();
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return 0;
}
function unifiedInboxInteractionBucket(item) {
if (!item || item.status === 'closed') {
return '';
}
if (item.status === 'escalated') {
return 'escalated';
}
if (String(item.assigned_to || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
if (item.status === 'new' || !String(item.assigned_to || '').trim()) {
return 'new';
}
return '';
}
function unifiedInboxTelegramBucket(thread) {
if (!thread || thread.status === 'closed') {
return '';
}
if (['handoff_required', 'human_owned'].includes(String(thread.ai_state || '').trim())) {
return 'ai';
}
if (String(thread.claimed_by_user || '').trim() === String(state.user || '').trim()) {
return 'mine';
}
return 'new';
}
function renderUnifiedInboxBadge(label, className = '') {
if (!label) {
return '';
}
return `<span class="micro-badge ${className}">${escapeHtml(label)}</span>`;
}
function unifiedInboxActionLabel(item) {
if (item.kind === 'telegram') {
return 'Открыть чат';
}
if (item.kind === 'call') {
return 'Открыть звонок';
}
return 'К обращению';
}
function buildUnifiedInboxInteractionItem(item) {
const bucket = unifiedInboxInteractionBucket(item);
if (!bucket) {
return null;
}
const facebookLead = isFacebookLeadInteraction(item);
const badges = [
facebookLead ? renderUnifiedInboxBadge('Facebook', 'facebook') : '',
renderUnifiedInboxBadge(channelLabel(item.channel), 'channel'),
item.queue_id ? renderUnifiedInboxBadge(item.queue_id, 'queue') : '',
item.assigned_to ? renderUnifiedInboxBadge(item.assigned_to, 'owner') : '',
].filter(Boolean);
return {
key: `interaction:${item.interaction_id}`,
kind: 'interaction',
bucket,
sortValue: unifiedInboxSortValue(item.updated_at, item.created_at),
title: item.subject || 'Обращение без темы',
subtitle: item.interaction_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(item.customer_id || '')}`,
`Статус: ${statusMeta(item.status).label}`,
`Обновлено: ${formatIsoShort(item.updated_at || item.created_at)}`,
],
customerId: item.customer_id || '',
interactionId: item.interaction_id,
canReply: facebookLead,
};
}
function buildUnifiedInboxTelegramItem(thread) {
const bucket = unifiedInboxTelegramBucket(thread);
if (!bucket) {
return null;
}
const customerId = customerIdForInteractionId(thread.interaction_id);
const aiMeta = telegramThreadAiStateMeta(thread);
const unreadCount = telegramThreadUnreadCount(thread);
const badges = [
renderUnifiedInboxBadge('Telegram', 'channel'),
unreadCount > 0 ? renderUnifiedInboxBadge(`${unreadCount} new`, 'queue') : '',
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.tone) : '',
thread.claimed_by_user ? renderUnifiedInboxBadge(thread.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `telegram:${thread.thread_id}`,
kind: 'telegram',
bucket,
sortValue: unifiedInboxSortValue(thread.last_message_at, thread.updated_at, thread.created_at),
title: telegramThreadDisplayName(thread),
subtitle: telegramThreadSummary(thread),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
thread.last_message_preview ? `Последнее: ${thread.last_message_preview}` : 'Последнее сообщение пока не загружено',
`Статус: ${telegramThreadHeaderPresence(thread)}`,
],
customerId,
interactionId: thread.interaction_id || '',
threadId: thread.thread_id,
};
}
function buildUnifiedInboxCallItem(item) {
const summary = voiceSummaryForItem(item);
const aiMeta = voiceAiStatusMeta(item);
const interaction = interactionById(item.interaction_id || '');
const customerId = interaction?.customer_id || '';
const caller = voiceCustomerDisplayName(item, summary);
const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status);
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '',
nameStatusMeta ? renderUnifiedInboxBadge(nameStatusMeta.shortLabel, nameStatusMeta.className) : '',
item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `call:${item.call_id}`,
kind: 'call',
bucket: 'calls',
sortValue: unifiedInboxSortValue(item.started_at, item.updated_at),
title: caller,
subtitle: voiceCustomerCallSubtitle(item),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Контакт: ${voiceCustomerCallSubtitle(item)}`,
summary ? `Имя: ${voiceCustomerNameStateLine(summary) || 'без подтверждения'}` : '',
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
].filter(Boolean),
customerId,
interactionId: item.interaction_id || '',
callId: item.call_id,
};
}
function buildUnifiedInboxCollections() {
const collections = {
new: [],
mine: [],
ai: [],
escalated: [],
calls: [],
};
const representedInteractionIds = new Set();
state.telegram.threads.forEach((thread) => {
if (thread?.interaction_id && thread.status !== 'closed') {
representedInteractionIds.add(thread.interaction_id);
}
const item = buildUnifiedInboxTelegramItem(thread);
if (item) {
collections[item.bucket].push(item);
}
});
state.liveCalls.items.forEach((call) => {
if (call?.interaction_id) {
representedInteractionIds.add(call.interaction_id);
}
const item = buildUnifiedInboxCallItem(call);
if (item) {
collections.calls.push(item);
}
});
state.interactions.forEach((item) => {
if (representedInteractionIds.has(item.interaction_id)) {
return;
}
const inboxItem = buildUnifiedInboxInteractionItem(item);
if (inboxItem) {
collections[inboxItem.bucket].push(inboxItem);
}
});
Object.values(collections).forEach((items) => {
items.sort((left, right) => right.sortValue - left.sortValue);
});
return collections;
}
function renderUnifiedInboxCard(item) {
const actions = [
`<button type="button" data-inbox-action="open" data-inbox-kind="${escapeHtml(item.kind)}" data-thread-id="${escapeHtml(item.threadId || '')}" data-interaction-id="${escapeHtml(item.interactionId || '')}" data-call-id="${escapeHtml(item.callId || '')}" data-customer-id="${escapeHtml(item.customerId || '')}">${escapeHtml(unifiedInboxActionLabel(item))}</button>`,
item.customerId ? `<button type="button" data-inbox-action="customer" data-customer-id="${escapeHtml(item.customerId)}">К клиенту</button>` : '',
item.canReply ? `<button type="button" data-reply-action="facebook" data-interaction-id="${escapeHtml(item.interactionId || '')}">Ответить</button>` : '',
].filter(Boolean).join('');
return `
<article class="pipeline-card unified-inbox-card${item.bucket === 'escalated' ? ' escalated' : ''}">
<div class="card-badges">${item.badges.join('')}</div>
<div class="row-item-head">
<h3 class="card-title">${escapeHtml(item.title)}</h3>
</div>
<p class="card-subtitle">${escapeHtml(item.subtitle)}</p>
${item.metaLines.map((line) => `<p class="card-meta-line">${escapeHtml(line)}</p>`).join('')}
<div class="card-divider"></div>
<div class="row-actions">
${actions}
</div>
</article>
`;
}
function renderUnifiedInbox() {
const board = $('unifiedInboxBoard');
const summary = $('unifiedInboxSummary');
if (!board || !summary) {
return;
}
const collections = buildUnifiedInboxCollections();
const total = Object.values(collections).reduce((count, items) => count + items.length, 0);
summary.innerHTML = [
renderSummaryCard('В очереди', String(total), 'Все задачи без дублей между каналами'),
renderSummaryCard('Новые', String(collections.new.length), 'Свободные обращения и чаты'),
renderSummaryCard('Мои', String(collections.mine.length), 'Закреплено за текущим пользователем'),
renderSummaryCard('AI handoff', String(collections.ai.length), 'Диалоги, где AI позвал человека'),
renderSummaryCard('Звонки', String(collections.calls.length), 'Активные голосовые разговоры'),
renderSummaryCard('Эскалации', String(collections.escalated.length), 'Очередь второй линии и спорные кейсы'),
].join('');
board.innerHTML = `
<div class="pipeline-board unified-inbox-board">
${UNIFIED_INBOX_COLUMNS.map((column) => `
<section class="pipeline-column">
<div class="pipeline-head">
<div class="pipeline-title">${escapeHtml(column.title)}</div>
<div class="pipeline-count">${collections[column.key].length}</div>
</div>
<div class="pipeline-stack">
${collections[column.key].length
? collections[column.key].map((item) => renderUnifiedInboxCard(item)).join('')
: `<div class="empty-state">${escapeHtml(column.emptyMessage)}</div>`}
</div>
</section>
`).join('')}
</div>
`;
}
function focusLiveCall(callId) {
if (!callId) {
return;
}
const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null;
const relatedCall = activeCall || state.liveCalls.recentItems.find((item) => item.call_id === callId) || null;
const select = $('liveCallIdSelect');
if (select) {
select.value = activeCall ? callId : '';
}
state.liveCalls.selectedCallId = callId;
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncLiveCallActionButtons();
ensureVoiceAiSummaryLoaded(relatedCall);
}
function handleLiveCallTableClick(event) {
const button = event.target.closest('[data-live-call-action]');
if (!button) {
return;
}
const callId = button.dataset.callId || '';
if (!callId) {
return;
}
if (button.dataset.liveCallAction === 'edit-name') {
openLiveCallNameEditor(callId, 'panel');
return;
}
if (button.dataset.liveCallAction === 'customer') {
const customerId = button.dataset.customerId || '';
if (customerId) {
openCustomerProfile(customerId);
}
}
}
async function openUnifiedInboxItem(button) {
const kind = button.dataset.inboxKind || '';
const interactionId = button.dataset.interactionId || '';
const threadId = button.dataset.threadId || '';
const callId = button.dataset.callId || '';
const customerId = button.dataset.customerId || '';
if (kind === 'telegram') {
window.location.hash = '#messages';
await selectMessengerConversation(messengerConversationId('telegram', threadId));
return;
}
if (kind === 'call') {
focusLiveCall(callId);
window.location.hash = '#calls';
return;
}
if (customerId) {
$('interactionCustomerId').value = customerId;
}
window.location.hash = '#interactions';
log('Открыто обращение из единой очереди', { interaction_id: interactionId, customer_id: customerId || '-' });
}
function handleUnifiedInboxClick(event) {
const button = event.target.closest('[data-inbox-action]');
if (!button) {
return;
}
if (button.dataset.inboxAction === 'customer') {
openCustomerProfile(button.dataset.customerId || '');
return;
}
openUnifiedInboxItem(button).catch((err) => {
log('Не удалось открыть элемент единой очереди', { error: err.message });
});
}
async function refreshUnifiedInbox() {
await Promise.all([
searchCustomers(),
loadInteractions(),
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }),
loadLiveCalls(false, { preserveStateOnError: true }),
selectedCustomer() ? ensureCustomerHistoryLoaded(selectedCustomer().customer_id, { force: true }) : Promise.resolve(null),
]);
log('Единая очередь обновлена', { total: Object.values(buildUnifiedInboxCollections()).reduce((count, items) => count + items.length, 0) });
}
function telephonyLabel(value) {
return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно';
}
function selectedLiveCallId() {
const value = $('liveCallIdSelect')?.value || '';
return value.trim();
}
function selectedLiveCallItem() {
const callId = selectedLiveCallId();
return state.liveCalls.items.find((item) => item.call_id === callId) || null;
}
function liveCallById(callId) {
if (!callId) {
return null;
}
return state.liveCalls.items.find((item) => item.call_id === callId)
|| state.liveCalls.recentItems.find((item) => item.call_id === callId)
|| null;
}
function liveCallCustomerId(item) {
return interactionById(item?.interaction_id || '')?.customer_id || '';
}
function closeCustomerProfileNameEditor() {
state.customers.nameEditor.open = false;
state.customers.nameEditor.customerId = '';
state.customers.nameEditor.draft = '';
state.customers.nameEditor.saving = false;
state.customers.nameEditor.error = '';
renderCustomerSpotlight();
}
function openCustomerProfileNameEditor(customerId = state.customers.selectedCustomerId) {
const customer = state.customers.items.find((item) => item.customer_id === customerId) || null;
if (!customer) {
return;
}
state.customers.nameEditor.open = true;
state.customers.nameEditor.customerId = customer.customer_id;
state.customers.nameEditor.draft = customer.display_name || '';
state.customers.nameEditor.saving = false;
state.customers.nameEditor.error = '';
state.customers.nameEditor.flash = '';
renderCustomerSpotlight();
}
function closeLiveCallNameEditor() {
state.liveCalls.nameEditor.open = false;
state.liveCalls.nameEditor.callId = '';
state.liveCalls.nameEditor.customerId = '';
state.liveCalls.nameEditor.draft = '';
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = '';
updateLiveCallNameEditorsUi();
}
function openLiveCallNameEditor(callId, mode = 'panel') {
const item = liveCallById(callId);
const customerId = liveCallCustomerId(item);
if (!item || !customerId) {
log('Нельзя исправить имя: звонок не привязан к клиенту', { call_id: callId || '-' });
return;
}
state.liveCalls.nameEditor.open = true;
state.liveCalls.nameEditor.mode = mode;
state.liveCalls.nameEditor.callId = item.call_id;
state.liveCalls.nameEditor.customerId = customerId;
state.liveCalls.nameEditor.draft = voiceCustomerDisplayName(item);
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = '';
if (mode === 'panel') {
focusLiveCall(item.call_id);
}
updateLiveCallNameEditorsUi();
}
function applyCustomerNamePatchLocally({ customerId, callId, displayName }) {
const normalizedName = String(displayName || '').trim();
if (!customerId || !normalizedName) {
return;
}
state.customers.items = state.customers.items.map((item) => (
item.customer_id === customerId ? { ...item, display_name: normalizedName } : item
));
const history = state.customers.historyById[customerId];
if (history?.customer) {
history.customer.display_name = normalizedName;
}
const interactionIds = new Set(
state.interactions
.filter((item) => item.customer_id === customerId)
.map((item) => item.interaction_id)
.filter(Boolean),
);
const patchCall = (item) => {
if (!item) {
return item;
}
const matchesCall = callId && item.call_id === callId;
const matchesCustomer = interactionIds.has(item.interaction_id);
if (!matchesCall && !matchesCustomer) {
return item;
}
return {
...item,
caller_name: normalizedName,
};
};
state.liveCalls.items = state.liveCalls.items.map(patchCall);
state.liveCalls.recentItems = state.liveCalls.recentItems.map(patchCall);
if (history?.live_calls) {
history.live_calls = history.live_calls.map((item) => ({
...item,
caller_name: interactionIds.has(item.interaction_id) || item.call_id === callId ? normalizedName : item.caller_name,
}));
}
const affectedCallIds = new Set(
[...state.liveCalls.items, ...state.liveCalls.recentItems]
.filter((item) => interactionIds.has(item.interaction_id) || item.call_id === callId)
.map((item) => item.call_id)
.filter(Boolean),
);
affectedCallIds.forEach((id) => {
if (!state.liveCalls.aiSummaries[id]) {
return;
}
state.liveCalls.aiSummaries[id] = {
...state.liveCalls.aiSummaries[id],
customer_name_status: 'name_obtained',
customer_name_value: normalizedName,
customer_name_source: 'manual',
};
});
if (state.liveCalls.nameEditor.customerId === customerId && !state.liveCalls.nameEditor.saving) {
state.liveCalls.nameEditor.draft = normalizedName;
}
if (state.customers.nameEditor.customerId === customerId && !state.customers.nameEditor.saving) {
state.customers.nameEditor.draft = normalizedName;
}
renderCustomerList();
refreshVoiceSummaryDependentViews();
}
async function persistCustomerDisplayName({ customerId, displayName, callId = '' }) {
const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName, source: 'manual' }),
});
const savedName = data?.display_name || displayName;
applyCustomerNamePatchLocally({
customerId,
callId,
displayName: savedName,
});
ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {});
if (callId) {
loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {});
}
refreshLiveCallsInBackground();
return savedName;
}
async function saveLiveCallCustomerName() {
const { customerId, callId } = state.liveCalls.nameEditor;
const displayName = String(state.liveCalls.nameEditor.draft || '').trim().replace(/\s+/g, ' ');
if (!customerId || !callId) {
return;
}
if (displayName.length < 2) {
state.liveCalls.nameEditor.error = 'Введите имя клиента минимум из 2 символов.';
updateLiveCallNameEditorsUi();
return;
}
state.liveCalls.nameEditor.saving = true;
state.liveCalls.nameEditor.error = '';
updateLiveCallNameEditorsUi();
try {
const savedName = await persistCustomerDisplayName({ customerId, callId, displayName });
const data = { display_name: savedName };
closeLiveCallNameEditor();
log('Имя клиента обновлено оператором', { customer_id: customerId, call_id: callId, name: data?.display_name || displayName });
} catch (err) {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
}
}
async function saveCustomerProfileName() {
const customerId = state.customers.nameEditor.customerId || state.customers.selectedCustomerId;
const displayName = String(state.customers.nameEditor.draft || '').trim().replace(/\s+/g, ' ');
if (!customerId) {
return;
}
if (displayName.length < 2) {
state.customers.nameEditor.error = 'Введите имя клиента минимум из 2 символов.';
renderCustomerSpotlight();
return;
}
const currentName = String(selectedCustomer()?.display_name || '').trim();
if (displayName === currentName) {
closeCustomerProfileNameEditor();
return;
}
state.customers.nameEditor.saving = true;
state.customers.nameEditor.error = '';
renderCustomerSpotlight();
try {
const savedName = await persistCustomerDisplayName({ customerId, displayName });
state.customers.nameEditor.flash = 'Сохранено';
closeCustomerProfileNameEditor();
log('Имя клиента обновлено из профиля', { customer_id: customerId, name: savedName });
} catch (err) {
state.customers.nameEditor.saving = false;
state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
renderCustomerSpotlight();
}
}
function liveCallNameEditorMeta(item, customerId) {
if (!item || !customerId) {
return 'Выберите звонок, связанный с клиентом, чтобы исправить имя.';
}
return `${voiceCustomerCallSubtitle(item)} • клиент ${customerId}`;
}
function updateLiveCallNameEditorsUi() {
const panel = $('liveCallNameEditor');
const panelInput = $('liveCallNameInput');
const panelMeta = $('liveCallNameEditorMeta');
const panelStatus = $('liveCallNameStatus');
const panelSave = $('liveCallNameSaveBtn');
const panelCancel = $('liveCallNameCancelBtn');
const popup = $('browserPhoneNameEditor');
const popupInput = $('browserPhoneNameInput');
const popupMeta = $('browserPhoneNameHint');
const popupStatus = $('browserPhoneNameStatus');
const popupSave = $('browserPhoneNameSaveBtn');
const popupCancel = $('browserPhoneNameCancelBtn');
const editor = state.liveCalls.nameEditor;
const item = liveCallById(editor.callId);
const meta = liveCallNameEditorMeta(item, editor.customerId);
const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : '');
if (panel) {
const panelVisible = editor.open && editor.mode === 'panel';
panel.classList.toggle('hidden', !panelVisible);
if (panelInput) {
if (panelInput.value !== editor.draft) {
panelInput.value = editor.draft;
}
panelInput.disabled = editor.saving;
}
if (panelMeta) {
panelMeta.textContent = meta;
}
if (panelStatus) {
panelStatus.textContent = statusText;
panelStatus.classList.toggle('error', Boolean(editor.error));
}
if (panelSave) {
panelSave.disabled = editor.saving || !editor.customerId;
panelSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя';
}
if (panelCancel) {
panelCancel.disabled = editor.saving;
}
}
if (popup) {
const popupVisible = editor.open && editor.mode === 'popup';
popup.classList.toggle('hidden', !popupVisible);
if (popupInput) {
if (popupInput.value !== editor.draft) {
popupInput.value = editor.draft;
}
popupInput.disabled = editor.saving;
}
if (popupMeta) {
popupMeta.textContent = meta;
}
if (popupStatus) {
popupStatus.textContent = statusText;
popupStatus.classList.toggle('hidden', !statusText);
popupStatus.classList.toggle('error', Boolean(editor.error));
}
if (popupSave) {
popupSave.disabled = editor.saving || !editor.customerId;
popupSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя';
}
if (popupCancel) {
popupCancel.disabled = editor.saving;
}
}
}
function isClaimableLiveCall(item) {
if (!item) {
return false;
}
return !item.claimed_by_user && ['ringing', 'claimed', 'connected'].includes(item.telephony_status);
}
function canControlLiveCall(item) {
if (!item) {
return false;
}
return ['claimed', 'connected'].includes(item.telephony_status);
}
function terminalActionLabel(item) {
const value = item?.terminal_action || '';
if (value === 'blind-transfer') {
return 'Передан';
}
if (value === 'hangup') {
return 'Завершён оператором';
}
return 'Завершён';
}
function voiceAiStatusMeta(item) {
const stateKey = String(item?.ai_state || '').trim();
if (!stateKey) {
return null;
}
return VOICE_AI_STATE_META[stateKey] || { label: stateKey, className: 'ai-muted' };
}
function liveCallHasAi(item) {
return Boolean(
item?.voice_session_id
|| item?.ai_session_id
|| item?.ai_state
|| item?.ai_handoff_reason
|| item?.ai_last_model_at,
);
}
function shouldLoadVoiceAiSummary(item) {
if (!item || !liveCallHasAi(item)) {
return false;
}
return ['handoff_requested', 'handoff_required', 'human_owned', 'error', 'closed'].includes(item.ai_state || '')
|| Boolean(item.ai_handoff_reason);
}
function voiceAiSummaryForCall(callId) {
return state.liveCalls.aiSummaries[callId] || null;
}
function voiceAiSummaryPending(callId) {
return Boolean(state.liveCalls.aiSummaryPending[callId]);
}
async function loadVoiceAiSummary(callId, options = {}) {
if (!callId || voiceAiSummaryPending(callId)) {
return voiceAiSummaryForCall(callId);
}
const { force = false, silent = false } = options;
if (!force && Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) {
return state.liveCalls.aiSummaries[callId];
}
state.liveCalls.aiSummaryPending[callId] = true;
try {
const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/ai-summary`);
state.liveCalls.aiSummaries[callId] = data || null;
return state.liveCalls.aiSummaries[callId];
} catch (err) {
if (!silent) {
log('Не удалось загрузить сводку AI по звонку', { call_id: callId, error: err.message });
}
state.liveCalls.aiSummaries[callId] = null;
return null;
} finally {
delete state.liveCalls.aiSummaryPending[callId];
refreshVoiceSummaryDependentViews();
}
}
function ensureVoiceAiSummaryLoaded(item) {
if (!shouldLoadVoiceAiSummary(item)) {
return;
}
const callId = item.call_id || '';
if (!callId || voiceAiSummaryPending(callId) || Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) {
return;
}
window.setTimeout(() => {
loadVoiceAiSummary(callId, { silent: true });
}, 0);
}
function renderVoiceAiSummaryField(label, value) {
if (!value) {
return '';
}
return `
<div class="voice-summary-field">
<div class="voice-summary-label">${escapeHtml(label)}</div>
<div class="voice-summary-value">${escapeHtml(value)}</div>
</div>
`;
}
function formatVoiceCustomerNameStatus(status) {
switch (String(status || '').trim()) {
case 'name_obtained':
return 'Подтверждено';
case 'name_followup_required':
return 'Нужно уточнить';
case 'name_not_obtained':
return 'Не подтверждено';
default:
return String(status || '').trim();
}
}
function voiceCustomerNameStatusMeta(status) {
switch (String(status || '').trim()) {
case 'name_obtained':
return { label: 'Имя подтверждено', shortLabel: 'Имя подтверждено', className: 'name-confirmed' };
case 'name_followup_required':
return { label: 'Имя нужно уточнить', shortLabel: 'Уточнить имя', className: 'name-followup' };
case 'name_not_obtained':
return { label: 'Имя не подтверждено', shortLabel: 'Без подтверждения', className: 'name-missing' };
default: {
const fallback = String(status || '').trim();
return fallback ? { label: fallback, shortLabel: fallback, className: 'name-missing' } : null;
}
}
}
function formatVoiceCustomerNameSource(source) {
switch (String(source || '').trim()) {
case 'known_customer':
return 'Из карточки клиента';
case 'voice_start':
return 'Стартовый этап';
case 'voice_followup':
return 'Уточнил AI';
case 'manual':
return 'Оператор';
case 'external_identity':
return 'Из voice identity';
default:
return String(source || '').trim();
}
}
function formatVoiceStartLanguage(language) {
switch (String(language || '').trim()) {
case 'ru':
return 'Русский';
case 'kz':
return 'Қазақша';
default:
return String(language || '').trim();
}
}
function voiceSummaryForItem(item) {
const callId = String(item?.call_id || '').trim();
return callId ? voiceAiSummaryForCall(callId) : null;
}
function voiceCustomerDisplayName(item, summary = voiceSummaryForItem(item)) {
const preferredName = String(summary?.customer_name_value || '').trim();
if (preferredName) {
return preferredName;
}
const callerName = String(item?.caller_name || '').trim();
if (callerName) {
return callerName;
}
const callerNumber = String(item?.caller_number || '').trim();
if (callerNumber) {
return callerNumber;
}
const callId = String(item?.call_id || '').trim();
if (callId) {
return callId;
}
return 'Неизвестный абонент';
}
function voiceCustomerCallSubtitle(item) {
const parts = [
String(item?.caller_number || '').trim(),
String(item?.call_id || '').trim(),
].filter(Boolean);
return parts.join(' • ') || 'Звонок без номера';
}
function voiceCustomerNameStateLine(summary) {
if (!summary) {
return '';
}
const parts = [];
const statusMeta = voiceCustomerNameStatusMeta(summary.customer_name_status);
if (statusMeta?.label) {
parts.push(statusMeta.label);
}
const sourceLabel = formatVoiceCustomerNameSource(summary.customer_name_source);
if (sourceLabel) {
parts.push(sourceLabel);
}
const languageLabel = formatVoiceStartLanguage(summary.voice_start_language);
if (languageLabel) {
parts.push(languageLabel);
}
return parts.join(' • ');
}
function voiceCustomerIncomingMeta(item, summary = voiceSummaryForItem(item)) {
const parts = [];
const callerNumber = String(item?.caller_number || '').trim();
if (callerNumber) {
parts.push(`Номер: ${callerNumber}`);
}
const nameState = voiceCustomerNameStateLine(summary);
if (nameState) {
parts.push(nameState);
}
return parts.join(' • ') || `call_id: ${String(item?.call_id || '—').trim() || '—'}`;
}
function refreshVoiceSummaryDependentViews() {
updateLiveCallSelector(state.liveCalls.items);
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
renderUnifiedInbox();
updateBrowserPhoneUi();
}
function renderVoiceAiTranscript(summary) {
const segments = Array.isArray(summary?.transcript_segments)
? summary.transcript_segments.filter((segment) => segment && String(segment.text || '').trim())
: [];
if (!segments.length) {
return '';
}
return `
<div class="voice-summary-transcript">
<div class="voice-summary-transcript-head">
<div class="voice-summary-label">Транскрипт разговора</div>
<div class="voice-summary-transcript-meta">${escapeHtml(`${segments.length} реплик`)}</div>
</div>
<div class="voice-summary-transcript-list">
${segments
.map((segment) => {
const speaker = segment?.speaker === 'assistant' ? 'AI' : 'Клиент';
const lineClass = segment?.speaker === 'assistant' ? 'assistant' : 'caller';
const timeLabel = formatIsoShort(segment?.created_at || '');
const interrupted = segment?.interrupted && segment?.speaker === 'assistant' ? ' • прерван' : '';
return `
<div class="voice-transcript-line ${lineClass}">
<div class="voice-transcript-speaker-row">
<div class="voice-transcript-speaker">${escapeHtml(`${speaker}${interrupted}`)}</div>
<div class="voice-transcript-time">${escapeHtml(timeLabel)}</div>
</div>
<div class="voice-transcript-text">${escapeHtml(segment?.text || '')}</div>
</div>
`;
})
.join('')}
</div>
</div>
`;
}
function renderVoiceAiSummary(summary, pending = false) {
if (!summary && !pending) {
return '';
}
if (pending && !summary) {
return `
<section class="voice-ai-summary voice-ai-summary-loading">
<div class="voice-ai-summary-head">
<div>
<div class="voice-ai-summary-title">Сводка AI</div>
<div class="voice-ai-summary-caption">Подтягиваем контекст AI по текущему звонку...</div>
</div>
</div>
</section>
`;
}
const statusClass = summary?.status_tone === 'handoff' ? 'handoff' : 'answered';
return `
<section class="voice-ai-summary">
<div class="voice-ai-summary-head">
<div>
<div class="voice-ai-summary-title">Сводка AI</div>
<div class="voice-ai-summary-caption">Краткий контекст передачи до ручной обработки звонка.</div>
</div>
<div class="voice-ai-summary-side">
<div class="voice-summary-status ${statusClass}">${escapeHtml(summary?.status_label || 'AI-сводка')}</div>
<div class="voice-ai-summary-meta">${escapeHtml(formatIsoShort(summary?.generated_at || ''))}</div>
</div>
</div>
<div class="voice-ai-summary-grid">
${renderVoiceAiSummaryField('Имя клиента', summary?.customer_name_value)}
${renderVoiceAiSummaryField('Статус имени', formatVoiceCustomerNameStatus(summary?.customer_name_status))}
${renderVoiceAiSummaryField('Источник имени', formatVoiceCustomerNameSource(summary?.customer_name_source))}
${renderVoiceAiSummaryField('Язык старта', formatVoiceStartLanguage(summary?.voice_start_language))}
${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)}
${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)}
${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)}
${renderVoiceAiSummaryField('Следующий шаг', summary?.recommended_next_step)}
</div>
${renderVoiceAiTranscript(summary)}
</section>
`;
}
function describeLiveCallError(message) {
const text = String(message || '').trim();
const lower = text.toLowerCase();
if (!text) {
return 'Операция по звонку не выполнена.';
}
if (lower.includes('already ended') || lower.includes('channel_already_closed')) {
return 'Звонок уже завершён.';
}
if (lower.includes('already claimed by another operator')) {
return 'Звонок уже закреплён другим оператором.';
}
if (lower.includes('unable to resolve active channel')) {
return 'Asterisk не смог найти активный канал звонка.';
}
if (lower.includes('operator_extension is required')) {
return 'Укажите внутренний номер оператора для этого действия.';
}
if (lower.includes('target extension')) {
return 'Указанный target недоступен для передачи.';
}
if (lower.includes('blind transfer failed')) {
return 'Передача звонка не выполнена.';
}
if (lower.includes('hangup failed')) {
return 'Не удалось завершить звонок.';
}
if (lower.includes('claim failed')) {
return 'Не удалось принять звонок в работу.';
}
return text;
}
function browserPhoneConfig() {
return state.browserPhone.config || null;
}
function browserPhoneLibraryAvailable() {
return Boolean(window.SIP && window.SIP.UserAgent);
}
function browserPhoneAudioElement() {
return $('browserPhoneRemoteAudio');
}
function browserPhoneAudioConstraints() {
if (state.browserPhone.micDeviceId) {
return { audio: { deviceId: { exact: state.browserPhone.micDeviceId } }, video: false };
}
return { audio: true, video: false };
}
function browserPhoneIncomingLabel(session) {
const remote = session?.remoteIdentity;
const name = remote?.displayName || remote?.uri?.user || remote?.uri?.toString?.() || 'неизвестно';
return `Входящий звонок: ${name}`;
}
function browserPhoneAudioContext() {
if (state.browserPhone.ringtoneContext) {
return state.browserPhone.ringtoneContext;
}
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioContextCtor) {
return null;
}
state.browserPhone.ringtoneContext = new AudioContextCtor();
return state.browserPhone.ringtoneContext;
}
async function ensureBrowserPhoneAudioContextResumed() {
const context = browserPhoneAudioContext();
if (!context) {
return null;
}
if (context.state === 'suspended') {
await context.resume().catch(() => {});
}
return context;
}
function playBrowserPhoneRingtoneBurst(context) {
if (!context) {
return;
}
const tones = [
{ offset: 0, duration: 0.18, frequency: 880 },
{ offset: 0.24, duration: 0.18, frequency: 660 },
];
tones.forEach((tone) => {
const oscillator = context.createOscillator();
const gain = context.createGain();
const startAt = context.currentTime + tone.offset;
const stopAt = startAt + tone.duration;
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(tone.frequency, startAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(0.14, startAt + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(startAt);
oscillator.stop(stopAt + 0.02);
});
}
async function startBrowserPhoneRingtone() {
if (state.browserPhone.ringtoneTimer) {
return;
}
const context = await ensureBrowserPhoneAudioContextResumed();
if (!context) {
return;
}
state.browserPhone.ringtoneActive = true;
playBrowserPhoneRingtoneBurst(context);
state.browserPhone.ringtoneTimer = window.setInterval(() => {
playBrowserPhoneRingtoneBurst(context);
}, 2200);
updateBrowserPhoneUi();
}
function stopBrowserPhoneRingtone() {
if (state.browserPhone.ringtoneTimer) {
window.clearInterval(state.browserPhone.ringtoneTimer);
state.browserPhone.ringtoneTimer = null;
}
if (state.browserPhone.ringtoneActive) {
state.browserPhone.ringtoneActive = false;
updateBrowserPhoneUi();
}
}
function syncBrowserPhoneSpeakerSink() {
const element = browserPhoneAudioElement();
if (!element || !state.browserPhone.speakerDeviceId) {
return;
}
if (typeof element.setSinkId !== 'function') {
return;
}
element.setSinkId(state.browserPhone.speakerDeviceId).catch(() => {});
}
function setBrowserPhoneStatus(text) {
state.browserPhone.status = text;
updateBrowserPhoneUi();
}
function setBrowserPhoneWarning(text = '') {
state.browserPhone.warning = text;
updateBrowserPhoneUi();
}
function toggleBrowserPhoneSettings() {
state.browserPhone.settingsOpen = !state.browserPhone.settingsOpen;
updateBrowserPhoneUi();
}
function browserPhoneStatusState() {
const config = browserPhoneConfig();
const hasSession = Boolean(state.browserPhone.session);
const popupPhase = browserPhonePopupPhase();
if (!config) {
return 'unavailable';
}
if (!config.enabled) {
return 'disabled';
}
if (state.browserPhone.connecting && popupPhase === 'idle') {
return 'connecting';
}
if (popupPhase === 'error') {
return 'error';
}
if (popupPhase === 'ending') {
return 'ending';
}
if (popupPhase === 'incoming') {
return 'incoming';
}
if (popupPhase === 'connecting') {
return 'connecting';
}
if (popupPhase === 'in-call' || hasSession) {
return 'active';
}
if (state.browserPhone.connected) {
return 'registered';
}
return 'offline';
}
function browserPhoneStatusLabel() {
const labels = {
unavailable: 'Недоступен',
disabled: 'Отключен',
connecting: 'Подключение',
ending: 'Завершаем',
error: 'Нужно завершить',
incoming: 'Входящий звонок',
active: 'В разговоре',
registered: 'Зарегистрирован',
offline: 'Не подключен',
};
return labels[browserPhoneStatusState()] || state.browserPhone.status || 'Не подключен';
}
function browserPhonePopupPhase() {
if (state.browserPhone.callPhase === 'error') {
return 'error';
}
if (state.browserPhone.endingCallId || state.browserPhone.callPhase === 'ending') {
return 'ending';
}
if (state.browserPhone.callPhase === 'connecting') {
return 'connecting';
}
if (state.browserPhone.incoming || state.browserPhone.callPhase === 'incoming') {
return 'incoming';
}
if (state.browserPhone.callPhase === 'in-call' || state.browserPhone.session) {
return 'in-call';
}
return 'idle';
}
function browserPhoneCallById(callId, options = {}) {
if (!callId) {
return null;
}
const { includeRecent = true } = options;
const live = state.liveCalls.items.find((item) => item.call_id === callId);
if (live) {
return live;
}
if (!includeRecent) {
return null;
}
return state.liveCalls.recentItems.find((item) => item.call_id === callId) || null;
}
function browserPhoneActivePopupCall() {
const trackedIds = [
state.browserPhone.endingCallId,
state.browserPhone.popupCallId,
state.browserPhone.autoClaimCallId,
].filter(Boolean);
for (const callId of trackedIds) {
const found = browserPhoneCallById(callId);
if (found) {
return found;
}
}
const owned = state.liveCalls.items.find((item) => item.claimed_by_user === state.user);
if (owned) {
return owned;
}
const candidates = browserPhoneClaimCandidates();
if (candidates.length === 1) {
return candidates[0];
}
const selected = selectedLiveCallItem();
if (selected) {
return selected;
}
return null;
}
function browserPhoneCallTimerSource(item) {
return item?.connected_at || item?.started_at || state.browserPhone.sessionStartedAt || '';
}
function formatCallTimer(value) {
if (!value) {
return '00:00';
}
const startedAt = new Date(value);
if (Number.isNaN(startedAt.getTime())) {
return '00:00';
}
const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt.getTime()) / 1000));
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
return `${minutes}:${seconds}`;
}
function browserPhonePopupVisible() {
return browserPhonePopupPhase() !== 'idle';
}
function browserPhoneCallSummary(item) {
if (!item) {
return 'Ожидаем карточку звонка из bridge...';
}
const summary = voiceSummaryForItem(item);
const aiMeta = voiceAiStatusMeta(item);
const nameState = voiceCustomerNameStateLine(summary);
const parts = [
item.queue_code || item.queue_id || 'queue?',
item.interaction_id || 'interaction?',
item.operator_extension ? `ext ${item.operator_extension}` : '',
nameState,
aiMeta ? aiMeta.label : '',
].filter(Boolean);
return parts.join(' • ');
}
function browserPhoneHintText(config) {
const popupPhase = browserPhonePopupPhase();
if (!config) {
return 'Browser softphone недоступен: bridge не вернул конфиг для текущего пользователя.';
}
if (!config.enabled) {
return 'Browser softphone отключён в окружении. Внешний SIP-клиент остаётся fallback-путём.';
}
if (!browserPhoneLibraryAvailable()) {
return 'Browser softphone не инициализирован: SIP.js asset не загрузился.';
}
if (popupPhase === 'incoming' && state.browserPhone.ringtoneActive) {
return 'Входящий звонок в браузере. Используйте Ответить или Отклонить. Рингтон воспроизводится локально.';
}
if (popupPhase === 'connecting') {
return 'Подключаем медиа и быстро сопоставляем звонок с bridge. При неоднозначности можно нажать «Принять в работу» вручную.';
}
if (popupPhase === 'ending') {
return 'Ждём финальное подтверждение завершения звонка от bridge.';
}
if (popupPhase === 'error') {
return 'Локальная media session уже завершена, но bridge ещё не подтвердил финальный terminal state. Проверьте предупреждение в popup.';
}
if (state.browserPhone.connected) {
return 'Телефон зарегистрирован. Входящий браузерный звонок откроет отдельное окно и попытается выполнить авто-claim.';
}
return 'Подключите browser phone и разрешите доступ к микрофону.';
}
function syncBrowserPhonePopupLifecycle() {
const activeCallId = state.browserPhone.endingCallId || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId;
if (!activeCallId) {
updateBrowserPhoneUi();
return;
}
const liveCall = state.liveCalls.items.find((item) => item.call_id === activeCallId);
const recentCall = state.liveCalls.recentItems.find((item) => item.call_id === activeCallId);
if (!liveCall && recentCall && !state.browserPhone.session && !state.browserPhone.incoming) {
state.browserPhone.endingCallId = '';
state.browserPhone.popupCallId = '';
state.browserPhone.autoClaimCallId = '';
state.browserPhone.callPhase = '';
state.browserPhone.warning = '';
if (state.browserPhone.connected) {
state.browserPhone.status = 'Зарегистрирован';
}
}
updateBrowserPhoneUi();
}
function updateBrowserPhoneUi() {
const config = browserPhoneConfig();
const hasSession = Boolean(state.browserPhone.session);
const pendingAction = state.liveCalls.pendingAction || '';
const popupCall = browserPhoneActivePopupCall();
const popupPhase = browserPhonePopupPhase();
const phoneState = browserPhoneStatusState();
const connectBtn = $('browserPhoneConnectBtn');
const disconnectBtn = $('browserPhoneDisconnectBtn');
const micSelect = $('browserPhoneMicSelect');
const speakerSelect = $('browserPhoneSpeakerSelect');
const settingsPanel = $('browserPhoneSettingsPanel');
const statusBtn = $('browserPhoneStatusBtn');
const statusLabel = $('browserPhoneStatusLabel');
const hint = $('browserPhoneHint');
const muteBtn = $('browserPhoneMuteBtn');
const answerBtn = $('browserPhoneAnswerBtn');
const rejectBtn = $('browserPhoneRejectBtn');
const claimBtn = $('browserPhoneCallClaimBtn');
const editNameBtn = $('browserPhoneEditNameBtn');
const transferBtn = $('browserPhoneCallTransferBtn');
const hangupBtn = $('browserPhoneCallHangupBtn');
const transferType = $('browserPhoneTransferTargetType');
const transferValue = $('browserPhoneTransferTargetValue');
const overlay = $('browserPhoneCallOverlay');
const popupState = $('browserPhoneCallState');
const popupTitle = $('browserPhoneCallTitle');
const popupIncomingText = $('browserPhoneIncomingText');
const popupMeta = $('browserPhoneCallMeta');
const popupAiSummary = $('browserPhoneAiSummary');
const popupTimer = $('browserPhoneCallTimer');
const popupWarning = $('browserPhoneCallWarning');
const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null;
const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false;
$('browserPhoneRegistrationState').textContent = state.browserPhone.status;
$('browserPhoneOperatorExtension').textContent = config?.operator_extension || '—';
$('browserPhoneWsUrl').textContent = config?.ws_url || '—';
statusLabel.textContent = browserPhoneStatusLabel();
statusBtn.dataset.state = phoneState;
statusBtn.setAttribute('aria-expanded', state.browserPhone.settingsOpen ? 'true' : 'false');
settingsPanel.classList.toggle('hidden', !state.browserPhone.settingsOpen);
connectBtn.disabled = !config?.enabled || state.browserPhone.connected || state.browserPhone.connecting;
disconnectBtn.disabled = !state.browserPhone.connected && !hasSession && !state.browserPhone.connecting;
micSelect.disabled = state.browserPhone.connecting;
speakerSelect.disabled = state.browserPhone.connecting;
hint.textContent = browserPhoneHintText(config);
const incoming = popupPhase === 'incoming';
const connecting = popupPhase === 'connecting';
const ending = popupPhase === 'ending';
const errorState = popupPhase === 'error';
const active = popupPhase === 'in-call';
const canClaim = Boolean(popupCall && isClaimableLiveCall(popupCall) && !incoming && !connecting && !ending && !state.browserPhone.autoClaimInFlight);
const canControl = Boolean(popupCall && canControlLiveCall(popupCall) && !connecting && !ending);
const popupCustomerId = liveCallCustomerId(popupCall);
overlay.classList.toggle('hidden', !browserPhonePopupVisible());
popupState.textContent = errorState
? 'Нужна синхронизация завершения'
: ending
? 'Завершаем звонок'
: connecting
? 'Подключаем media'
: incoming
? 'Входящий звонок'
: active
? 'Разговор в браузере'
: 'Browser call';
popupTitle.textContent = popupCall
? voiceCustomerDisplayName(popupCall, popupAiSummaryData)
: browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', '');
popupIncomingText.textContent = popupCall
? voiceCustomerIncomingMeta(popupCall, popupAiSummaryData)
: state.browserPhone.session
? browserPhoneIncomingLabel(state.browserPhone.session)
: 'SIP invite ещё не поступал.';
popupMeta.textContent = browserPhoneCallSummary(popupCall);
if (popupCall) {
ensureVoiceAiSummaryLoaded(popupCall);
}
popupAiSummary.innerHTML = renderVoiceAiSummary(popupAiSummaryData, popupAiSummaryPending);
popupAiSummary.classList.toggle('hidden', !popupAiSummary.innerHTML.trim());
popupTimer.textContent = incoming
? 'ringing'
: connecting
? 'connecting…'
: ending
? 'ending…'
: formatCallTimer(browserPhoneCallTimerSource(popupCall));
popupWarning.textContent = state.browserPhone.warning;
popupWarning.classList.toggle('hidden', !state.browserPhone.warning);
answerBtn.classList.toggle('hidden', !incoming);
rejectBtn.classList.toggle('hidden', !incoming);
claimBtn.classList.toggle('hidden', !canClaim);
editNameBtn.classList.toggle('hidden', !popupCall || !popupCustomerId);
muteBtn.classList.toggle('hidden', !(active || connecting));
transferBtn.classList.toggle('hidden', !(canControl || ending || errorState));
hangupBtn.classList.toggle('hidden', !(popupCall || hasSession || ending || errorState || connecting));
transferType.classList.toggle('hidden', !(canControl || ending || errorState));
transferValue.classList.toggle('hidden', !(canControl || ending || errorState));
answerBtn.disabled = !incoming || Boolean(pendingAction);
rejectBtn.disabled = !incoming || Boolean(pendingAction);
claimBtn.disabled = !canClaim || Boolean(pendingAction) || state.browserPhone.autoClaimInFlight;
editNameBtn.disabled = !popupCall || !popupCustomerId || state.liveCalls.nameEditor.saving;
muteBtn.disabled = !hasSession;
muteBtn.textContent = state.browserPhone.muted ? 'Включить микрофон' : 'Выключить микрофон';
transferBtn.disabled = !canControl || Boolean(pendingAction) || ending || connecting || !transferValue.value.trim();
hangupBtn.disabled = (!popupCall && !hasSession) || Boolean(pendingAction) || ending;
transferType.disabled = !canControl || Boolean(pendingAction) || connecting;
transferValue.disabled = !canControl || Boolean(pendingAction) || connecting;
if (state.liveCalls.nameEditor.mode === 'popup' && (!popupCall || state.liveCalls.nameEditor.callId !== popupCall.call_id)) {
closeLiveCallNameEditor();
} else {
updateLiveCallNameEditorsUi();
}
}
function renderBrowserDeviceOptions(selectId, devices, preferredId, placeholder) {
const select = $(selectId);
if (!select) {
return;
}
const items = devices.length ? devices : [{ deviceId: '', label: placeholder }];
select.innerHTML = items
.map((item, index) => `<option value="${escapeHtml(item.deviceId || '')}">${escapeHtml(item.label || `${placeholder} ${index + 1}`)}</option>`)
.join('');
if (preferredId && items.some((item) => item.deviceId === preferredId)) {
select.value = preferredId;
} else if (!devices.length) {
select.value = '';
}
}
async function loadBrowserPhoneDevices() {
if (!navigator.mediaDevices?.enumerateDevices) {
renderBrowserDeviceOptions('browserPhoneMicSelect', [], '', 'Media devices not available');
renderBrowserDeviceOptions('browserPhoneSpeakerSelect', [], '', 'Output selection not supported');
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter((item) => item.kind === 'audioinput');
const outputs = devices.filter((item) => item.kind === 'audiooutput');
renderBrowserDeviceOptions('browserPhoneMicSelect', inputs, state.browserPhone.micDeviceId, 'Микрофон');
renderBrowserDeviceOptions('browserPhoneSpeakerSelect', outputs, state.browserPhone.speakerDeviceId, 'Динамик');
if (!state.browserPhone.micDeviceId) {
state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value;
}
if (!state.browserPhone.speakerDeviceId) {
state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value;
}
syncBrowserPhoneSpeakerSink();
}
async function ensureBrowserPhoneMediaAccess() {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Browser getUserMedia is not available');
}
const stream = await navigator.mediaDevices.getUserMedia(browserPhoneAudioConstraints());
stream.getTracks().forEach((track) => track.stop());
}
function stopBrowserPhoneLocalStream() {
if (state.browserPhone.localStream) {
state.browserPhone.localStream.getTracks().forEach((track) => track.stop());
}
state.browserPhone.localStream = null;
}
function browserPhoneLocalStreamHealthy() {
const stream = state.browserPhone.localStream;
return Boolean(stream && stream.getTracks().some((track) => track.readyState === 'live'));
}
async function ensureBrowserPhoneLocalStream(options = {}) {
const { forceFresh = false } = options;
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Browser getUserMedia is not available');
}
if (forceFresh) {
stopBrowserPhoneLocalStream();
}
if (browserPhoneLocalStreamHealthy()) {
return state.browserPhone.localStream;
}
if (state.browserPhone.localStreamPromise) {
return state.browserPhone.localStreamPromise;
}
state.browserPhone.localStreamPromise = navigator.mediaDevices
.getUserMedia(browserPhoneAudioConstraints())
.then((stream) => {
stopBrowserPhoneLocalStream();
state.browserPhone.localStream = stream;
return stream;
})
.finally(() => {
state.browserPhone.localStreamPromise = null;
});
return state.browserPhone.localStreamPromise;
}
function prepareBrowserPhoneAnswerMedia() {
return ensureBrowserPhoneLocalStream().catch((err) => {
log('Browser softphone не смог заранее подготовить media', { error: err.message });
return null;
});
}
function browserPhoneClaimCandidates() {
return browserPhoneClaimCandidatesFrom(state.liveCalls.items);
}
function browserPhoneCallTimestampMs(item) {
const raw = item?.last_transition_at || item?.connected_at || item?.started_at || item?.updated_at || '';
const ts = raw ? new Date(raw).getTime() : Number.NaN;
return Number.isFinite(ts) ? ts : Number.NaN;
}
function browserPhoneSessionTimestampMs() {
const raw = state.browserPhone.sessionStartedAt || '';
const ts = raw ? new Date(raw).getTime() : Number.NaN;
return Number.isFinite(ts) ? ts : Number.NaN;
}
function browserPhoneClaimCandidatesFrom(items) {
const config = browserPhoneConfig();
const extension = config?.operator_extension || '';
return (items || []).filter((item) => {
if (!['ringing', 'claimed', 'connected'].includes(item.telephony_status)) {
return false;
}
if (extension && item.operator_extension && item.operator_extension !== extension) {
return false;
}
return true;
});
}
function browserPhoneBestCandidateMatch(candidates) {
if (!Array.isArray(candidates) || !candidates.length) {
return null;
}
const sessionTs = browserPhoneSessionTimestampMs();
const ranked = [...candidates].sort((left, right) => {
const leftTs = browserPhoneCallTimestampMs(left);
const rightTs = browserPhoneCallTimestampMs(right);
if (Number.isFinite(sessionTs)) {
const leftDelta = Number.isFinite(leftTs) ? Math.abs(leftTs - sessionTs) : Number.POSITIVE_INFINITY;
const rightDelta = Number.isFinite(rightTs) ? Math.abs(rightTs - sessionTs) : Number.POSITIVE_INFINITY;
if (leftDelta !== rightDelta) {
return leftDelta - rightDelta;
}
}
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return rightTs - leftTs;
}
return String(right?.call_id || '').localeCompare(String(left?.call_id || ''));
});
return ranked[0] || null;
}
function attachBrowserRemoteAudio(session) {
const element = browserPhoneAudioElement();
const sdh = session?.sessionDescriptionHandler;
const peerConnection = sdh?.peerConnection;
if (!element || !peerConnection) {
return;
}
const syncRemoteStream = () => {
const stream = new MediaStream();
peerConnection.getReceivers().forEach((receiver) => {
if (receiver.track) {
stream.addTrack(receiver.track);
}
});
element.srcObject = stream;
syncBrowserPhoneSpeakerSink();
element.play().catch(() => {});
};
syncRemoteStream();
peerConnection.addEventListener('track', syncRemoteStream);
}
function applyBrowserMuteState() {
const sdh = state.browserPhone.session?.sessionDescriptionHandler;
const peerConnection = sdh?.peerConnection;
if (!peerConnection) {
return;
}
peerConnection.getSenders().forEach((sender) => {
if (sender.track && sender.track.kind === 'audio') {
sender.track.enabled = !state.browserPhone.muted;
}
});
}
function resetBrowserPhoneSessionState() {
stopBrowserPhoneRingtone();
state.browserPhone.session = null;
state.browserPhone.incoming = false;
state.browserPhone.muted = false;
state.browserPhone.autoClaimCallId = '';
state.browserPhone.autoClaimInFlight = false;
state.browserPhone.sessionStartedAt = '';
state.browserPhone.callPhase = state.browserPhone.endingCallId ? 'ending' : '';
stopBrowserPhoneLocalStream();
const element = browserPhoneAudioElement();
if (element) {
element.srcObject = null;
}
updateBrowserPhoneUi();
}
function browserSessionStateValue(session = state.browserPhone.session) {
const SessionState = window.SIP?.SessionState;
if (!session || !SessionState) {
return null;
}
return session.state;
}
function browserSessionIsActive(session = state.browserPhone.session) {
const SessionState = window.SIP?.SessionState;
if (!session || !SessionState) {
return false;
}
return ![SessionState.Terminated].includes(session.state);
}
function ensureBrowserSessionFreshForInvite() {
if (!state.browserPhone.session) {
return true;
}
if (browserSessionIsActive(state.browserPhone.session)) {
return false;
}
resetBrowserPhoneSessionState();
return true;
}
function bindBrowserPhoneSession(session) {
const SessionState = window.SIP?.SessionState;
state.browserPhone.session = session;
state.browserPhone.incoming = true;
state.browserPhone.muted = false;
state.browserPhone.popupCallId = '';
state.browserPhone.callPhase = 'incoming';
state.browserPhone.autoClaimInFlight = false;
state.browserPhone.warning = '';
setBrowserPhoneStatus('Входящий звонок');
startBrowserPhoneRingtone().catch(() => {});
prepareBrowserPhoneAnswerMedia();
updateBrowserPhoneUi();
session.stateChange.addListener((newState) => {
if (newState === SessionState.Established) {
stopBrowserPhoneRingtone();
state.browserPhone.incoming = false;
state.browserPhone.callPhase = 'in-call';
state.browserPhone.sessionStartedAt = new Date().toISOString();
setBrowserPhoneStatus('Соединено в браузере');
attachBrowserRemoteAudio(session);
applyBrowserMuteState();
if (!state.browserPhone.autoClaimInFlight && !state.browserPhone.popupCallId && !state.browserPhone.autoClaimCallId) {
void bestEffortBrowserAutoClaim({ refreshAttempts: 1, retryDelayMs: 150 });
}
return;
}
if (newState === SessionState.Terminated) {
resetBrowserPhoneSessionState();
if (state.browserPhone.connected && !state.browserPhone.endingCallId) {
setBrowserPhoneStatus('Зарегистрирован');
}
syncBrowserPhonePopupLifecycle();
}
});
}
async function terminateBrowserPhoneSessionLocally() {
const session = state.browserPhone.session;
if (!session) {
return;
}
try {
const SessionState = window.SIP?.SessionState;
if (state.browserPhone.incoming && typeof session.reject === 'function') {
await session.reject();
return;
}
if (session.state === SessionState.Established && typeof session.bye === 'function') {
await session.bye();
return;
}
if (typeof session.cancel === 'function') {
await session.cancel();
return;
}
if (typeof session.dispose === 'function') {
session.dispose();
}
} catch {
if (typeof session.dispose === 'function') {
session.dispose();
}
}
}
async function claimLiveCallById(callId, options = {}) {
const operatorExtension = (options.operatorExtension || browserPhoneConfig()?.operator_extension || '').trim();
const payload = {};
if (operatorExtension) {
payload.operator_extension = operatorExtension;
}
const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/claim`, {
method: 'POST',
body: JSON.stringify(payload),
});
state.browserPhone.popupCallId = data.call_id;
state.browserPhone.endingCallId = '';
state.browserPhone.warning = '';
applyClaimedLiveCall(data);
refreshLiveCallsInBackground();
return data;
}
async function hangupLiveCallById(callId, options = {}) {
const { optimistic = true } = options;
const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/hangup`, {
method: 'POST',
body: JSON.stringify({}),
});
if (optimistic) {
moveLiveCallToRecent(data);
refreshLiveCallsInBackground();
} else {
state.browserPhone.endingCallId = callId;
updateBrowserPhoneUi();
refreshLiveCallsInBackground();
}
return data;
}
async function bestEffortBrowserAutoClaim(options = {}) {
return bestEffortBrowserAutoClaimWithOptions(options);
}
function browserPhoneAutoClaimSnapshot(items = state.liveCalls.items) {
const byId = (callId) => {
if (!callId) {
return null;
}
return (items || []).find((item) => item.call_id === callId) || null;
};
const trackedIds = [
state.browserPhone.popupCallId,
state.browserPhone.autoClaimCallId,
selectedLiveCallId(),
].filter(Boolean);
for (const callId of trackedIds) {
const found = byId(callId);
if (!found) {
continue;
}
if (found.claimed_by_user === state.user) {
return { mode: 'owned', call: found };
}
if (isClaimableLiveCall(found)) {
return { mode: 'claim', call: found };
}
}
const candidates = browserPhoneClaimCandidatesFrom(items);
const ownedCandidates = candidates.filter((item) => item.claimed_by_user === state.user);
if (ownedCandidates.length === 1) {
return { mode: 'owned', call: ownedCandidates[0] };
}
if (ownedCandidates.length > 1) {
const bestOwned = browserPhoneBestCandidateMatch(ownedCandidates);
if (bestOwned) {
return { mode: 'owned', call: bestOwned };
}
}
const unclaimedCandidates = candidates.filter((item) => !item.claimed_by_user);
if (unclaimedCandidates.length === 1) {
return { mode: 'claim', call: unclaimedCandidates[0] };
}
if (unclaimedCandidates.length > 1) {
const bestUnclaimed = browserPhoneBestCandidateMatch(unclaimedCandidates);
if (bestUnclaimed) {
return { mode: 'claim', call: bestUnclaimed };
}
}
return null;
}
async function bestEffortBrowserAutoClaimWithOptions(options = {}) {
const { refreshAttempts = 2, retryDelayMs = 150 } = options;
if (state.browserPhone.autoClaimInFlight) {
return false;
}
state.browserPhone.autoClaimInFlight = true;
updateBrowserPhoneUi();
const config = browserPhoneConfig();
const operatorExtension = config?.operator_extension || '';
const handleMatch = async (match) => {
if (!match?.call) {
return false;
}
if (match.mode === 'owned') {
state.browserPhone.autoClaimCallId = match.call.call_id;
state.browserPhone.popupCallId = match.call.call_id;
state.browserPhone.warning = '';
return true;
}
if (match.mode === 'claim') {
try {
const claimed = await claimLiveCallById(match.call.call_id, { operatorExtension });
state.browserPhone.autoClaimCallId = claimed.call_id;
state.browserPhone.popupCallId = claimed.call_id;
state.browserPhone.warning = '';
log('Browser softphone выполнил auto-claim', { call_id: claimed.call_id, extension: operatorExtension || claimed.operator_extension });
return true;
} catch (err) {
setBrowserPhoneWarning('Звонок соединён, но авто-claim не выполнился. Нажмите «Принять в работу».');
log('Browser softphone не смог выполнить auto-claim', { error: describeLiveCallError(err.message) });
return false;
}
}
return false;
};
try {
const localMatch = browserPhoneAutoClaimSnapshot();
if (await handleMatch(localMatch)) {
return true;
}
for (let attempt = 0; attempt < refreshAttempts; attempt += 1) {
if (attempt > 0 && retryDelayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, retryDelayMs));
}
await loadLiveCalls(false, { preserveStateOnError: true });
const refreshedMatch = browserPhoneAutoClaimSnapshot();
if (await handleMatch(refreshedMatch)) {
return true;
}
}
setBrowserPhoneWarning('Не удалось однозначно сопоставить звонок. Нажмите «Принять в работу» вручную.');
log('Browser softphone требует ручного claim', { candidates: browserPhoneClaimCandidates().length });
return false;
} finally {
state.browserPhone.autoClaimInFlight = false;
updateBrowserPhoneUi();
}
}
async function fetchBrowserSoftphoneConfig() {
try {
const data = await api('asterisk-bridge', 'asterisk/browser-softphone/config');
state.browserPhone.config = data;
state.browserPhone.status = data.enabled ? 'Готов к подключению' : 'Отключён в окружении';
} catch (err) {
state.browserPhone.config = null;
state.browserPhone.status = 'Не настроен';
log('Browser softphone config недоступен', { error: describeLiveCallError(err.message) });
} finally {
updateBrowserPhoneUi();
}
}
async function connectBrowserSoftphone() {
if (state.browserPhone.connecting || state.browserPhone.connected) {
return;
}
if (!browserPhoneLibraryAvailable()) {
setBrowserPhoneStatus('SIP.js asset не загружен');
return;
}
if (!browserPhoneConfig()) {
await fetchBrowserSoftphoneConfig();
}
const config = browserPhoneConfig();
if (!config?.enabled) {
updateBrowserPhoneUi();
return;
}
try {
state.browserPhone.connecting = true;
state.browserPhone.settingsOpen = false;
state.browserPhone.warning = '';
setBrowserPhoneStatus('Подключаем browser softphone...');
await ensureBrowserPhoneMediaAccess();
await ensureBrowserPhoneAudioContextResumed();
await loadBrowserPhoneDevices();
const uri = window.SIP.UserAgent.makeURI(config.sip_uri);
if (!uri) {
throw new Error('Invalid browser SIP URI');
}
const userAgent = new window.SIP.UserAgent({
uri,
authorizationUsername: config.authorization_username,
authorizationPassword: config.password,
displayName: config.display_name,
transportOptions: { server: config.ws_url },
sessionDescriptionHandlerFactoryOptions: {
constraints: browserPhoneAudioConstraints(),
peerConnectionConfiguration: {
iceServers: Array.isArray(config.ice_servers) ? config.ice_servers : [],
iceCandidatePoolSize: 4,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
},
},
});
userAgent.delegate = {
onInvite: (invitation) => {
if (!ensureBrowserSessionFreshForInvite()) {
invitation.reject().catch(() => {});
log('Browser softphone отклонил входящий INVITE: предыдущая session ещё активна');
return;
}
bindBrowserPhoneSession(invitation);
log('Browser softphone получил входящий звонок', { remote: browserPhoneIncomingLabel(invitation) });
},
};
const registerer = new window.SIP.Registerer(userAgent);
await userAgent.start();
await registerer.register();
state.browserPhone.ua = userAgent;
state.browserPhone.registerer = registerer;
state.browserPhone.connected = true;
setBrowserPhoneStatus('Зарегистрирован');
} catch (err) {
state.browserPhone.connected = false;
state.browserPhone.ua = null;
state.browserPhone.registerer = null;
setBrowserPhoneStatus('Ошибка регистрации');
log('Browser softphone не подключился', { error: err.message });
} finally {
state.browserPhone.connecting = false;
updateBrowserPhoneUi();
}
}
async function disconnectBrowserSoftphone() {
try {
stopBrowserPhoneRingtone();
if (state.browserPhone.session) {
await terminateBrowserPhoneSessionLocally();
}
if (state.browserPhone.registerer) {
await state.browserPhone.registerer.unregister().catch(() => {});
}
if (state.browserPhone.ua) {
await state.browserPhone.ua.stop().catch(() => {});
}
} finally {
state.browserPhone.connected = false;
state.browserPhone.connecting = false;
state.browserPhone.settingsOpen = false;
state.browserPhone.ua = null;
state.browserPhone.registerer = null;
state.browserPhone.popupCallId = '';
state.browserPhone.endingCallId = '';
state.browserPhone.callPhase = '';
state.browserPhone.warning = '';
stopBrowserPhoneLocalStream();
resetBrowserPhoneSessionState();
setBrowserPhoneStatus(browserPhoneConfig()?.enabled ? 'Отключён' : 'Не настроен');
}
}
async function answerBrowserSoftphoneCall() {
const session = state.browserPhone.session;
if (!session || !state.browserPhone.incoming) {
return;
}
try {
stopBrowserPhoneRingtone();
state.browserPhone.warning = '';
state.browserPhone.incoming = false;
state.browserPhone.callPhase = 'connecting';
setBrowserPhoneStatus('Соединяем browser media...');
updateBrowserPhoneUi();
const localMediaStream = await ensureBrowserPhoneLocalStream();
await session.accept({
sessionDescriptionHandlerOptions: {
constraints: browserPhoneAudioConstraints(),
localMediaStream,
},
});
const hasImmediateMatch = Boolean(browserPhoneAutoClaimSnapshot());
void bestEffortBrowserAutoClaimWithOptions({
refreshAttempts: hasImmediateMatch ? 0 : 2,
retryDelayMs: 150,
});
} catch (err) {
state.browserPhone.incoming = true;
state.browserPhone.callPhase = 'incoming';
startBrowserPhoneRingtone().catch(() => {});
setBrowserPhoneWarning('Не удалось ответить на звонок в браузере.');
setBrowserPhoneStatus('Входящий звонок');
log('Ответ в браузерном телефоне не выполнен', { error: err.message });
}
}
async function rejectBrowserSoftphoneCall() {
if (!state.browserPhone.session || !state.browserPhone.incoming) {
return;
}
stopBrowserPhoneRingtone();
state.browserPhone.warning = '';
await terminateBrowserPhoneSessionLocally();
resetBrowserPhoneSessionState();
state.browserPhone.callPhase = '';
if (state.browserPhone.connected) {
setBrowserPhoneStatus('Зарегистрирован');
}
}
function toggleBrowserSoftphoneMute() {
if (!state.browserPhone.session) {
return;
}
state.browserPhone.muted = !state.browserPhone.muted;
applyBrowserMuteState();
updateBrowserPhoneUi();
}
function syncLiveCallActionButtons() {
const loadActionsBtn = $('loadLiveCallActionsBtn');
const refreshBtn = $('loadLiveCallsBtn');
const buttons = [loadActionsBtn, refreshBtn].filter(Boolean);
const hasCall = Boolean(selectedLiveCallId());
const pendingAction = state.liveCalls.pendingAction || '';
buttons.forEach((button) => {
if (!button.dataset.defaultLabel) {
button.dataset.defaultLabel = button.textContent;
}
button.textContent = button.dataset.defaultLabel;
});
loadActionsBtn.disabled = !hasCall || Boolean(pendingAction);
refreshBtn.disabled = Boolean(pendingAction);
updateBrowserPhoneUi();
}
async function runLiveCallAction(actionName, handler) {
if (state.liveCalls.pendingAction) {
log('Дождитесь завершения предыдущего действия по звонку');
return null;
}
state.liveCalls.pendingAction = actionName;
syncLiveCallActionButtons();
try {
return await handler();
} finally {
state.liveCalls.pendingAction = '';
syncLiveCallActionButtons();
}
}
async function claimBrowserPopupCall() {
const popupCall = browserPhoneActivePopupCall();
if (!popupCall) {
setBrowserPhoneWarning('Не удалось найти активный звонок для claim.');
return;
}
await runLiveCallAction('claim', async () => {
try {
const data = await claimLiveCallById(popupCall.call_id);
state.browserPhone.popupCallId = data.call_id;
state.browserPhone.warning = '';
log('Звонок принят в работу', {
call_id: data.call_id,
claimed_by: data.claimed_by_user,
extension: data.operator_extension,
});
} catch (err) {
setBrowserPhoneWarning(describeLiveCallError(err.message));
log('Не удалось принять звонок в работу', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) });
}
});
}
async function hangupBrowserPopupCall() {
const popupCall = browserPhoneActivePopupCall();
if (!popupCall && !state.browserPhone.session) {
setBrowserPhoneWarning('Не удалось найти активный звонок для завершения.');
return;
}
await runLiveCallAction('hangup', async () => {
const callId = popupCall?.call_id || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId || selectedLiveCallId();
try {
state.browserPhone.endingCallId = callId;
state.browserPhone.callPhase = 'ending';
state.browserPhone.warning = '';
updateBrowserPhoneUi();
if (state.browserPhone.session) {
await terminateBrowserPhoneSessionLocally();
resetBrowserPhoneSessionState();
state.browserPhone.endingCallId = callId;
state.browserPhone.popupCallId = callId;
state.browserPhone.callPhase = 'ending';
updateBrowserPhoneUi();
}
if (!callId) {
throw new Error('hangup failed: call_id is missing');
}
await hangupLiveCallById(callId, { optimistic: false });
log('Отправлена команда завершения звонка', { call_id: callId });
} catch (err) {
state.browserPhone.callPhase = 'error';
state.browserPhone.endingCallId = callId || state.browserPhone.endingCallId;
setBrowserPhoneWarning(describeLiveCallError(err.message));
refreshLiveCallsInBackground();
log('Не удалось завершить звонок', { call_id: callId || popupCall?.call_id || '', error: describeLiveCallError(err.message) });
}
});
}
async function transferBrowserPopupCall() {
const popupCall = browserPhoneActivePopupCall();
const targetType = $('browserPhoneTransferTargetType').value;
const targetValue = $('browserPhoneTransferTargetValue').value.trim();
if (!popupCall) {
setBrowserPhoneWarning('Не удалось найти активный звонок для передачи.');
return;
}
if (!targetValue) {
setBrowserPhoneWarning('Укажите target для передачи звонка.');
return;
}
await runLiveCallAction('transfer', async () => {
try {
const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(popupCall.call_id)}/blind-transfer`, {
method: 'POST',
body: JSON.stringify({ target_type: targetType, target_value: targetValue }),
});
if (state.browserPhone.session) {
await terminateBrowserPhoneSessionLocally();
resetBrowserPhoneSessionState();
}
state.browserPhone.popupCallId = '';
state.browserPhone.endingCallId = '';
state.browserPhone.warning = '';
moveLiveCallToRecent(data);
refreshLiveCallsInBackground();
log('Blind transfer выполнен', {
call_id: data.call_id,
target_type: targetType,
target_value: targetValue,
});
} catch (err) {
setBrowserPhoneWarning(describeLiveCallError(err.message));
log('Не удалось передать звонок', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) });
}
});
}
function updateLiveCallSelector(items) {
const select = $('liveCallIdSelect');
const previous = state.liveCalls.selectedCallId || select.value || '';
const sorted = [...items];
select.innerHTML = sorted.length
? sorted
.map((item) => {
const summary = voiceSummaryForItem(item);
const label = `${voiceCustomerDisplayName(item, summary)} | ${item.call_id}${item.interaction_id ? ` | ${item.interaction_id}` : ''}`;
return `<option value="${escapeHtml(item.call_id)}">${escapeHtml(label)}</option>`;
})
.join('')
: '<option value="">Нет активных звонков</option>';
if (sorted.length) {
const hasPrevious = sorted.some((item) => item.call_id === previous);
const latestRinging = sorted.find((item) => item.telephony_status === 'ringing' && !item.claimed_by_user);
if (latestRinging && latestRinging.call_id !== previous) {
select.value = latestRinging.call_id;
} else {
select.value = hasPrevious ? previous : sorted[0].call_id;
}
} else {
select.value = '';
}
state.liveCalls.selectedCallId = select.value;
syncLiveCallActionButtons();
}
function renderLiveCallCard(item, { recent = false } = {}) {
const isSelected = item.call_id === state.liveCalls.selectedCallId;
const summary = voiceSummaryForItem(item);
const caller = voiceCustomerDisplayName(item, summary);
const aiMeta = voiceAiStatusMeta(item);
const customerId = liveCallCustomerId(item);
const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status);
const nameStatusLine = nameStatusMeta
? `<p class="card-meta-line">имя: ${escapeHtml(nameStatusMeta.label)}</p>`
: '';
const nameSourceLabel = formatVoiceCustomerNameSource(summary?.customer_name_source);
const nameSourceLine = nameSourceLabel
? `<p class="card-meta-line">источник имени: ${escapeHtml(nameSourceLabel)}</p>`
: '';
const languageLabel = formatVoiceStartLanguage(summary?.voice_start_language);
const languageLine = languageLabel
? `<p class="card-meta-line">язык старта: ${escapeHtml(languageLabel)}</p>`
: '';
const badges = [
`<span class="micro-badge queue">${escapeHtml(item.queue_code || item.queue_id)}</span>`,
`<span class="micro-badge assignee">${escapeHtml(telephonyLabel(item.telephony_status))}</span>`,
];
if (aiMeta) {
badges.push(`<span class="micro-badge ${escapeHtml(aiMeta.className)}">${escapeHtml(aiMeta.label)}</span>`);
}
if (nameStatusMeta) {
badges.push(`<span class="micro-badge ${escapeHtml(nameStatusMeta.className)}">${escapeHtml(nameStatusMeta.shortLabel)}</span>`);
}
if (recent && item.terminal_action) {
badges.push(`<span class="micro-badge terminal">${escapeHtml(terminalActionLabel(item))}</span>`);
}
if (!recent && item.claimed_by_user) {
badges.push(`<span class="micro-badge owner">${escapeHtml(item.claimed_by_user)}</span>`);
}
const targetLine = recent && item.terminal_action === 'blind-transfer' && item.terminal_target
? `<p class="card-meta-line">цель: ${escapeHtml(item.terminal_target)}</p>`
: '';
const hangupLine = recent && item.hangup_cause
? `<p class="card-meta-line">завершение: ${escapeHtml(item.hangup_cause)}</p>`
: '';
const aiReasonLine = item.ai_handoff_reason
? `<p class="card-meta-line">AI: ${escapeHtml(item.ai_handoff_reason)}</p>`
: '';
const timingLine = recent
? `<p class="card-meta-line">завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}</p>`
: `<p class="card-meta-line">соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}</p>`;
const actions = [
customerId ? `<button type="button" class="btn ghost" data-live-call-action="edit-name" data-call-id="${escapeHtml(item.call_id)}">Исправить имя</button>` : '',
customerId ? `<button type="button" class="btn ghost" data-live-call-action="customer" data-call-id="${escapeHtml(item.call_id)}" data-customer-id="${escapeHtml(customerId)}">К клиенту</button>` : '',
].filter(Boolean);
return `
<article class="pipeline-card live-call-card ${recent ? 'closed' : ''} ${isSelected ? 'selected' : ''}">
<div class="card-badges">${badges.join('')}</div>
<h3 class="card-title">${escapeHtml(caller)}</h3>
<p class="card-subtitle">${escapeHtml(voiceCustomerCallSubtitle(item))}</p>
<p class="card-meta-line">обращение: ${escapeHtml(item.interaction_id || 'не найдено')}</p>
<p class="card-meta-line">взял в работу: ${escapeHtml(item.claimed_by_user || '-')}</p>
<p class="card-meta-line">внутренний номер: ${escapeHtml(item.operator_extension || '-')}</p>
<p class="card-meta-line">начат: ${escapeHtml(formatIsoShort(item.started_at))}</p>
${timingLine}
${nameStatusLine}
${nameSourceLine}
${languageLine}
${aiReasonLine}
${targetLine}
${hangupLine}
<p class="card-meta-line">запись: ${item.has_recording ? 'да' : 'нет'}</p>
${actions.length ? `<div class="live-call-card-actions">${actions.join('')}</div>` : ''}
</article>
`;
}
function renderLiveCallColumn(title, items, emptyMessage, options = {}) {
const { recent = false } = options;
return `
<section class="pipeline-column ${recent ? 'pipeline-column-recent' : ''}">
<div class="pipeline-head">
<div class="pipeline-title">${escapeHtml(title)}</div>
<div class="pipeline-count">${items.length}</div>
</div>
<div class="pipeline-stack">
${items.length ? items.map((item) => renderLiveCallCard(item, { recent })).join('') : `<div class="empty-state">${escapeHtml(emptyMessage)}</div>`}
</div>
</section>
`;
}
function renderLiveCallsTable(activeItems, recentItems) {
activeItems.forEach((item) => ensureVoiceAiSummaryLoaded(item));
recentItems.forEach((item) => ensureVoiceAiSummaryLoaded(item));
$('liveCallsTable').innerHTML = `
<div class="pipeline-board live-calls-board">
${renderLiveCallColumn('Активные звонки', activeItems, 'Активных звонков нет.')}
${renderLiveCallColumn('Только что завершённые', recentItems, 'Недавних завершённых звонков нет.', { recent: true })}
</div>
`;
if (!activeItems.length && !recentItems.length) {
$('liveCallStatusHint').textContent = 'Активных звонков нет.';
syncLiveCallActionButtons();
return;
}
if (activeItems.length) {
const first = activeItems[0];
$('liveCallStatusHint').textContent = `Активных: ${activeItems.length}. Недавно завершённых: ${recentItems.length}. Последний активный: ${first.call_id} (${telephonyLabel(first.telephony_status)}).`;
} else {
const firstRecent = recentItems[0];
$('liveCallStatusHint').textContent = `Активных нет. Недавно завершённых: ${recentItems.length}. Последний завершённый: ${firstRecent.call_id} (${terminalActionLabel(firstRecent)}).`;
}
syncLiveCallActionButtons();
}
function uniqueLiveCalls(items) {
const seen = new Set();
return (items || []).filter((item) => {
const callId = item?.call_id;
if (!callId || seen.has(callId)) {
return false;
}
seen.add(callId);
return true;
});
}
function applyLiveCallCollections(activeItems, recentItems, options = {}) {
const { selectedCallId = undefined } = options;
state.liveCalls.items = uniqueLiveCalls(activeItems);
state.liveCalls.recentItems = uniqueLiveCalls(recentItems);
if (selectedCallId !== undefined) {
state.liveCalls.selectedCallId = selectedCallId;
}
updateLiveCallSelector(state.liveCalls.items);
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
updateLiveCallNameEditorsUi();
syncBrowserPhonePopupLifecycle();
renderCustomerSpotlight();
renderUnifiedInbox();
}
function applyClaimedLiveCall(item) {
const activeItems = [...state.liveCalls.items];
const existingIndex = activeItems.findIndex((entry) => entry.call_id === item.call_id);
if (existingIndex >= 0) {
activeItems[existingIndex] = item;
} else {
activeItems.unshift(item);
}
const recentItems = state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id);
applyLiveCallCollections(activeItems, recentItems, { selectedCallId: item.call_id });
}
function moveLiveCallToRecent(item) {
const activeItems = state.liveCalls.items.filter((entry) => entry.call_id !== item.call_id);
const recentItems = [item, ...state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id)];
const nextSelectedCallId = state.liveCalls.selectedCallId === item.call_id
? (activeItems[0]?.call_id || '')
: state.liveCalls.selectedCallId;
applyLiveCallCollections(activeItems, recentItems, { selectedCallId: nextSelectedCallId });
}
function refreshLiveCallsInBackground() {
window.setTimeout(() => {
loadLiveCalls(false, { preserveStateOnError: true });
}, 0);
}
async function loadLiveCalls(logResult = true, options = {}) {
const { preserveStateOnError = false } = options;
try {
const [activeData, recentData] = await Promise.all([
api('asterisk-bridge', 'asterisk/live-calls'),
api('asterisk-bridge', 'asterisk/recent-calls'),
]);
const items = Array.isArray(activeData) ? activeData : [];
const recentItems = Array.isArray(recentData) ? recentData : [];
applyLiveCallCollections(items, recentItems);
if (logResult) {
log('Живые звонки обновлены', { active: items.length, recent: recentItems.length });
}
} catch (err) {
if (!preserveStateOnError) {
applyLiveCallCollections([], [], { selectedCallId: '' });
$('liveCallStatusHint').textContent = 'Сервис live-calls недоступен.';
}
if (logResult) {
log('Не удалось загрузить звонки', { error: describeLiveCallError(err.message) });
}
} finally {
syncLiveCallActionButtons();
}
}
async function claimLiveCall() {
await claimBrowserPopupCall();
}
async function hangupLiveCall() {
await hangupBrowserPopupCall();
}
async function blindTransferLiveCall() {
await transferBrowserPopupCall();
}
async function loadLiveCallActions() {
const callId = selectedLiveCallId();
if (!callId) {
$('liveCallActionsOutput').textContent = 'Выберите звонок.';
return;
}
try {
const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/actions`);
setOutput('liveCallActionsOutput', data);
log('Загружен action log по звонку', { call_id: callId, actions: data.length });
} catch (err) {
$('liveCallActionsOutput').textContent = err.message;
log('Не удалось загрузить action log', { call_id: callId, error: err.message });
}
}
function startLiveCallsPolling() {
if (state.liveCalls.pollTimer) {
window.clearInterval(state.liveCalls.pollTimer);
state.liveCalls.pollTimer = null;
}
state.liveCalls.pollTimer = window.setInterval(() => {
loadLiveCalls(false);
}, 3000);
}
async function loadInteractions() {
try {
const data = await api('interaction', 'interactions');
state.interactions = Array.isArray(data) ? data : [];
if (!data.length) {
setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.');
renderCustomerSpotlight();
renderUnifiedInbox();
return;
}
$('interactionTable').innerHTML = renderInteractionBoard(data);
renderCustomerSpotlight();
renderUnifiedInbox();
} catch (err) {
state.interactions = [];
setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.');
renderCustomerSpotlight();
renderUnifiedInbox();
log('Не удалось загрузить обращения', { error: err.message });
}
}
window.assignInteraction = async function assignInteraction(id) {
const assignee = getDefaultAssignee();
try {
await api('interaction', `interactions/${id}/assign`, {
method: 'PATCH',
body: JSON.stringify({ assignee }),
});
await loadInteractions();
log('Обращение назначено', { id, assignee });
} catch (err) {
log('Не удалось назначить обращение', { error: err.message });
}
};
window.escalateInteraction = async function escalateInteraction(id) {
const targetQueueId = getDefaultEscalationQueue();
try {
await api('interaction', `interactions/${id}/escalate`, {
method: 'POST',
body: JSON.stringify({ target_queue_id: targetQueueId }),
});
await loadInteractions();
log('Обращение передано на 2 линию', { id, target_queue_id: targetQueueId });
} catch (err) {
log('Не удалось передать обращение', { error: err.message });
}
};
window.closeInteraction = async function closeInteraction(id) {
try {
await api('interaction', `interactions/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: 'closed' }),
});
await loadInteractions();
log('Обращение закрыто', { id });
} catch (err) {
log('Не удалось закрыть обращение', { error: err.message });
}
};
async function loadSupervisor() {
try {
const data = await api('supervisor', 'supervisor/realtime');
renderSupervisorSummary(data);
setOutput('supervisorOutput', data);
log('Данные супервизора обновлены', {
agents: data?.agents?.total || 0,
queue: data?.queues?.[0]?.queue_id || 'нет',
});
} catch (err) {
$('supervisorSummary').innerHTML = renderSummaryCard('Супервизор', 'нет данных', 'Сервис не ответил');
$('supervisorOutput').textContent = err.message;
log('Не удалось обновить супервизора', { error: err.message });
}
}
async function loadKpi() {
try {
const data = await api('reporting', 'reports/kpi');
renderKpiSummary(data);
setOutput('kpiOutput', data);
log('KPI обновлены', data?.kpi || {});
} catch (err) {
$('kpiSummary').innerHTML = renderSummaryCard('KPI', 'нет данных', 'Сервис не ответил');
$('kpiOutput').textContent = err.message;
log('Не удалось обновить KPI', { error: err.message });
}
}
async function handleCustomerActionClick(event) {
const button = event.target.closest('[data-customer-action]');
if (!button) {
return;
}
const customer = selectedCustomer();
if (!customer) {
return;
}
if (button.dataset.customerAction === 'edit-name') {
openCustomerProfileNameEditor(customer.customer_id);
return;
}
if (button.dataset.customerAction === 'cancel-name-edit') {
closeCustomerProfileNameEditor();
return;
}
if (button.dataset.customerAction === 'workspace') {
$('interactionCustomerId').value = customer.customer_id;
window.location.hash = '#workspace';
log('Клиент открыт в рабочем столе', { customer_id: customer.customer_id });
return;
}
if (button.dataset.customerAction === 'telegram') {
const threadId = button.dataset.threadId || '';
if (!threadId) {
return;
}
window.location.hash = '#messages';
await selectMessengerConversation(messengerConversationId('telegram', threadId));
log('Открыт Telegram клиента в Сообщениях', { customer_id: customer.customer_id, thread_id: threadId });
return;
}
if (button.dataset.customerAction === 'interaction') {
const interactionId = button.dataset.interactionId || '';
$('interactionCustomerId').value = customer.customer_id;
window.location.hash = '#workspace';
log('Открыт кейс клиента', { customer_id: customer.customer_id, interaction_id: interactionId || '-' });
return;
}
if (button.dataset.customerAction === 'call') {
const callId = button.dataset.callId || '';
if (!callId) {
return;
}
focusLiveCall(callId);
window.location.hash = '#calls';
log('Открыт звонок клиента', { customer_id: customer.customer_id, call_id: callId });
}
}
function handleCustomerProfileNameInput(event) {
const input = event.target?.closest?.('[data-customer-name-input]');
if (!input) {
return;
}
state.customers.nameEditor.draft = input.value || '';
state.customers.nameEditor.flash = '';
if (!state.customers.nameEditor.error) {
return;
}
state.customers.nameEditor.error = '';
const form = input.closest('[data-customer-name-form]');
const status = form?.querySelector?.('[data-customer-name-status]');
if (status) {
status.textContent = 'Имя сохранится в профиле клиента и voice-контуре.';
status.classList.remove('error');
}
}
function handleCustomerProfileNameSubmit(event) {
const form = event.target?.closest?.('[data-customer-name-form]');
if (!form) {
return;
}
event.preventDefault();
saveCustomerProfileName().catch((err) => {
state.customers.nameEditor.saving = false;
state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
renderCustomerSpotlight();
});
}
function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await refreshUnifiedInbox();
if (operatorHashState().view === 'messages') {
await loadMessengerThreads(false, { preserveSelection: true, preserveOnError: true });
}
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
log('Данные на экране обновлены');
});
$('refreshUnifiedInboxBtn').addEventListener('click', () => {
refreshUnifiedInbox().catch((err) => {
log('Не удалось обновить единую очередь', { error: err.message });
});
});
$('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick);
$('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm());
$('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false));
$('createCustomerBtn').addEventListener('click', createCustomer);
$('exportCustomersBtn').addEventListener('click', exportCustomersCsv);
$('customerPrevPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page - 1));
$('customerNextPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page + 1));
$('customerQuery').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchCustomers();
}
});
$('customerList').addEventListener('click', (event) => {
const row = event.target.closest('[data-customer-id]');
if (!row) {
return;
}
openCustomerProfile(row.dataset.customerId || '');
});
$('customerList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const row = event.target.closest('[data-customer-id]');
if (!row) {
return;
}
event.preventDefault();
openCustomerProfile(row.dataset.customerId || '');
});
$('customerSpotlight')?.addEventListener('click', handleCustomerActionClick);
$('customerSpotlight')?.addEventListener('input', handleCustomerProfileNameInput);
$('customerSpotlight')?.addEventListener('submit', handleCustomerProfileNameSubmit);
$('customerProfileContent').addEventListener('click', handleCustomerActionClick);
$('customerProfileContent').addEventListener('input', handleCustomerProfileNameInput);
$('customerProfileContent').addEventListener('submit', handleCustomerProfileNameSubmit);
$('customerProfileBackBtn').addEventListener('click', () => {
window.location.hash = '#customers-page';
});
$('messengerRefreshBtn').addEventListener('click', () => {
loadMessengerThreads(true, { preserveSelection: true, preserveOnError: true }).catch(() => {});
});
$('messengerRetryBtn').addEventListener('click', () => {
loadMessengerThreads(true, { preserveSelection: true, preserveOnError: true }).catch(() => {});
});
document.querySelectorAll('[data-messenger-filter]').forEach((button) => {
button.addEventListener('click', () => setMessengerFilter(button.dataset.messengerFilter || 'all'));
});
$('messengerConversationList').addEventListener('click', (event) => {
const button = event.target.closest('[data-messenger-conversation-id]');
if (!button) {
return;
}
selectMessengerConversation(button.dataset.messengerConversationId || '').catch(() => {});
});
$('messengerConversationList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-messenger-conversation-id]');
if (!button) {
return;
}
event.preventDefault();
selectMessengerConversation(button.dataset.messengerConversationId || '').catch(() => {});
});
$('messengerClaimBtn').addEventListener('click', () => {
claimMessengerConversation().catch(() => {});
});
$('messengerCloseBtn').addEventListener('click', () => {
closeMessengerConversation().catch(() => {});
});
$('messengerOpenProfileBtn').addEventListener('click', openMessengerCustomerProfile);
$('messengerContextOpenProfileBtn').addEventListener('click', openMessengerCustomerProfile);
$('messengerSendReplyBtn').addEventListener('click', () => {
sendMessengerReply().catch(() => {});
});
$('messengerReplyText').addEventListener('input', (event) => {
state.messenger.composerText = event.target.value || '';
syncMessengerComposerHeight();
syncMessengerActionButtons();
});
$('messengerReplyText').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
sendMessengerReply().catch(() => {});
});
$('loadTelegramThreadsBtn').addEventListener('click', () => loadTelegramThreads(true, { preserveSelection: true, preserveOnError: true }));
$('telegramThreadSearch').addEventListener('input', (event) => {
state.telegram.searchQuery = event.target.value || '';
renderTelegramWorkspace();
});
$('telegramHeaderSearchBtn').addEventListener('click', () => {
toggleTelegramMessageSearch();
});
$('telegramMessageSearch').addEventListener('input', (event) => {
state.telegram.messageSearchQuery = event.target.value || '';
renderTelegramWorkspace();
});
$('telegramMessageSearch').addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
if (telegramMessageSearchQuery()) {
state.telegram.messageSearchQuery = '';
renderTelegramWorkspace();
return;
}
toggleTelegramMessageSearch(false);
});
$('telegramMessageSearchClearBtn').addEventListener('click', () => {
if (telegramMessageSearchQuery()) {
state.telegram.messageSearchQuery = '';
renderTelegramWorkspace();
$('telegramMessageSearch').focus();
return;
}
toggleTelegramMessageSearch(false);
});
$('telegramHeaderCallBtn').addEventListener('click', () => {
log('Voice call action для Telegram пока не назначен');
});
$('telegramHeaderMenuBtn').addEventListener('click', () => {
toggleTelegramOperatorTray();
});
$('telegramThreadsList').addEventListener('click', (event) => {
const button = event.target.closest('[data-telegram-thread-id]');
if (!button) {
return;
}
selectTelegramThread(button.dataset.telegramThreadId || '');
});
$('telegramThreadsList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-telegram-thread-id]');
if (!button) {
return;
}
event.preventDefault();
selectTelegramThread(button.dataset.telegramThreadId || '');
});
$('telegramClaimThreadBtn').addEventListener('click', claimTelegramThread);
$('telegramReturnToAiBtn').addEventListener('click', returnTelegramThreadToAi);
$('telegramCloseThreadBtn').addEventListener('click', closeTelegramThread);
$('telegramEscalateThreadBtn').addEventListener('click', escalateTelegramThread);
$('telegramSendReplyBtn').addEventListener('click', sendTelegramReply);
$('telegramReplyText').addEventListener('input', () => {
syncTelegramComposerHeight();
syncTelegramActionButtons();
});
$('telegramReplyText').addEventListener('keydown', (event) => {
if (event.key !== 'Enter') {
return;
}
if (event.shiftKey) {
return;
}
event.preventDefault();
sendTelegramReply();
});
if (featureEnabled('whatsapp')) {
$('whatsappSearchInput').addEventListener('input', (event) => {
state.whatsapp.searchQuery = event.target.value || '';
renderWhatsappWorkspace();
});
$('whatsappFilterBar').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-filter]');
if (!button) {
return;
}
setWhatsappFilter(button.dataset.whatsappFilter || 'all');
});
$('whatsappClaimBtn').addEventListener('click', () => {
claimWhatsappThread().catch(() => {});
});
$('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi);
$('whatsappChatList').addEventListener('click', (event) => {
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
$('whatsappChatList').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const button = event.target.closest('[data-whatsapp-chat-id]');
if (!button) {
return;
}
event.preventDefault();
selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {});
});
document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => {
button.addEventListener('click', handleWhatsappUiAction);
});
$('whatsappSendBtn').addEventListener('click', sendWhatsappMessage);
$('whatsappComposerInput').addEventListener('input', (event) => {
state.whatsapp.composerText = event.target.value || '';
syncWhatsappComposerUi();
});
$('whatsappComposerInput').addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || event.shiftKey) {
return;
}
event.preventDefault();
sendWhatsappMessage();
});
}
$('createInteractionBtn').addEventListener('click', createInteraction);
$('loadInteractionsBtn').addEventListener('click', loadInteractions);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true));
$('loadLiveCallActionsBtn').addEventListener('click', loadLiveCallActions);
$('liveCallsTable').addEventListener('click', handleLiveCallTableClick);
$('liveCallNameInput').addEventListener('input', (event) => {
state.liveCalls.nameEditor.draft = event.target.value || '';
if (state.liveCalls.nameEditor.error) {
state.liveCalls.nameEditor.error = '';
}
updateLiveCallNameEditorsUi();
});
$('liveCallNameSaveBtn').addEventListener('click', () => {
saveLiveCallCustomerName().catch((err) => {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
});
});
$('liveCallNameCancelBtn').addEventListener('click', closeLiveCallNameEditor);
$('browserPhoneStatusBtn').addEventListener('click', toggleBrowserPhoneSettings);
$('browserPhoneCallSettingsBtn').addEventListener('click', toggleBrowserPhoneSettings);
$('browserPhoneConnectBtn').addEventListener('click', connectBrowserSoftphone);
$('browserPhoneDisconnectBtn').addEventListener('click', disconnectBrowserSoftphone);
$('browserPhoneMuteBtn').addEventListener('click', toggleBrowserSoftphoneMute);
$('browserPhoneAnswerBtn').addEventListener('click', answerBrowserSoftphoneCall);
$('browserPhoneRejectBtn').addEventListener('click', rejectBrowserSoftphoneCall);
$('browserPhoneCallClaimBtn').addEventListener('click', claimBrowserPopupCall);
$('browserPhoneEditNameBtn').addEventListener('click', () => {
const popupCall = browserPhoneActivePopupCall();
if (popupCall?.call_id) {
openLiveCallNameEditor(popupCall.call_id, 'popup');
}
});
$('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall);
$('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall);
$('browserPhoneNameInput').addEventListener('input', (event) => {
state.liveCalls.nameEditor.draft = event.target.value || '';
if (state.liveCalls.nameEditor.error) {
state.liveCalls.nameEditor.error = '';
}
updateLiveCallNameEditorsUi();
});
$('browserPhoneNameSaveBtn').addEventListener('click', () => {
saveLiveCallCustomerName().catch((err) => {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
});
});
$('browserPhoneNameCancelBtn').addEventListener('click', closeLiveCallNameEditor);
$('browserPhoneMicSelect').addEventListener('change', () => {
state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value;
stopBrowserPhoneLocalStream();
if (state.browserPhone.incoming) {
prepareBrowserPhoneAnswerMedia();
}
});
$('browserPhoneSpeakerSelect').addEventListener('change', () => {
state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value;
syncBrowserPhoneSpeakerSink();
});
$('browserPhoneTransferTargetValue').addEventListener('input', updateBrowserPhoneUi);
$('browserPhoneTransferTargetType').addEventListener('change', updateBrowserPhoneUi);
$('liveCallIdSelect').addEventListener('change', () => {
state.liveCalls.selectedCallId = $('liveCallIdSelect').value;
syncLiveCallActionButtons();
});
$('logoutBtn').addEventListener('click', logout);
window.addEventListener('message', handleOidcMessage);
window.addEventListener('hashchange', handleOperatorHashChange);
if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', () => {
loadBrowserPhoneDevices().catch(() => {});
});
}
}
async function init() {
if (!restoreStoredSession()) {
window.location.href = '/';
return;
}
await loadOperatorConfig();
wire();
applyOperatorViewFromHash();
$('defaultAssignee').value = DEMO_ASSIGNEE;
$('defaultEscalationQueue').value = DEMO_QUEUE;
$('telegramEscalationQueue').value = DEMO_QUEUE;
updateSessionInfo();
syncLiveCallActionButtons();
renderUnifiedInbox();
renderMessengerWorkspace();
if (featureEnabled('whatsapp')) {
renderWhatsappWorkspace();
}
renderTelegramWorkspace();
await Promise.all([checkGateway(), loadOidcConfig()]);
await fetchBrowserSoftphoneConfig();
await loadBrowserPhoneDevices().catch(() => {});
syncCustomerFormUi();
await searchCustomers();
await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
await loadLiveCalls(false);
startLiveCallsPolling();
startTelegramPolling();
if (featureEnabled('whatsapp')) {
startWhatsappPolling();
}
log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE });
}
init();