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: {},
},
customers: {
items: [],
page: 1,
pageSize: 7,
selectedCustomerId: '',
leadFormOpen: false,
historyById: {},
pendingHistoryById: {},
historyErrorsById: {},
},
telegram: {
threads: [],
selectedThreadId: '',
messages: [],
selectedThreadSummary: null,
searchQuery: '',
messageSearchQuery: '',
messageSearchOpen: false,
operatorTrayOpen: false,
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 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',
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;
}
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 = `
${message}`;
}
function setEmptyBlock(targetId, message) {
$(targetId).innerHTML = `${message}
`;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
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 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) {
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) {
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.filter(Boolean) : [];
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(' • '),
});
});
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(' • '),
});
});
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(' • '),
});
});
}
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(' • '),
});
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(' • '),
});
}
});
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 emptyCustomerSpotlightMarkup(title, description) {
return `
${escapeHtml(title)}
${escapeHtml(description)}
`;
}
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 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 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 primaryThread = historySummary?.primary_telegram_thread_id
? threads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null
: [...threads].sort((left, right) => {
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at);
})[0] || null;
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 `
${meta.token}
${escapeHtml(event.title)}
${escapeHtml(event.body)}
${event.note ? `
${escapeHtml(event.note)}
` : ''}
`;
}).join('')
: 'Пока нет событий по этому клиенту.
';
return `
${escapeHtml(customerInitials(customer.display_name))}
Клиент ${escapeHtml(customer.customer_id)}
${escapeHtml(customer.display_name || 'Без имени')}
${escapeHtml(customerLeadCompany(customer))}
${escapeHtml(source.label)}
${escapeHtml(leadStatus.label)}
AI ${score}
${summaryCards}
`;
}
function renderCustomerSpotlight() {
const target = $('customerSpotlight');
const customer = selectedCustomer();
if (target && !customer) {
target.innerHTML = emptyCustomerSpotlightMarkup(
'Выберите клиента',
'Здесь появятся профиль клиента и единая история контактов по всем каналам.',
);
}
if (target && customer) {
target.innerHTML = customerSpotlightMarkup(customer);
}
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} • омниканальный профиль`;
}
target.innerHTML = customerSpotlightMarkup(customer, { pageView: true });
}
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 = `
Лиды пока не найдены.
Попробуйте другой запрос или добавьте новый лид вручную.
`;
renderCustomerSpotlight();
updateCustomerPagerUi();
return;
}
$('customerList').innerHTML = `
ИМЯ КОНТАКТА
ИСТОЧНИК
ДАТА СОЗДАНИЯ
СТАТУС
AI SCORE
${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 `
${escapeHtml(source.label)}
${escapeHtml(customerLeadCreatedAtLabel(item, index))}
${escapeHtml(status.label)}
${score}
`;
}).join('')}
`;
renderCustomerSpotlight();
updateCustomerPagerUi();
}
function selectCustomer(customerId) {
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 `
${escapeHtml(label)}
${escapeHtml(value || '-')}
`;
}
function whatsappSummaryStatusMarkup(summary) {
if (!summary?.status_label) {
return '';
}
const tone = String(summary.status_tone || 'handoff').trim() || 'handoff';
return `${escapeHtml(summary.status_label)}`;
}
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 `
${escapeHtml(label)}
${escapeHtml(value || '—')}
`;
}
function telegramSummaryStatusMarkup(summary) {
if (!summary?.status_label) {
return '';
}
const tone = String(summary.status_tone || 'handoff').trim() || 'handoff';
return `${escapeHtml(summary.status_label)}`;
}
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()
? `${escapeHtml(part)}`
: 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 `${checks}`;
}
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 = 'Пока нет Telegram диалогов.
';
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 `
`;
})
.join('');
}
function renderTelegramMessagesTimeline() {
const target = $('telegramMessagesTimeline');
const thread = selectedTelegramThread();
if (!thread) {
target.innerHTML = 'Выберите диалог слева, чтобы увидеть историю сообщений.
';
return;
}
if (!state.telegram.messages.length) {
target.innerHTML = 'Сообщений пока нет.
';
return;
}
const messages = telegramVisibleMessages();
if (!messages.length) {
target.innerHTML = 'Ничего не найдено в этом чате.
';
return;
}
const fragments = messages.map((message) => {
const direction = message.direction || 'system';
if (direction === 'system') {
return `
${highlightTelegramMessageText(message.text || 'Системное сообщение')}
`;
}
const mine = direction === 'outbound';
const aiBadge = message.author_type === 'ai'
? 'AI'
: '';
return `
${aiBadge}
${highlightTelegramMessageText(message.text || 'Сообщение без текста.')}
${escapeHtml(telegramThreadTimeLabel(message.created_at))}
${mine ? telegramMessageStatusMarkup(message) : ''}
`;
});
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;
}
renderTelegramWorkspace();
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(() => {
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
}, 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 '✓✓';
}
if (message.deliveryStatus === 'delivered') {
return '✓✓';
}
return '✓';
}
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 = `
Чаты не найдены
Попробуйте другой запрос или переключите активный фильтр.
`;
return;
}
target.innerHTML = chats.map((chat) => {
const [start, end] = whatsappAvatarPalette(chat.title);
const selected = chat.id === state.whatsapp.selectedChatId;
const aiMeta = whatsappThreadAiStateMeta(chat);
return `
`;
}).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 = `
Выберите чат
${state.whatsapp.mode === 'live' ? 'Здесь появятся живые WhatsApp-диалоги.' : 'Демо-режим остаётся доступным, пока backend недоступен.'}
`;
return;
}
if (!chat.messages?.length) {
target.innerHTML = `
Пока нет сообщений
${state.whatsapp.mode === 'live' ? 'Ждём входящую активность WhatsApp в этом чате.' : 'Демо-рабочее место готово к первому сообщению.'}
`;
return;
}
const fragments = [
'Сообщения и звонки защищены сквозным шифрованием. Никто вне этого чата не может их прочитать или прослушать.
',
];
let lastDayKey = '';
(chat.messages || []).forEach((message) => {
const dayKey = whatsappMessageDayKey(message.createdAt);
if (message.direction === 'system') {
fragments.push(`${escapeHtml(message.text || whatsappMessageDayLabel(message.createdAt))}
`);
lastDayKey = dayKey || lastDayKey;
return;
}
if (dayKey && dayKey !== lastDayKey) {
lastDayKey = dayKey;
fragments.push(`${escapeHtml(whatsappMessageDayLabel(message.createdAt))}
`);
}
const mine = message.direction === 'out';
const aiBadge = mine && message.authorType === 'ai'
? 'AI'
: '';
const author = !mine && chat.isGroup && message.author
? `${escapeHtml(message.author)}
`
: '';
fragments.push(`
${aiBadge}
${author}
${escapeHtml(message.text || '')}
${escapeHtml(whatsappMessageTimeLabel(message.createdAt))}
${whatsappMessageStatusMarkup(message)}
`);
});
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 `
${label}
${value}
${note ? `
${note}
` : ''}
`;
}
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 === '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);
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 cardClass = item.status === 'escalated'
? 'pipeline-card escalated'
: item.status === 'closed'
? 'pipeline-card closed'
: 'pipeline-card';
const badges = [
`${escapeHtml(channelLabel(item.channel))}`,
item.queue_id ? `${escapeHtml(item.queue_id)}` : '',
item.assigned_to ? `${escapeHtml(item.assigned_to)}` : '',
].filter(Boolean).join('');
return `
${badges}
${escapeHtml(item.subject || 'Без темы')}
${escapeHtml(meta.label)}
${escapeHtml(item.interaction_id)}
Клиент: ${escapeHtml(item.customer_id || 'не привязан')}
Обновлено: ${escapeHtml(formatIsoShort(item.updated_at))}
`;
}
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 `
${BOARD_COLUMNS.map((column) => {
const cards = groups[column.key];
const content = cards.length
? cards.map((item) => renderInteractionCard(item)).join('')
: '
Пока пусто.
';
return `
${column.title}
${cards.length}
${content}
`;
}).join('')}
`;
}
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 `${escapeHtml(label)}`;
}
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 badges = [
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,
};
}
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 aiMeta = voiceAiStatusMeta(item);
const interaction = interactionById(item.interaction_id || '');
const customerId = interaction?.customer_id || '';
const caller = item.caller_name || item.caller_number || item.call_id || 'Неизвестный абонент';
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.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: item.call_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
],
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 = [
``,
item.customerId ? `` : '',
].filter(Boolean).join('');
return `
${item.badges.join('')}
${escapeHtml(item.title)}
${escapeHtml(item.subtitle)}
${item.metaLines.map((line) => `${escapeHtml(line)}
`).join('')}
${actions}
`;
}
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 = `
${UNIFIED_INBOX_COLUMNS.map((column) => `
${escapeHtml(column.title)}
${collections[column.key].length}
${collections[column.key].length
? collections[column.key].map((item) => renderUnifiedInboxCard(item)).join('')
: `
${escapeHtml(column.emptyMessage)}
`}
`).join('')}
`;
}
function focusLiveCall(callId) {
if (!callId) {
return;
}
const select = $('liveCallIdSelect');
if (select) {
select.value = callId;
}
state.liveCalls.selectedCallId = callId;
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncLiveCallActionButtons();
const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null;
ensureVoiceAiSummaryLoaded(activeCall);
}
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 = '#telegram-page';
await selectTelegramThread(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 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];
updateBrowserPhoneUi();
}
}
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 `
${escapeHtml(label)}
${escapeHtml(value)}
`;
}
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 `
Транскрипт разговора
${escapeHtml(`${segments.length} реплик`)}
${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 `
${escapeHtml(`${speaker}${interrupted}`)}
${escapeHtml(timeLabel)}
${escapeHtml(segment?.text || '')}
`;
})
.join('')}
`;
}
function renderVoiceAiSummary(summary, pending = false) {
if (!summary && !pending) {
return '';
}
if (pending && !summary) {
return `
Сводка AI
Подтягиваем контекст AI по текущему звонку...
`;
}
const statusClass = summary?.status_tone === 'handoff' ? 'handoff' : 'answered';
return `
Сводка AI
Краткий контекст передачи до ручной обработки звонка.
${escapeHtml(summary?.status_label || 'AI-сводка')}
${escapeHtml(formatIsoShort(summary?.generated_at || ''))}
${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)}
${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)}
${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)}
${renderVoiceAiSummaryField('Следующий шаг', summary?.recommended_next_step)}
${renderVoiceAiTranscript(summary)}
`;
}
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 aiMeta = voiceAiStatusMeta(item);
const parts = [
item.queue_code || item.queue_id || 'queue?',
item.interaction_id || 'interaction?',
item.operator_extension ? `ext ${item.operator_extension}` : '',
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 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');
$('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);
overlay.classList.toggle('hidden', !browserPhonePopupVisible());
popupState.textContent = errorState
? 'Нужна синхронизация завершения'
: ending
? 'Завершаем звонок'
: connecting
? 'Подключаем media'
: incoming
? 'Входящий звонок'
: active
? 'Разговор в браузере'
: 'Browser call';
popupTitle.textContent = popupCall?.caller_number || popupCall?.caller_name || browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', '');
popupIncomingText.textContent = state.browserPhone.session
? browserPhoneIncomingLabel(state.browserPhone.session)
: popupCall
? `call_id: ${popupCall.call_id}`
: 'SIP invite ещё не поступал.';
popupMeta.textContent = browserPhoneCallSummary(popupCall);
if (popupCall) {
ensureVoiceAiSummaryLoaded(popupCall);
}
const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null;
const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false;
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);
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;
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;
}
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) => ``)
.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) => ``)
.join('')
: '';
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 = !recent && item.call_id === state.liveCalls.selectedCallId;
const caller = item.caller_number || item.caller_name || 'неизвестно';
const aiMeta = voiceAiStatusMeta(item);
const badges = [
`${escapeHtml(item.queue_code || item.queue_id)}`,
`${escapeHtml(telephonyLabel(item.telephony_status))}`,
];
if (aiMeta) {
badges.push(`${escapeHtml(aiMeta.label)}`);
}
if (recent && item.terminal_action) {
badges.push(`${escapeHtml(terminalActionLabel(item))}`);
}
if (!recent && item.claimed_by_user) {
badges.push(`${escapeHtml(item.claimed_by_user)}`);
}
const targetLine = recent && item.terminal_action === 'blind-transfer' && item.terminal_target
? `цель: ${escapeHtml(item.terminal_target)}
`
: '';
const hangupLine = recent && item.hangup_cause
? `завершение: ${escapeHtml(item.hangup_cause)}
`
: '';
const aiReasonLine = item.ai_handoff_reason
? `AI: ${escapeHtml(item.ai_handoff_reason)}
`
: '';
const timingLine = recent
? `завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}
`
: `соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}
`;
return `
${badges.join('')}
${escapeHtml(item.call_id)}
обращение: ${escapeHtml(item.interaction_id)}
абонент: ${escapeHtml(caller)}
взял в работу: ${escapeHtml(item.claimed_by_user || '-')}
внутренний номер: ${escapeHtml(item.operator_extension || '-')}
начат: ${escapeHtml(formatIsoShort(item.started_at))}
${timingLine}
${aiReasonLine}
${targetLine}
${hangupLine}
запись: ${item.has_recording ? 'да' : 'нет'}
`;
}
function renderLiveCallColumn(title, items, emptyMessage, options = {}) {
const { recent = false } = options;
return `
${escapeHtml(title)}
${items.length}
${items.length ? items.map((item) => renderLiveCallCard(item, { recent })).join('') : `
${escapeHtml(emptyMessage)}
`}
`;
}
function renderLiveCallsTable(activeItems, recentItems) {
$('liveCallsTable').innerHTML = `
${renderLiveCallColumn('Активные звонки', activeItems, 'Активных звонков нет.')}
${renderLiveCallColumn('Только что завершённые', recentItems, 'Недавних завершённых звонков нет.', { recent: true })}
`;
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);
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 === '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 = '#telegram-page';
await selectTelegramThread(threadId);
log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId });
}
}
function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await refreshUnifiedInbox();
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);
$('customerProfileContent').addEventListener('click', handleCustomerActionClick);
$('customerProfileBackBtn').addEventListener('click', () => {
window.location.hash = '#customers-page';
});
$('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);
$('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);
$('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall);
$('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall);
$('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();
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();