const state = { user: 'admin', role: 'admin', token: null, authSource: 'local', fullName: null, features: { whatsapp: false, }, logLines: [], interactions: [], oidc: { enabled: false, loginPath: '/auth/oidc/start?return_mode=popup', providerLabel: 'Keycloak', }, liveCalls: { items: [], recentItems: [], selectedCallId: '', pollTimer: null, pendingAction: '', aiSummaries: {}, aiSummaryPending: {}, nameEditor: { open: false, mode: 'panel', callId: '', customerId: '', draft: '', saving: false, error: '', }, }, customers: { items: [], page: 1, pageSize: 7, selectedCustomerId: '', leadFormOpen: false, historyById: {}, pendingHistoryById: {}, historyErrorsById: {}, nameEditor: { open: false, customerId: '', draft: '', saving: false, error: '', flash: '', }, }, telegram: { threads: [], selectedThreadId: '', messages: [], selectedThreadSummary: null, searchQuery: '', messageSearchQuery: '', messageSearchOpen: false, operatorTrayOpen: false, pendingAction: '', pollTimer: null, }, 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 normalizedCustomerTags(item) { return Array.isArray(item?.tags) ? item.tags.map((tag) => String(tag || '').trim().toLowerCase()).filter(Boolean) : []; } function isFacebookCustomer(item) { const tags = normalizedCustomerTags(item); return tags.includes('facebook') || tags.includes('facebook-lead'); } function isFacebookLeadInteraction(item) { const subject = String(item?.subject || item?.title || '').trim().toLowerCase(); if (subject.startsWith('[facebook]')) { return true; } const customerId = item?.customer_id || item?.customerId || ''; if (!customerId) { return false; } const customer = state.customers.items.find((entry) => entry.customer_id === customerId) || null; return isFacebookCustomer(customer); } function customerInitials(name) { const parts = String(name || '') .trim() .split(/\s+/) .filter(Boolean) .slice(0, 2); if (!parts.length) { return 'LD'; } return parts.map((part) => part[0].toUpperCase()).join(''); } function customerLeadSourceMeta(item, index) { if (isFacebookCustomer(item)) { return { label: 'Facebook', className: 'lead-badge source-facebook' }; } const variants = [ { label: 'Instagram', className: 'lead-badge source-instagram' }, { label: 'WhatsApp', className: 'lead-badge source-whatsapp' }, { label: 'Site', className: 'lead-badge source-site' }, { label: 'Referral', className: 'lead-badge source-referral' }, ]; return variants[customerHashSeed(item, index) % variants.length]; } function customerLeadStatusMeta(item, index) { if (isFacebookCustomer(item)) { return { label: 'Новый', className: 'lead-badge status-new' }; } const variants = [ { label: 'Переговоры', className: 'lead-badge status-negotiation' }, { label: 'Квалификация', className: 'lead-badge status-qualification' }, { label: 'Предложение', className: 'lead-badge status-proposal' }, { label: 'Первичный контакт', className: 'lead-badge status-initial' }, { label: 'Успех', className: 'lead-badge status-success' }, { label: 'Новый', className: 'lead-badge status-new' }, { label: 'Отказ', className: 'lead-badge status-lost' }, ]; return variants[customerHashSeed(item, index + 17) % variants.length]; } function customerLeadScore(item, index) { return 12 + (customerHashSeed(item, index + 29) % 81); } function customerLeadScoreClass(score) { if (score >= 80) { return 'lead-score score-strong'; } if (score >= 50) { return 'lead-score score-medium'; } return 'lead-score score-weak'; } function customerLeadCompany(item) { const tags = (Array.isArray(item?.tags) ? item.tags : []) .map((tag) => String(tag || '').trim()) .filter((tag) => tag && !['facebook', 'facebook-lead', 'hot-lead'].includes(tag.toLowerCase())); if (tags.length) { return tags[0]; } const suffixes = ['Tech', 'Group', 'Retail', 'Logistics', 'Studio', 'Services']; const seed = customerHashSeed(item, 41); return `${String(item?.display_name || 'Lead').split(' ')[0]} ${suffixes[seed % suffixes.length]}`; } function customerLeadCreatedAtLabel(item, index) { if (item?.created_at) { return formatIsoShort(item.created_at); } const seed = customerHashSeed(item, index + 59); const shifted = new Date(Date.now() - ((seed % 9) * 86400000 + (seed % 18) * 3600000)); return shifted.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' }); } function customerLeadRows() { const start = (state.customers.page - 1) * state.customers.pageSize; return state.customers.items.slice(start, start + state.customers.pageSize); } function selectedCustomer() { return state.customers.items.find((item) => item.customer_id === state.customers.selectedCustomerId) || state.customers.items[0] || null; } function customerProfileHash(customerId = state.customers.selectedCustomerId) { return customerId ? `#customer-profile/${encodeURIComponent(customerId)}` : '#customer-profile'; } function openCustomerProfile(customerId) { selectCustomer(customerId || ''); ensureCustomerHistoryLoaded(customerId || state.customers.selectedCustomerId, { force: true }).catch(() => {}); window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId); } function interactionById(interactionId) { return state.interactions.find((item) => item.interaction_id === interactionId) || null; } function customerIdForInteractionId(interactionId) { return interactionById(interactionId)?.customer_id || ''; } function customerDisplayName(customerId) { if (!customerId) { return 'не привязан'; } const customer = state.customers.items.find((item) => item.customer_id === customerId) || null; return customer?.display_name || customerId; } function customerIndex(customer) { if (!customer) { return -1; } return state.customers.items.findIndex((item) => item.customer_id === customer.customer_id); } function customerPhones(customer) { const phones = []; if (Array.isArray(customer?.phones)) { phones.push(...customer.phones); } if (customer?.phone) { phones.push(customer.phone); } if (customer?.preferred_phone) { phones.unshift(customer.preferred_phone); } return [...new Set(phones.map((value) => String(value || '').trim()).filter(Boolean))]; } function customerInteractions(customer) { if (!customer) { return []; } return state.interactions.filter((item) => item.customer_id === customer.customer_id); } function customerInteractionIds(customer) { return new Set(customerInteractions(customer).map((item) => item.interaction_id).filter(Boolean)); } function customerTelegramThreads(customer) { const interactionIds = customerInteractionIds(customer); return state.telegram.threads.filter((thread) => interactionIds.has(thread.interaction_id)); } function customerLiveCalls(customer) { const interactionIds = customerInteractionIds(customer); const phones = new Set(customerPhones(customer)); return uniqueLiveCalls([...state.liveCalls.items, ...state.liveCalls.recentItems]).filter((item) => { if (interactionIds.has(item.interaction_id)) { return true; } return phones.has(String(item.caller_number || '').trim()); }); } function customerHistoryPayload(customerId) { if (!customerId) { return null; } return state.customers.historyById[customerId] || null; } function customerHistoryPending(customerId) { return Boolean(customerId && state.customers.pendingHistoryById[customerId]); } async function ensureCustomerHistoryLoaded(customerId, options = {}) { const { force = false } = options; if (!customerId) { return null; } if (customerHistoryPending(customerId)) { return customerHistoryPayload(customerId); } if (!force && customerHistoryPayload(customerId)) { return customerHistoryPayload(customerId); } state.customers.pendingHistoryById[customerId] = true; delete state.customers.historyErrorsById[customerId]; try { const data = await api('customer', `customers/${encodeURIComponent(customerId)}/history`); state.customers.historyById[customerId] = data || null; return state.customers.historyById[customerId]; } catch (err) { state.customers.historyErrorsById[customerId] = err.message; return null; } finally { delete state.customers.pendingHistoryById[customerId]; renderCustomerProfilePage(); } } function customerPreferredChannel(customer) { const counters = new Map(); customerInteractions(customer).forEach((item) => { const key = item.channel || 'voice'; counters.set(key, (counters.get(key) || 0) + 1); }); if (customerTelegramThreads(customer).length) { counters.set('telegram', (counters.get('telegram') || 0) + customerTelegramThreads(customer).length); } if (customerLiveCalls(customer).length) { counters.set('voice', (counters.get('voice') || 0) + customerLiveCalls(customer).length); } const [winner] = [...counters.entries()].sort((left, right) => right[1] - left[1]); return winner?.[0] || 'voice'; } function customerHistoryTimeValue(value) { if (!value) { return 0; } const dt = new Date(value); if (Number.isNaN(dt.getTime())) { return 0; } return dt.getTime(); } function customerHistoryMeta(kind) { if (kind === 'voice') { return { token: 'VC', className: 'voice' }; } if (kind === 'telegram') { return { token: 'TG', className: 'telegram' }; } if (kind === 'email') { return { token: 'EM', className: 'email' }; } if (kind === 'webchat') { return { token: 'WC', className: 'webchat' }; } if (kind === 'profile') { return { token: 'ID', className: 'profile' }; } return { token: 'CS', className: 'case' }; } function buildCustomerHistoryEvents(customer) { if (!customer) { return []; } const company = customerLeadCompany(customer); const phones = customerPhones(customer); const interactions = customerInteractions(customer); const interactionIds = new Set(interactions.map((item) => item.interaction_id).filter(Boolean)); const threads = customerTelegramThreads(customer); const liveCalls = customerLiveCalls(customer); const events = []; events.push({ timestamp: customer.created_at || '', kind: 'profile', title: 'Профиль клиента активен', body: phones.length ? `Контакт доступен для оператора по номеру ${phones[0]}.` : 'Контакт добавлен в клиентскую базу и готов к работе.', note: [customer.customer_id, company].filter(Boolean).join(' • '), }); interactions.forEach((item) => { const meta = statusMeta(item.status); events.push({ timestamp: item.updated_at || item.created_at || '', kind: item.channel || 'case', title: `${channelLabel(item.channel)} • ${meta.label}`, body: item.subject || 'Обращение без темы', note: [ item.interaction_id, item.assigned_to ? `владелец ${item.assigned_to}` : '', item.queue_id ? `queue ${item.queue_id}` : '', ].filter(Boolean).join(' • '), interaction_id: item.interaction_id, }); }); threads.forEach((thread) => { events.push({ timestamp: thread.last_message_at || '', kind: 'telegram', title: 'Telegram диалог', body: thread.last_message_preview || 'Последнее сообщение недоступно.', note: [ thread.display_name || thread.username || `chat ${thread.chat_id}`, thread.claimed_by_user ? `владелец ${thread.claimed_by_user}` : '', thread.status ? statusMeta(thread.status).label : '', ].filter(Boolean).join(' • '), interaction_id: thread.interaction_id, thread_id: thread.thread_id, }); }); const activeThread = selectedTelegramThread(); if (activeThread && interactionIds.has(activeThread.interaction_id) && state.telegram.messages.length) { state.telegram.messages.slice(-3).forEach((message) => { const direction = message.direction === 'outbound' ? 'Исходящее сообщение' : 'Входящее сообщение'; events.push({ timestamp: message.created_at || '', kind: 'telegram', title: direction, body: message.text || 'Сообщение без текста', note: [ activeThread.display_name || activeThread.username || `chat ${activeThread.chat_id}`, message.operator_user ? `оператор ${message.operator_user}` : '', message.delivery_status || '', ].filter(Boolean).join(' • '), interaction_id: activeThread.interaction_id, thread_id: activeThread.thread_id, }); }); } liveCalls.forEach((item) => { const completed = item.telephony_status === 'ended' || item.ended_at; events.push({ timestamp: item.last_transition_at || item.ended_at || item.connected_at || item.started_at || '', kind: 'voice', title: completed ? `Звонок • ${terminalActionLabel(item)}` : `Звонок • ${telephonyLabel(item.telephony_status)}`, body: `Номер клиента: ${item.caller_number || 'не определён'}`, note: [ item.call_id, item.operator_extension ? `внутр. ${item.operator_extension}` : '', item.claimed_by_user ? `владелец ${item.claimed_by_user}` : '', ].filter(Boolean).join(' • '), interaction_id: item.interaction_id, call_id: item.call_id, }); const aiMeta = voiceAiStatusMeta(item); if (aiMeta) { events.push({ timestamp: item.ai_last_model_at || item.last_transition_at || item.updated_at || '', kind: 'voice', title: `Голосовой AI • ${aiMeta.label}`, body: item.ai_handoff_reason || 'AI участвовал в обработке звонка и обновил контекст для оператора.', note: [ item.call_id, item.voice_session_id ? `голос ${item.voice_session_id}` : '', item.ai_session_id ? `ai ${item.ai_session_id}` : '', ].filter(Boolean).join(' • '), interaction_id: item.interaction_id, call_id: item.call_id, }); } }); return events .sort((left, right) => { const timeDelta = customerHistoryTimeValue(right.timestamp) - customerHistoryTimeValue(left.timestamp); if (timeDelta !== 0) { return timeDelta; } return String(right.note || '').localeCompare(String(left.note || '')); }) .slice(0, 9); } function latestItemByTime(items, fields = []) { const source = Array.isArray(items) ? items.filter(Boolean) : []; if (!source.length) { return null; } return [...source].sort((left, right) => { const rightTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(right?.[field])), 0); const leftTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(left?.[field])), 0); if (rightTime !== leftTime) { return rightTime - leftTime; } return String(right?.interaction_id || right?.thread_id || right?.call_id || '').localeCompare( String(left?.interaction_id || left?.thread_id || left?.call_id || ''), ); })[0] || null; } function customerHistoryEventActions(event, context = {}) { const threadIds = context.threadIds instanceof Set ? context.threadIds : new Set(); const callIds = context.callIds instanceof Set ? context.callIds : new Set(); const actions = []; if (event?.thread_id && threadIds.has(event.thread_id)) { actions.push({ action: 'telegram', label: 'К Telegram', threadId: event.thread_id, }); } if (event?.call_id && callIds.has(event.call_id)) { actions.push({ action: 'call', label: 'К звонку', callId: event.call_id, }); } if (event?.interaction_id) { actions.push({ action: 'interaction', label: 'К кейсу', interactionId: event.interaction_id, }); } return actions; } function renderCustomerHistoryActions(event, context = {}) { const actions = customerHistoryEventActions(event, context); if (!actions.length) { return ''; } return `
    ${actions.map((action) => ` `).join('')}
    `; } function emptyCustomerSpotlightMarkup(title, description) { return `
    ${escapeHtml(title)}

    ${escapeHtml(description)}

    `; } function customerProfileVoiceNameContext(liveCalls = []) { const latestCall = latestItemByTime(liveCalls, ['last_transition_at', 'ended_at', 'connected_at', 'started_at', 'updated_at']); if (!latestCall) { return { statusMeta: null, sourceLabel: '', }; } const voiceContext = voiceSummaryForItem(latestCall) || latestCall || {}; return { statusMeta: voiceCustomerNameStatusMeta(voiceContext?.customer_name_status), sourceLabel: formatVoiceCustomerNameSource(voiceContext?.customer_name_source), }; } function customerProfileNameEditorMarkup(customer, context = {}) { const editor = state.customers.nameEditor; const isOpen = editor.open && editor.customerId === customer.customer_id; const statusMeta = context.statusMeta || null; const sourceLabel = String(context.sourceLabel || '').trim(); const flash = editor.flash && editor.customerId === customer.customer_id ? editor.flash : ''; const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : 'Имя сохранится в профиле клиента и voice-контуре.'); return `

    ${escapeHtml(customer.display_name || 'Без имени')}

    ${escapeHtml(customerLeadCompany(customer))}

    ${(statusMeta || sourceLabel || flash) ? `
    ${statusMeta ? `${escapeHtml(statusMeta.shortLabel)}` : ''} ${sourceLabel ? `Источник: ${escapeHtml(sourceLabel)}` : ''} ${flash ? `${escapeHtml(flash)}` : ''}
    ` : ''}
    ${isOpen ? `
    Как сохранить имя клиента в профиле

    ${escapeHtml(statusText)}

    ` : ''} `; } function customerSpotlightMarkup(customer, options = {}) { const { pageView = false } = options; const index = Math.max(customerIndex(customer), 0); const phones = customerPhones(customer); const source = customerLeadSourceMeta(customer, index); const leadStatus = customerLeadStatusMeta(customer, index); const score = customerLeadScore(customer, index); const historyPayload = customerHistoryPayload(customer.customer_id); const historySummary = historyPayload?.summary || null; const currentThreads = customerTelegramThreads(customer); const currentLiveCalls = customerLiveCalls(customer); const interactions = Array.isArray(historyPayload?.interactions) ? historyPayload.interactions : customerInteractions(customer); const threads = Array.isArray(historyPayload?.telegram_threads) ? historyPayload.telegram_threads : customerTelegramThreads(customer); const liveCalls = Array.isArray(historyPayload?.live_calls) ? historyPayload.live_calls : customerLiveCalls(customer); const profileNameContext = customerProfileVoiceNameContext(liveCalls); const historyEvents = Array.isArray(historyPayload?.history) && historyPayload.history.length ? historyPayload.history : buildCustomerHistoryEvents(customer); const latestEvent = historyEvents[0] || null; const openCases = Number.isFinite(Number(historySummary?.open_cases)) ? Number(historySummary.open_cases) : interactions.filter((item) => item.status !== 'closed').length; const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length ? historySummary.active_channels : [...new Set([ ...interactions.map((item) => item.channel).filter(Boolean), ...(threads.length ? ['telegram'] : []), ...(liveCalls.length ? ['voice'] : []), ])]; const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : []; const latestInteraction = latestItemByTime(interactions, ['updated_at', 'created_at']); const primaryThread = historySummary?.primary_telegram_thread_id ? currentThreads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null : latestItemByTime(currentThreads, ['last_message_at', 'updated_at', 'created_at']); const primaryCall = latestItemByTime(currentLiveCalls, ['last_transition_at', 'ended_at', 'connected_at', 'started_at', 'updated_at']); const historyActionContext = { threadIds: new Set(currentThreads.map((item) => item.thread_id).filter(Boolean)), callIds: new Set(currentLiveCalls.map((item) => item.call_id).filter(Boolean)), }; const historyStatusText = customerHistoryPending(customer.customer_id) ? 'Обновляем ленту клиента из backend...' : state.customers.historyErrorsById[customer.customer_id] ? 'Показываем локальную историю, пока backend недоступен.' : (latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения'); const summaryCards = [ renderSummaryCard( 'Контакты', String(Number.isFinite(Number(historySummary?.contact_points)) ? Number(historySummary.contact_points) : (interactions.length + threads.length + liveCalls.length)), 'Все точки касания клиента', ), renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'), renderSummaryCard( 'Каналы', String(activeChannels.length || (phones.length ? 1 : 0)), activeChannels.length ? activeChannels.map((item) => channelLabel(item)).join(' • ') : 'Пока только профиль', ), renderSummaryCard( 'Последний контакт', historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'), historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована', ), ].join(''); const historyMarkup = historyEvents.length ? historyEvents.map((event) => { const meta = customerHistoryMeta(event.kind); return `
    ${meta.token}
    ${escapeHtml(event.title)}
    ${escapeHtml(event.body)}
    ${event.note ? `
    ${escapeHtml(event.note)}
    ` : ''} ${renderCustomerHistoryActions(event, historyActionContext)}
    `; }).join('') : '
    Пока нет событий по этому клиенту.
    '; return `
    ${escapeHtml(customerInitials(customer.display_name))}
    Клиент ${escapeHtml(customer.customer_id)}
    ${customerProfileNameEditorMarkup(customer, profileNameContext)}
    ${escapeHtml(source.label)} ${escapeHtml(leadStatus.label)} AI ${score}
    ${summaryCards}
    Основной номер
    ${escapeHtml(historySummary?.primary_phone || phones[0] || 'Не указан')}
    ${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}
    Предпочтительный канал
    ${escapeHtml(channelLabel(customerPreferredChannel(customer)))}
    ${escapeHtml(openCases ? 'Есть активные обращения' : 'Сейчас без открытых кейсов')}
    Теги профиля
    ${(tags.length ? tags : [customerLeadCompany(customer)]).slice(0, 4).map((tag) => `${escapeHtml(tag)}`).join('')}
    Активные каналы
    ${(activeChannels.length ? activeChannels : ['profile']).map((channel) => `${escapeHtml(channel === 'profile' ? 'Профиль' : channelLabel(channel))}`).join('')}
    Единая история клиента

    Все контакты в одной ленте

    ${escapeHtml(historyStatusText)}
    ${historyMarkup}
    `; } function renderCustomerSpotlight() { const target = $('customerSpotlight'); const customer = selectedCustomer(); if (target && !customer) { target.innerHTML = emptyCustomerSpotlightMarkup( 'Выберите клиента', 'Здесь появятся профиль клиента и единая история контактов по всем каналам.', ); } if (target && customer) { const listEl = target.querySelector('.customer-history-list'); const scrollPos = listEl ? listEl.scrollTop : 0; const h = target.offsetHeight; if (h > 0) target.style.minHeight = h + 'px'; target.innerHTML = customerSpotlightMarkup(customer); if (h > 0) target.style.minHeight = ''; const newListEl = target.querySelector('.customer-history-list'); if (newListEl && scrollPos > 0) { newListEl.scrollTop = scrollPos; } } renderCustomerProfilePage(); } function renderCustomerProfilePage() { const target = $('customerProfileContent'); if (!target) { return; } const customer = selectedCustomer(); const title = $('customerProfileTitle'); const hint = $('customerProfileHint'); if (!customer) { if (title) { title.textContent = 'Профиль клиента'; } if (hint) { hint.textContent = 'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.'; } target.innerHTML = emptyCustomerSpotlightMarkup( 'Выберите клиента', 'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.', ); return; } if (title) { title.textContent = customer.display_name || 'Профиль клиента'; } const historyPayload = customerHistoryPayload(customer.customer_id); ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {}); if (hint) { const phones = customerPhones(customer); const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || ''; hint.textContent = primaryPhone ? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}` : `Клиент ${customer.customer_id} • омниканальный профиль`; } const listEl = target.querySelector('.customer-history-list'); const scrollPos = listEl ? listEl.scrollTop : 0; const pageScroll = window.scrollY || document.documentElement.scrollTop; const h = target.offsetHeight; if (h > 0) target.style.minHeight = h + 'px'; target.innerHTML = customerSpotlightMarkup(customer, { pageView: true }); if (h > 0) target.style.minHeight = ''; const newListEl = target.querySelector('.customer-history-list'); if (newListEl && scrollPos > 0) { newListEl.scrollTop = scrollPos; } if (pageScroll > 0 && pageScroll > window.scrollY) { window.scrollTo(window.scrollX, pageScroll); } } function syncCustomerFormUi() { $('customerLeadForm').classList.toggle('hidden', !state.customers.leadFormOpen); $('toggleCustomerLeadFormBtn').textContent = state.customers.leadFormOpen ? 'Закрыть форму' : '+ Добавить лид'; } function updateCustomerPagerUi() { const total = state.customers.items.length; const pageCount = Math.max(1, Math.ceil(total / state.customers.pageSize)); const start = total ? (state.customers.page - 1) * state.customers.pageSize + 1 : 0; const end = total ? Math.min(state.customers.page * state.customers.pageSize, total) : 0; $('customerPageSummary').textContent = total ? `Показано ${start}-${end} из ${total}` : 'Пока нет лидов.'; $('customerPrevPageBtn').disabled = state.customers.page <= 1; $('customerNextPageBtn').disabled = state.customers.page >= pageCount; } function renderCustomerList() { const rows = customerLeadRows(); if (!rows.length) { $('customerList').innerHTML = `
    Лиды пока не найдены.

    Попробуйте другой запрос или добавьте новый лид вручную.

    `; 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(customerInitials(item.display_name))}
    ${escapeHtml(item.display_name)}
    ${escapeHtml(customerLeadCompany(item))}
    ${escapeHtml(source.label)}
    ${escapeHtml(customerLeadCreatedAtLabel(item, index))}
    ${escapeHtml(status.label)}
    ${score}
    `; }).join('')}
    `; renderCustomerSpotlight(); updateCustomerPagerUi(); } function selectCustomer(customerId) { if (state.customers.nameEditor.customerId && state.customers.nameEditor.customerId !== customerId) { state.customers.nameEditor.open = false; state.customers.nameEditor.customerId = ''; state.customers.nameEditor.draft = ''; state.customers.nameEditor.saving = false; state.customers.nameEditor.error = ''; state.customers.nameEditor.flash = ''; } state.customers.selectedCustomerId = customerId || ''; if (customerId) { $('interactionCustomerId').value = customerId; } renderCustomerList(); } function setCustomerPage(page) { const totalPages = Math.max(1, Math.ceil(state.customers.items.length / state.customers.pageSize)); state.customers.page = Math.min(Math.max(page, 1), totalPages); renderCustomerList(); } function selectedTelegramThread() { return state.telegram.threads.find((thread) => thread.thread_id === state.telegram.selectedThreadId) || null; } function telegramThreadCanClaim(thread) { if (!thread || thread.status === 'closed') { return false; } return !thread.claimed_by_user; } function telegramThreadCanReply(thread) { if (!thread || thread.status === 'closed') { return false; } if (state.role === 'admin' || state.role === 'supervisor') { return true; } return thread.claimed_by_user === state.user; } function telegramThreadCanManage(thread) { if (!thread) { return false; } if (state.role === 'admin' || state.role === 'supervisor') { return true; } return thread.claimed_by_user === state.user; } function telegramThreadCanReturnToAi(thread) { if (!thread || thread.status === 'closed') { return false; } if (String(thread.ai_state || '') !== 'human_owned') { return false; } return telegramThreadCanManage(thread); } function telegramThreadDisplayName(thread) { return thread?.display_name || thread?.username || thread?.chat_id || 'Telegram'; } function telegramThreadInitials(thread) { return customerInitials(telegramThreadDisplayName(thread)); } function telegramThreadTimeLabel(value) { if (!value) { return 'сейчас'; } const dt = new Date(value); if (Number.isNaN(dt.getTime())) { return String(value); } const now = new Date(); const sameDay = dt.toDateString() === now.toDateString(); if (sameDay) { return dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); } return dt.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }); } function telegramHashValue(value) { let hash = 0; const text = String(value || 'telegram'); for (let index = 0; index < text.length; index += 1) { hash = ((hash << 5) - hash + text.charCodeAt(index)) >>> 0; } return hash; } function telegramAvatarPalette(seed) { return TELEGRAM_AVATAR_PALETTES[telegramHashValue(seed) % TELEGRAM_AVATAR_PALETTES.length]; } function telegramAvatarStyle(seed) { const [start, end] = telegramAvatarPalette(seed); return `style="--telegram-avatar-start:${start};--telegram-avatar-end:${end};"`; } function whatsappAvatarPalette(seed) { return WHATSAPP_AVATAR_PALETTES[telegramHashValue(seed || 'whatsapp') % WHATSAPP_AVATAR_PALETTES.length]; } function cloneWhatsappChats() { return WHATSAPP_MOCK_CHATS.map((chat) => ({ ...chat, messages: (chat.messages || []).map((message) => ({ ...message })), })); } function sortWhatsappChats(chats) { return [...(chats || [])].sort((left, right) => { const pinnedDelta = Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)); if (pinnedDelta !== 0) { return pinnedDelta; } return String(right.lastMessageAt || '').localeCompare(String(left.lastMessageAt || '')); }); } function seedWhatsappChats() { if (state.whatsapp.chats.length) { return; } state.whatsapp.mode = 'mock'; state.whatsapp.chats = sortWhatsappChats(cloneWhatsappChats()); state.whatsapp.selectedChatId = state.whatsapp.chats[0]?.id || ''; } function whatsappChatThreadId(chat) { return chat?.threadId || chat?.id || ''; } function whatsappThreadCanClaim(thread) { if (!thread || thread.status === 'closed') { return false; } return !thread.claimedByUser; } function whatsappThreadCanReply(thread) { if (!thread || thread.status === 'closed') { return false; } if (state.role === 'admin' || state.role === 'supervisor') { return true; } return !thread.claimedByUser || thread.claimedByUser === state.user; } function whatsappThreadCanManage(thread) { if (!thread) { return false; } if (state.role === 'admin' || state.role === 'supervisor') { return true; } return thread.claimedByUser === state.user; } function whatsappThreadCanReturnToAi(thread) { if (!thread || thread.status === 'closed') { return false; } if (String(thread.aiState || '').trim() !== 'human_owned') { return false; } return whatsappThreadCanManage(thread); } function whatsappThreadDisplayName(thread) { return thread?.title || thread?.display_name || thread?.phoneNumber || thread?.phone_number || thread?.chatId || thread?.chat_id || 'WhatsApp'; } function whatsappThreadAiStateMeta(thread) { const stateKey = String(thread?.aiState || thread?.ai_state || '').trim(); if (!stateKey) { return null; } const meta = { queued: { label: 'AI готов', tone: 'queued' }, thinking: { label: 'AI думает', tone: 'thinking' }, active: { label: 'AI активен', tone: 'active' }, handoff_required: { label: 'Нужен оператор', tone: 'handoff' }, human_owned: { label: 'Operator', tone: 'human' }, closed: { label: 'AI завершён', tone: 'closed' }, error: { label: 'Ошибка AI', tone: 'error' }, }; return meta[stateKey] || { label: stateKey, tone: 'queued' }; } function whatsappThreadAiClaimable(thread) { return Boolean(thread && !thread.claimedByUser && ['queued', 'thinking', 'active', 'handoff_required'].includes(String(thread.aiState || ''))); } function whatsappThreadUnreadCount(thread) { const raw = Number(thread?.unreadCount ?? thread?.unread_count ?? 0); return Number.isFinite(raw) && raw > 0 ? raw : 0; } function whatsappThreadHandle(thread) { if (!thread) { return 'Direct message'; } if (thread.handle) { return thread.handle; } if (thread.phoneNumber || thread.phone_number) { return thread.phoneNumber || thread.phone_number; } if (thread.whatsappUserId || thread.whatsapp_user_id) { return thread.whatsappUserId || thread.whatsapp_user_id; } if (thread.isGroup || thread.is_group) { return 'Групповой чат'; } const chatId = thread.chatId || thread.chat_id; return chatId ? `chat ${chatId}` : 'Direct message'; } function whatsappThreadPresenceText(thread) { if (!thread) { return 'Выберите чат'; } if (thread.status === 'closed') { return 'Закрыт'; } const aiMeta = whatsappThreadAiStateMeta(thread); if (aiMeta && String(thread.aiState || '').trim() !== 'human_owned') { return aiMeta.label; } if (thread.claimedByUser) { return `в работе у ${thread.claimedByUser}`; } return thread.statusLine || whatsappThreadHandle(thread); } function whatsappThreadOwnerText(thread) { if (whatsappThreadAiClaimable(thread)) { return 'владелец AI'; } return thread?.claimedByUser ? `владелец ${thread.claimedByUser}` : 'владелец -'; } function whatsappThreadSummaryAccessible(thread) { if (!thread) { return false; } if (!['handoff_required', 'human_owned'].includes(String(thread.aiState || '').trim())) { return false; } if (String(state.role || '') !== 'operator') { return true; } const claimedByUser = String(thread.claimedByUser || '').trim(); return !claimedByUser || claimedByUser === String(state.user || '').trim(); } function whatsappSelectedSummaryVisible(thread) { if (!whatsappThreadSummaryAccessible(thread)) { return false; } const summary = state.whatsapp.selectedThreadSummary; return Boolean(summary && summary.thread_id === whatsappChatThreadId(thread)); } function whatsappSummaryFieldMarkup(label, value) { return `
    ${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 facebookLead = isFacebookLeadInteraction(item); const cardClass = item.status === 'escalated' ? 'pipeline-card escalated' : item.status === 'closed' ? 'pipeline-card closed' : 'pipeline-card'; const badges = [ facebookLead ? 'Facebook' : '', `${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))}

    ${facebookLead ? `` : ''}
    `; } 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 facebookLead = isFacebookLeadInteraction(item); const badges = [ facebookLead ? renderUnifiedInboxBadge('Facebook', 'facebook') : '', renderUnifiedInboxBadge(channelLabel(item.channel), 'channel'), item.queue_id ? renderUnifiedInboxBadge(item.queue_id, 'queue') : '', item.assigned_to ? renderUnifiedInboxBadge(item.assigned_to, 'owner') : '', ].filter(Boolean); return { key: `interaction:${item.interaction_id}`, kind: 'interaction', bucket, sortValue: unifiedInboxSortValue(item.updated_at, item.created_at), title: item.subject || 'Обращение без темы', subtitle: item.interaction_id, badges, metaLines: [ `Клиент: ${customerDisplayName(item.customer_id || '')}`, `Статус: ${statusMeta(item.status).label}`, `Обновлено: ${formatIsoShort(item.updated_at || item.created_at)}`, ], customerId: item.customer_id || '', interactionId: item.interaction_id, canReply: facebookLead, }; } function buildUnifiedInboxTelegramItem(thread) { const bucket = unifiedInboxTelegramBucket(thread); if (!bucket) { return null; } const customerId = customerIdForInteractionId(thread.interaction_id); const aiMeta = telegramThreadAiStateMeta(thread); const unreadCount = telegramThreadUnreadCount(thread); const badges = [ renderUnifiedInboxBadge('Telegram', 'channel'), unreadCount > 0 ? renderUnifiedInboxBadge(`${unreadCount} new`, 'queue') : '', aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.tone) : '', thread.claimed_by_user ? renderUnifiedInboxBadge(thread.claimed_by_user, 'owner') : '', ].filter(Boolean); return { key: `telegram:${thread.thread_id}`, kind: 'telegram', bucket, sortValue: unifiedInboxSortValue(thread.last_message_at, thread.updated_at, thread.created_at), title: telegramThreadDisplayName(thread), subtitle: telegramThreadSummary(thread), badges, metaLines: [ `Клиент: ${customerDisplayName(customerId)}`, thread.last_message_preview ? `Последнее: ${thread.last_message_preview}` : 'Последнее сообщение пока не загружено', `Статус: ${telegramThreadHeaderPresence(thread)}`, ], customerId, interactionId: thread.interaction_id || '', threadId: thread.thread_id, }; } function buildUnifiedInboxCallItem(item) { const summary = voiceSummaryForItem(item); const aiMeta = voiceAiStatusMeta(item); const interaction = interactionById(item.interaction_id || ''); const customerId = interaction?.customer_id || ''; const caller = voiceCustomerDisplayName(item, summary); const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status); const badges = [ renderUnifiedInboxBadge('Голос', 'channel'), renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'), aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '', nameStatusMeta ? renderUnifiedInboxBadge(nameStatusMeta.shortLabel, nameStatusMeta.className) : '', item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '', ].filter(Boolean); return { key: `call:${item.call_id}`, kind: 'call', bucket: 'calls', sortValue: unifiedInboxSortValue(item.started_at, item.updated_at), title: caller, subtitle: voiceCustomerCallSubtitle(item), badges, metaLines: [ `Клиент: ${customerDisplayName(customerId)}`, `Контакт: ${voiceCustomerCallSubtitle(item)}`, summary ? `Имя: ${voiceCustomerNameStateLine(summary) || 'без подтверждения'}` : '', `Обращение: ${item.interaction_id || 'не найдено'}`, `Начат: ${formatIsoShort(item.started_at || item.connected_at)}`, item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`, ].filter(Boolean), customerId, interactionId: item.interaction_id || '', callId: item.call_id, }; } function buildUnifiedInboxCollections() { const collections = { new: [], mine: [], ai: [], escalated: [], calls: [], }; const representedInteractionIds = new Set(); state.telegram.threads.forEach((thread) => { if (thread?.interaction_id && thread.status !== 'closed') { representedInteractionIds.add(thread.interaction_id); } const item = buildUnifiedInboxTelegramItem(thread); if (item) { collections[item.bucket].push(item); } }); state.liveCalls.items.forEach((call) => { if (call?.interaction_id) { representedInteractionIds.add(call.interaction_id); } const item = buildUnifiedInboxCallItem(call); if (item) { collections.calls.push(item); } }); state.interactions.forEach((item) => { if (representedInteractionIds.has(item.interaction_id)) { return; } const inboxItem = buildUnifiedInboxInteractionItem(item); if (inboxItem) { collections[inboxItem.bucket].push(inboxItem); } }); Object.values(collections).forEach((items) => { items.sort((left, right) => right.sortValue - left.sortValue); }); return collections; } function renderUnifiedInboxCard(item) { const actions = [ ``, item.customerId ? `` : '', item.canReply ? `` : '', ].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 activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null; const relatedCall = activeCall || state.liveCalls.recentItems.find((item) => item.call_id === callId) || null; const select = $('liveCallIdSelect'); if (select) { select.value = activeCall ? callId : ''; } state.liveCalls.selectedCallId = callId; renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); syncLiveCallActionButtons(); ensureVoiceAiSummaryLoaded(relatedCall); } function handleLiveCallTableClick(event) { const button = event.target.closest('[data-live-call-action]'); if (!button) { return; } const callId = button.dataset.callId || ''; if (!callId) { return; } if (button.dataset.liveCallAction === 'edit-name') { openLiveCallNameEditor(callId, 'panel'); return; } if (button.dataset.liveCallAction === 'customer') { const customerId = button.dataset.customerId || ''; if (customerId) { openCustomerProfile(customerId); } } } async function openUnifiedInboxItem(button) { const kind = button.dataset.inboxKind || ''; const interactionId = button.dataset.interactionId || ''; const threadId = button.dataset.threadId || ''; const callId = button.dataset.callId || ''; const customerId = button.dataset.customerId || ''; if (kind === 'telegram') { window.location.hash = '#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 liveCallById(callId) { if (!callId) { return null; } return state.liveCalls.items.find((item) => item.call_id === callId) || state.liveCalls.recentItems.find((item) => item.call_id === callId) || null; } function liveCallCustomerId(item) { return interactionById(item?.interaction_id || '')?.customer_id || ''; } function closeCustomerProfileNameEditor() { state.customers.nameEditor.open = false; state.customers.nameEditor.customerId = ''; state.customers.nameEditor.draft = ''; state.customers.nameEditor.saving = false; state.customers.nameEditor.error = ''; renderCustomerSpotlight(); } function openCustomerProfileNameEditor(customerId = state.customers.selectedCustomerId) { const customer = state.customers.items.find((item) => item.customer_id === customerId) || null; if (!customer) { return; } state.customers.nameEditor.open = true; state.customers.nameEditor.customerId = customer.customer_id; state.customers.nameEditor.draft = customer.display_name || ''; state.customers.nameEditor.saving = false; state.customers.nameEditor.error = ''; state.customers.nameEditor.flash = ''; renderCustomerSpotlight(); } function closeLiveCallNameEditor() { state.liveCalls.nameEditor.open = false; state.liveCalls.nameEditor.callId = ''; state.liveCalls.nameEditor.customerId = ''; state.liveCalls.nameEditor.draft = ''; state.liveCalls.nameEditor.saving = false; state.liveCalls.nameEditor.error = ''; updateLiveCallNameEditorsUi(); } function openLiveCallNameEditor(callId, mode = 'panel') { const item = liveCallById(callId); const customerId = liveCallCustomerId(item); if (!item || !customerId) { log('Нельзя исправить имя: звонок не привязан к клиенту', { call_id: callId || '-' }); return; } state.liveCalls.nameEditor.open = true; state.liveCalls.nameEditor.mode = mode; state.liveCalls.nameEditor.callId = item.call_id; state.liveCalls.nameEditor.customerId = customerId; state.liveCalls.nameEditor.draft = voiceCustomerDisplayName(item); state.liveCalls.nameEditor.saving = false; state.liveCalls.nameEditor.error = ''; if (mode === 'panel') { focusLiveCall(item.call_id); } updateLiveCallNameEditorsUi(); } function applyCustomerNamePatchLocally({ customerId, callId, displayName }) { const normalizedName = String(displayName || '').trim(); if (!customerId || !normalizedName) { return; } state.customers.items = state.customers.items.map((item) => ( item.customer_id === customerId ? { ...item, display_name: normalizedName } : item )); const history = state.customers.historyById[customerId]; if (history?.customer) { history.customer.display_name = normalizedName; } const interactionIds = new Set( state.interactions .filter((item) => item.customer_id === customerId) .map((item) => item.interaction_id) .filter(Boolean), ); const patchCall = (item) => { if (!item) { return item; } const matchesCall = callId && item.call_id === callId; const matchesCustomer = interactionIds.has(item.interaction_id); if (!matchesCall && !matchesCustomer) { return item; } return { ...item, caller_name: normalizedName, }; }; state.liveCalls.items = state.liveCalls.items.map(patchCall); state.liveCalls.recentItems = state.liveCalls.recentItems.map(patchCall); if (history?.live_calls) { history.live_calls = history.live_calls.map((item) => ({ ...item, caller_name: interactionIds.has(item.interaction_id) || item.call_id === callId ? normalizedName : item.caller_name, })); } const affectedCallIds = new Set( [...state.liveCalls.items, ...state.liveCalls.recentItems] .filter((item) => interactionIds.has(item.interaction_id) || item.call_id === callId) .map((item) => item.call_id) .filter(Boolean), ); affectedCallIds.forEach((id) => { if (!state.liveCalls.aiSummaries[id]) { return; } state.liveCalls.aiSummaries[id] = { ...state.liveCalls.aiSummaries[id], customer_name_status: 'name_obtained', customer_name_value: normalizedName, customer_name_source: 'manual', }; }); if (state.liveCalls.nameEditor.customerId === customerId && !state.liveCalls.nameEditor.saving) { state.liveCalls.nameEditor.draft = normalizedName; } if (state.customers.nameEditor.customerId === customerId && !state.customers.nameEditor.saving) { state.customers.nameEditor.draft = normalizedName; } renderCustomerList(); refreshVoiceSummaryDependentViews(); } async function persistCustomerDisplayName({ customerId, displayName, callId = '' }) { const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, { method: 'PATCH', body: JSON.stringify({ display_name: displayName, source: 'manual' }), }); const savedName = data?.display_name || displayName; applyCustomerNamePatchLocally({ customerId, callId, displayName: savedName, }); ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {}); if (callId) { loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {}); } refreshLiveCallsInBackground(); return savedName; } async function saveLiveCallCustomerName() { const { customerId, callId } = state.liveCalls.nameEditor; const displayName = String(state.liveCalls.nameEditor.draft || '').trim().replace(/\s+/g, ' '); if (!customerId || !callId) { return; } if (displayName.length < 2) { state.liveCalls.nameEditor.error = 'Введите имя клиента минимум из 2 символов.'; updateLiveCallNameEditorsUi(); return; } state.liveCalls.nameEditor.saving = true; state.liveCalls.nameEditor.error = ''; updateLiveCallNameEditorsUi(); try { const savedName = await persistCustomerDisplayName({ customerId, callId, displayName }); const data = { display_name: savedName }; closeLiveCallNameEditor(); log('Имя клиента обновлено оператором', { customer_id: customerId, call_id: callId, name: data?.display_name || displayName }); } catch (err) { state.liveCalls.nameEditor.saving = false; state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; updateLiveCallNameEditorsUi(); } } async function saveCustomerProfileName() { const customerId = state.customers.nameEditor.customerId || state.customers.selectedCustomerId; const displayName = String(state.customers.nameEditor.draft || '').trim().replace(/\s+/g, ' '); if (!customerId) { return; } if (displayName.length < 2) { state.customers.nameEditor.error = 'Введите имя клиента минимум из 2 символов.'; renderCustomerSpotlight(); return; } const currentName = String(selectedCustomer()?.display_name || '').trim(); if (displayName === currentName) { closeCustomerProfileNameEditor(); return; } state.customers.nameEditor.saving = true; state.customers.nameEditor.error = ''; renderCustomerSpotlight(); try { const savedName = await persistCustomerDisplayName({ customerId, displayName }); state.customers.nameEditor.flash = 'Сохранено'; closeCustomerProfileNameEditor(); log('Имя клиента обновлено из профиля', { customer_id: customerId, name: savedName }); } catch (err) { state.customers.nameEditor.saving = false; state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; renderCustomerSpotlight(); } } function liveCallNameEditorMeta(item, customerId) { if (!item || !customerId) { return 'Выберите звонок, связанный с клиентом, чтобы исправить имя.'; } return `${voiceCustomerCallSubtitle(item)} • клиент ${customerId}`; } function updateLiveCallNameEditorsUi() { const panel = $('liveCallNameEditor'); const panelInput = $('liveCallNameInput'); const panelMeta = $('liveCallNameEditorMeta'); const panelStatus = $('liveCallNameStatus'); const panelSave = $('liveCallNameSaveBtn'); const panelCancel = $('liveCallNameCancelBtn'); const popup = $('browserPhoneNameEditor'); const popupInput = $('browserPhoneNameInput'); const popupMeta = $('browserPhoneNameHint'); const popupStatus = $('browserPhoneNameStatus'); const popupSave = $('browserPhoneNameSaveBtn'); const popupCancel = $('browserPhoneNameCancelBtn'); const editor = state.liveCalls.nameEditor; const item = liveCallById(editor.callId); const meta = liveCallNameEditorMeta(item, editor.customerId); const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : ''); if (panel) { const panelVisible = editor.open && editor.mode === 'panel'; panel.classList.toggle('hidden', !panelVisible); if (panelInput) { if (panelInput.value !== editor.draft) { panelInput.value = editor.draft; } panelInput.disabled = editor.saving; } if (panelMeta) { panelMeta.textContent = meta; } if (panelStatus) { panelStatus.textContent = statusText; panelStatus.classList.toggle('error', Boolean(editor.error)); } if (panelSave) { panelSave.disabled = editor.saving || !editor.customerId; panelSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя'; } if (panelCancel) { panelCancel.disabled = editor.saving; } } if (popup) { const popupVisible = editor.open && editor.mode === 'popup'; popup.classList.toggle('hidden', !popupVisible); if (popupInput) { if (popupInput.value !== editor.draft) { popupInput.value = editor.draft; } popupInput.disabled = editor.saving; } if (popupMeta) { popupMeta.textContent = meta; } if (popupStatus) { popupStatus.textContent = statusText; popupStatus.classList.toggle('hidden', !statusText); popupStatus.classList.toggle('error', Boolean(editor.error)); } if (popupSave) { popupSave.disabled = editor.saving || !editor.customerId; popupSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя'; } if (popupCancel) { popupCancel.disabled = editor.saving; } } } function isClaimableLiveCall(item) { if (!item) { return false; } return !item.claimed_by_user && ['ringing', 'claimed', 'connected'].includes(item.telephony_status); } function canControlLiveCall(item) { if (!item) { return false; } return ['claimed', 'connected'].includes(item.telephony_status); } function terminalActionLabel(item) { const value = item?.terminal_action || ''; if (value === 'blind-transfer') { return 'Передан'; } if (value === 'hangup') { return 'Завершён оператором'; } return 'Завершён'; } function voiceAiStatusMeta(item) { const stateKey = String(item?.ai_state || '').trim(); if (!stateKey) { return null; } return VOICE_AI_STATE_META[stateKey] || { label: stateKey, className: 'ai-muted' }; } function liveCallHasAi(item) { return Boolean( item?.voice_session_id || item?.ai_session_id || item?.ai_state || item?.ai_handoff_reason || item?.ai_last_model_at, ); } function shouldLoadVoiceAiSummary(item) { if (!item || !liveCallHasAi(item)) { return false; } return ['handoff_requested', 'handoff_required', 'human_owned', 'error', 'closed'].includes(item.ai_state || '') || Boolean(item.ai_handoff_reason); } function voiceAiSummaryForCall(callId) { return state.liveCalls.aiSummaries[callId] || null; } function voiceAiSummaryPending(callId) { return Boolean(state.liveCalls.aiSummaryPending[callId]); } async function loadVoiceAiSummary(callId, options = {}) { if (!callId || voiceAiSummaryPending(callId)) { return voiceAiSummaryForCall(callId); } const { force = false, silent = false } = options; if (!force && Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) { return state.liveCalls.aiSummaries[callId]; } state.liveCalls.aiSummaryPending[callId] = true; try { const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/ai-summary`); state.liveCalls.aiSummaries[callId] = data || null; return state.liveCalls.aiSummaries[callId]; } catch (err) { if (!silent) { log('Не удалось загрузить сводку AI по звонку', { call_id: callId, error: err.message }); } state.liveCalls.aiSummaries[callId] = null; return null; } finally { delete state.liveCalls.aiSummaryPending[callId]; refreshVoiceSummaryDependentViews(); } } function ensureVoiceAiSummaryLoaded(item) { if (!shouldLoadVoiceAiSummary(item)) { return; } const callId = item.call_id || ''; if (!callId || voiceAiSummaryPending(callId) || Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) { return; } window.setTimeout(() => { loadVoiceAiSummary(callId, { silent: true }); }, 0); } function renderVoiceAiSummaryField(label, value) { if (!value) { return ''; } return `
    ${escapeHtml(label)}
    ${escapeHtml(value)}
    `; } function formatVoiceCustomerNameStatus(status) { switch (String(status || '').trim()) { case 'name_obtained': return 'Подтверждено'; case 'name_followup_required': return 'Нужно уточнить'; case 'name_not_obtained': return 'Не подтверждено'; default: return String(status || '').trim(); } } function voiceCustomerNameStatusMeta(status) { switch (String(status || '').trim()) { case 'name_obtained': return { label: 'Имя подтверждено', shortLabel: 'Имя подтверждено', className: 'name-confirmed' }; case 'name_followup_required': return { label: 'Имя нужно уточнить', shortLabel: 'Уточнить имя', className: 'name-followup' }; case 'name_not_obtained': return { label: 'Имя не подтверждено', shortLabel: 'Без подтверждения', className: 'name-missing' }; default: { const fallback = String(status || '').trim(); return fallback ? { label: fallback, shortLabel: fallback, className: 'name-missing' } : null; } } } function formatVoiceCustomerNameSource(source) { switch (String(source || '').trim()) { case 'known_customer': return 'Из карточки клиента'; case 'voice_start': return 'Стартовый этап'; case 'voice_followup': return 'Уточнил AI'; case 'manual': return 'Оператор'; case 'external_identity': return 'Из voice identity'; default: return String(source || '').trim(); } } function formatVoiceStartLanguage(language) { switch (String(language || '').trim()) { case 'ru': return 'Русский'; case 'kz': return 'Қазақша'; default: return String(language || '').trim(); } } function voiceSummaryForItem(item) { const callId = String(item?.call_id || '').trim(); return callId ? voiceAiSummaryForCall(callId) : null; } function voiceCustomerDisplayName(item, summary = voiceSummaryForItem(item)) { const preferredName = String(summary?.customer_name_value || '').trim(); if (preferredName) { return preferredName; } const callerName = String(item?.caller_name || '').trim(); if (callerName) { return callerName; } const callerNumber = String(item?.caller_number || '').trim(); if (callerNumber) { return callerNumber; } const callId = String(item?.call_id || '').trim(); if (callId) { return callId; } return 'Неизвестный абонент'; } function voiceCustomerCallSubtitle(item) { const parts = [ String(item?.caller_number || '').trim(), String(item?.call_id || '').trim(), ].filter(Boolean); return parts.join(' • ') || 'Звонок без номера'; } function voiceCustomerNameStateLine(summary) { if (!summary) { return ''; } const parts = []; const statusMeta = voiceCustomerNameStatusMeta(summary.customer_name_status); if (statusMeta?.label) { parts.push(statusMeta.label); } const sourceLabel = formatVoiceCustomerNameSource(summary.customer_name_source); if (sourceLabel) { parts.push(sourceLabel); } const languageLabel = formatVoiceStartLanguage(summary.voice_start_language); if (languageLabel) { parts.push(languageLabel); } return parts.join(' • '); } function voiceCustomerIncomingMeta(item, summary = voiceSummaryForItem(item)) { const parts = []; const callerNumber = String(item?.caller_number || '').trim(); if (callerNumber) { parts.push(`Номер: ${callerNumber}`); } const nameState = voiceCustomerNameStateLine(summary); if (nameState) { parts.push(nameState); } return parts.join(' • ') || `call_id: ${String(item?.call_id || '—').trim() || '—'}`; } function refreshVoiceSummaryDependentViews() { updateLiveCallSelector(state.liveCalls.items); renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); renderUnifiedInbox(); updateBrowserPhoneUi(); } function renderVoiceAiTranscript(summary) { const segments = Array.isArray(summary?.transcript_segments) ? summary.transcript_segments.filter((segment) => segment && String(segment.text || '').trim()) : []; if (!segments.length) { return ''; } return `
    Транскрипт разговора
    ${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_name_value)} ${renderVoiceAiSummaryField('Статус имени', formatVoiceCustomerNameStatus(summary?.customer_name_status))} ${renderVoiceAiSummaryField('Источник имени', formatVoiceCustomerNameSource(summary?.customer_name_source))} ${renderVoiceAiSummaryField('Язык старта', formatVoiceStartLanguage(summary?.voice_start_language))} ${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)} ${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)} ${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)} ${renderVoiceAiSummaryField('Следующий шаг', summary?.recommended_next_step)}
    ${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 summary = voiceSummaryForItem(item); const aiMeta = voiceAiStatusMeta(item); const nameState = voiceCustomerNameStateLine(summary); const parts = [ item.queue_code || item.queue_id || 'queue?', item.interaction_id || 'interaction?', item.operator_extension ? `ext ${item.operator_extension}` : '', nameState, aiMeta ? aiMeta.label : '', ].filter(Boolean); return parts.join(' • '); } function browserPhoneHintText(config) { const popupPhase = browserPhonePopupPhase(); if (!config) { return 'Browser softphone недоступен: bridge не вернул конфиг для текущего пользователя.'; } if (!config.enabled) { return 'Browser softphone отключён в окружении. Внешний SIP-клиент остаётся fallback-путём.'; } if (!browserPhoneLibraryAvailable()) { return 'Browser softphone не инициализирован: SIP.js asset не загрузился.'; } if (popupPhase === 'incoming' && state.browserPhone.ringtoneActive) { return 'Входящий звонок в браузере. Используйте Ответить или Отклонить. Рингтон воспроизводится локально.'; } if (popupPhase === 'connecting') { return 'Подключаем медиа и быстро сопоставляем звонок с bridge. При неоднозначности можно нажать «Принять в работу» вручную.'; } if (popupPhase === 'ending') { return 'Ждём финальное подтверждение завершения звонка от bridge.'; } if (popupPhase === 'error') { return 'Локальная media session уже завершена, но bridge ещё не подтвердил финальный terminal state. Проверьте предупреждение в popup.'; } if (state.browserPhone.connected) { return 'Телефон зарегистрирован. Входящий браузерный звонок откроет отдельное окно и попытается выполнить авто-claim.'; } return 'Подключите browser phone и разрешите доступ к микрофону.'; } function syncBrowserPhonePopupLifecycle() { const activeCallId = state.browserPhone.endingCallId || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId; if (!activeCallId) { updateBrowserPhoneUi(); return; } const liveCall = state.liveCalls.items.find((item) => item.call_id === activeCallId); const recentCall = state.liveCalls.recentItems.find((item) => item.call_id === activeCallId); if (!liveCall && recentCall && !state.browserPhone.session && !state.browserPhone.incoming) { state.browserPhone.endingCallId = ''; state.browserPhone.popupCallId = ''; state.browserPhone.autoClaimCallId = ''; state.browserPhone.callPhase = ''; state.browserPhone.warning = ''; if (state.browserPhone.connected) { state.browserPhone.status = 'Зарегистрирован'; } } updateBrowserPhoneUi(); } function updateBrowserPhoneUi() { const config = browserPhoneConfig(); const hasSession = Boolean(state.browserPhone.session); const pendingAction = state.liveCalls.pendingAction || ''; const popupCall = browserPhoneActivePopupCall(); const popupPhase = browserPhonePopupPhase(); const phoneState = browserPhoneStatusState(); const connectBtn = $('browserPhoneConnectBtn'); const disconnectBtn = $('browserPhoneDisconnectBtn'); const micSelect = $('browserPhoneMicSelect'); const speakerSelect = $('browserPhoneSpeakerSelect'); const settingsPanel = $('browserPhoneSettingsPanel'); const statusBtn = $('browserPhoneStatusBtn'); const statusLabel = $('browserPhoneStatusLabel'); const hint = $('browserPhoneHint'); const muteBtn = $('browserPhoneMuteBtn'); const answerBtn = $('browserPhoneAnswerBtn'); const rejectBtn = $('browserPhoneRejectBtn'); const claimBtn = $('browserPhoneCallClaimBtn'); const editNameBtn = $('browserPhoneEditNameBtn'); const transferBtn = $('browserPhoneCallTransferBtn'); const hangupBtn = $('browserPhoneCallHangupBtn'); const transferType = $('browserPhoneTransferTargetType'); const transferValue = $('browserPhoneTransferTargetValue'); const overlay = $('browserPhoneCallOverlay'); const popupState = $('browserPhoneCallState'); const popupTitle = $('browserPhoneCallTitle'); const popupIncomingText = $('browserPhoneIncomingText'); const popupMeta = $('browserPhoneCallMeta'); const popupAiSummary = $('browserPhoneAiSummary'); const popupTimer = $('browserPhoneCallTimer'); const popupWarning = $('browserPhoneCallWarning'); const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null; const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false; $('browserPhoneRegistrationState').textContent = state.browserPhone.status; $('browserPhoneOperatorExtension').textContent = config?.operator_extension || '—'; $('browserPhoneWsUrl').textContent = config?.ws_url || '—'; statusLabel.textContent = browserPhoneStatusLabel(); statusBtn.dataset.state = phoneState; statusBtn.setAttribute('aria-expanded', state.browserPhone.settingsOpen ? 'true' : 'false'); settingsPanel.classList.toggle('hidden', !state.browserPhone.settingsOpen); connectBtn.disabled = !config?.enabled || state.browserPhone.connected || state.browserPhone.connecting; disconnectBtn.disabled = !state.browserPhone.connected && !hasSession && !state.browserPhone.connecting; micSelect.disabled = state.browserPhone.connecting; speakerSelect.disabled = state.browserPhone.connecting; hint.textContent = browserPhoneHintText(config); const incoming = popupPhase === 'incoming'; const connecting = popupPhase === 'connecting'; const ending = popupPhase === 'ending'; const errorState = popupPhase === 'error'; const active = popupPhase === 'in-call'; const canClaim = Boolean(popupCall && isClaimableLiveCall(popupCall) && !incoming && !connecting && !ending && !state.browserPhone.autoClaimInFlight); const canControl = Boolean(popupCall && canControlLiveCall(popupCall) && !connecting && !ending); const popupCustomerId = liveCallCustomerId(popupCall); overlay.classList.toggle('hidden', !browserPhonePopupVisible()); popupState.textContent = errorState ? 'Нужна синхронизация завершения' : ending ? 'Завершаем звонок' : connecting ? 'Подключаем media' : incoming ? 'Входящий звонок' : active ? 'Разговор в браузере' : 'Browser call'; popupTitle.textContent = popupCall ? voiceCustomerDisplayName(popupCall, popupAiSummaryData) : browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', ''); popupIncomingText.textContent = popupCall ? voiceCustomerIncomingMeta(popupCall, popupAiSummaryData) : state.browserPhone.session ? browserPhoneIncomingLabel(state.browserPhone.session) : 'SIP invite ещё не поступал.'; popupMeta.textContent = browserPhoneCallSummary(popupCall); if (popupCall) { ensureVoiceAiSummaryLoaded(popupCall); } popupAiSummary.innerHTML = renderVoiceAiSummary(popupAiSummaryData, popupAiSummaryPending); popupAiSummary.classList.toggle('hidden', !popupAiSummary.innerHTML.trim()); popupTimer.textContent = incoming ? 'ringing' : connecting ? 'connecting…' : ending ? 'ending…' : formatCallTimer(browserPhoneCallTimerSource(popupCall)); popupWarning.textContent = state.browserPhone.warning; popupWarning.classList.toggle('hidden', !state.browserPhone.warning); answerBtn.classList.toggle('hidden', !incoming); rejectBtn.classList.toggle('hidden', !incoming); claimBtn.classList.toggle('hidden', !canClaim); editNameBtn.classList.toggle('hidden', !popupCall || !popupCustomerId); muteBtn.classList.toggle('hidden', !(active || connecting)); transferBtn.classList.toggle('hidden', !(canControl || ending || errorState)); hangupBtn.classList.toggle('hidden', !(popupCall || hasSession || ending || errorState || connecting)); transferType.classList.toggle('hidden', !(canControl || ending || errorState)); transferValue.classList.toggle('hidden', !(canControl || ending || errorState)); answerBtn.disabled = !incoming || Boolean(pendingAction); rejectBtn.disabled = !incoming || Boolean(pendingAction); claimBtn.disabled = !canClaim || Boolean(pendingAction) || state.browserPhone.autoClaimInFlight; editNameBtn.disabled = !popupCall || !popupCustomerId || state.liveCalls.nameEditor.saving; muteBtn.disabled = !hasSession; muteBtn.textContent = state.browserPhone.muted ? 'Включить микрофон' : 'Выключить микрофон'; transferBtn.disabled = !canControl || Boolean(pendingAction) || ending || connecting || !transferValue.value.trim(); hangupBtn.disabled = (!popupCall && !hasSession) || Boolean(pendingAction) || ending; transferType.disabled = !canControl || Boolean(pendingAction) || connecting; transferValue.disabled = !canControl || Boolean(pendingAction) || connecting; if (state.liveCalls.nameEditor.mode === 'popup' && (!popupCall || state.liveCalls.nameEditor.callId !== popupCall.call_id)) { closeLiveCallNameEditor(); } else { updateLiveCallNameEditorsUi(); } } function renderBrowserDeviceOptions(selectId, devices, preferredId, placeholder) { const select = $(selectId); if (!select) { return; } const items = devices.length ? devices : [{ deviceId: '', label: placeholder }]; select.innerHTML = items .map((item, index) => ``) .join(''); if (preferredId && items.some((item) => item.deviceId === preferredId)) { select.value = preferredId; } else if (!devices.length) { select.value = ''; } } async function loadBrowserPhoneDevices() { if (!navigator.mediaDevices?.enumerateDevices) { renderBrowserDeviceOptions('browserPhoneMicSelect', [], '', 'Media devices not available'); renderBrowserDeviceOptions('browserPhoneSpeakerSelect', [], '', 'Output selection not supported'); return; } const devices = await navigator.mediaDevices.enumerateDevices(); const inputs = devices.filter((item) => item.kind === 'audioinput'); const outputs = devices.filter((item) => item.kind === 'audiooutput'); renderBrowserDeviceOptions('browserPhoneMicSelect', inputs, state.browserPhone.micDeviceId, 'Микрофон'); renderBrowserDeviceOptions('browserPhoneSpeakerSelect', outputs, state.browserPhone.speakerDeviceId, 'Динамик'); if (!state.browserPhone.micDeviceId) { state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value; } if (!state.browserPhone.speakerDeviceId) { state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value; } syncBrowserPhoneSpeakerSink(); } async function ensureBrowserPhoneMediaAccess() { if (!navigator.mediaDevices?.getUserMedia) { throw new Error('Browser getUserMedia is not available'); } const stream = await navigator.mediaDevices.getUserMedia(browserPhoneAudioConstraints()); stream.getTracks().forEach((track) => track.stop()); } function stopBrowserPhoneLocalStream() { if (state.browserPhone.localStream) { state.browserPhone.localStream.getTracks().forEach((track) => track.stop()); } state.browserPhone.localStream = null; } function browserPhoneLocalStreamHealthy() { const stream = state.browserPhone.localStream; return Boolean(stream && stream.getTracks().some((track) => track.readyState === 'live')); } async function ensureBrowserPhoneLocalStream(options = {}) { const { forceFresh = false } = options; if (!navigator.mediaDevices?.getUserMedia) { throw new Error('Browser getUserMedia is not available'); } if (forceFresh) { stopBrowserPhoneLocalStream(); } if (browserPhoneLocalStreamHealthy()) { return state.browserPhone.localStream; } if (state.browserPhone.localStreamPromise) { return state.browserPhone.localStreamPromise; } state.browserPhone.localStreamPromise = navigator.mediaDevices .getUserMedia(browserPhoneAudioConstraints()) .then((stream) => { stopBrowserPhoneLocalStream(); state.browserPhone.localStream = stream; return stream; }) .finally(() => { state.browserPhone.localStreamPromise = null; }); return state.browserPhone.localStreamPromise; } function prepareBrowserPhoneAnswerMedia() { return ensureBrowserPhoneLocalStream().catch((err) => { log('Browser softphone не смог заранее подготовить media', { error: err.message }); return null; }); } function browserPhoneClaimCandidates() { return browserPhoneClaimCandidatesFrom(state.liveCalls.items); } function browserPhoneCallTimestampMs(item) { const raw = item?.last_transition_at || item?.connected_at || item?.started_at || item?.updated_at || ''; const ts = raw ? new Date(raw).getTime() : Number.NaN; return Number.isFinite(ts) ? ts : Number.NaN; } function browserPhoneSessionTimestampMs() { const raw = state.browserPhone.sessionStartedAt || ''; const ts = raw ? new Date(raw).getTime() : Number.NaN; return Number.isFinite(ts) ? ts : Number.NaN; } function browserPhoneClaimCandidatesFrom(items) { const config = browserPhoneConfig(); const extension = config?.operator_extension || ''; return (items || []).filter((item) => { if (!['ringing', 'claimed', 'connected'].includes(item.telephony_status)) { return false; } if (extension && item.operator_extension && item.operator_extension !== extension) { return false; } return true; }); } function browserPhoneBestCandidateMatch(candidates) { if (!Array.isArray(candidates) || !candidates.length) { return null; } const sessionTs = browserPhoneSessionTimestampMs(); const ranked = [...candidates].sort((left, right) => { const leftTs = browserPhoneCallTimestampMs(left); const rightTs = browserPhoneCallTimestampMs(right); if (Number.isFinite(sessionTs)) { const leftDelta = Number.isFinite(leftTs) ? Math.abs(leftTs - sessionTs) : Number.POSITIVE_INFINITY; const rightDelta = Number.isFinite(rightTs) ? Math.abs(rightTs - sessionTs) : Number.POSITIVE_INFINITY; if (leftDelta !== rightDelta) { return leftDelta - rightDelta; } } if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { return rightTs - leftTs; } return String(right?.call_id || '').localeCompare(String(left?.call_id || '')); }); return ranked[0] || null; } function attachBrowserRemoteAudio(session) { const element = browserPhoneAudioElement(); const sdh = session?.sessionDescriptionHandler; const peerConnection = sdh?.peerConnection; if (!element || !peerConnection) { return; } const syncRemoteStream = () => { const stream = new MediaStream(); peerConnection.getReceivers().forEach((receiver) => { if (receiver.track) { stream.addTrack(receiver.track); } }); element.srcObject = stream; syncBrowserPhoneSpeakerSink(); element.play().catch(() => {}); }; syncRemoteStream(); peerConnection.addEventListener('track', syncRemoteStream); } function applyBrowserMuteState() { const sdh = state.browserPhone.session?.sessionDescriptionHandler; const peerConnection = sdh?.peerConnection; if (!peerConnection) { return; } peerConnection.getSenders().forEach((sender) => { if (sender.track && sender.track.kind === 'audio') { sender.track.enabled = !state.browserPhone.muted; } }); } function resetBrowserPhoneSessionState() { stopBrowserPhoneRingtone(); state.browserPhone.session = null; state.browserPhone.incoming = false; state.browserPhone.muted = false; state.browserPhone.autoClaimCallId = ''; state.browserPhone.autoClaimInFlight = false; state.browserPhone.sessionStartedAt = ''; state.browserPhone.callPhase = state.browserPhone.endingCallId ? 'ending' : ''; stopBrowserPhoneLocalStream(); const element = browserPhoneAudioElement(); if (element) { element.srcObject = null; } updateBrowserPhoneUi(); } function browserSessionStateValue(session = state.browserPhone.session) { const SessionState = window.SIP?.SessionState; if (!session || !SessionState) { return null; } return session.state; } function browserSessionIsActive(session = state.browserPhone.session) { const SessionState = window.SIP?.SessionState; if (!session || !SessionState) { return false; } return ![SessionState.Terminated].includes(session.state); } function ensureBrowserSessionFreshForInvite() { if (!state.browserPhone.session) { return true; } if (browserSessionIsActive(state.browserPhone.session)) { return false; } resetBrowserPhoneSessionState(); return true; } function bindBrowserPhoneSession(session) { const SessionState = window.SIP?.SessionState; state.browserPhone.session = session; state.browserPhone.incoming = true; state.browserPhone.muted = false; state.browserPhone.popupCallId = ''; state.browserPhone.callPhase = 'incoming'; state.browserPhone.autoClaimInFlight = false; state.browserPhone.warning = ''; setBrowserPhoneStatus('Входящий звонок'); startBrowserPhoneRingtone().catch(() => {}); prepareBrowserPhoneAnswerMedia(); updateBrowserPhoneUi(); session.stateChange.addListener((newState) => { if (newState === SessionState.Established) { stopBrowserPhoneRingtone(); state.browserPhone.incoming = false; state.browserPhone.callPhase = 'in-call'; state.browserPhone.sessionStartedAt = new Date().toISOString(); setBrowserPhoneStatus('Соединено в браузере'); attachBrowserRemoteAudio(session); applyBrowserMuteState(); if (!state.browserPhone.autoClaimInFlight && !state.browserPhone.popupCallId && !state.browserPhone.autoClaimCallId) { void bestEffortBrowserAutoClaim({ refreshAttempts: 1, retryDelayMs: 150 }); } return; } if (newState === SessionState.Terminated) { resetBrowserPhoneSessionState(); if (state.browserPhone.connected && !state.browserPhone.endingCallId) { setBrowserPhoneStatus('Зарегистрирован'); } syncBrowserPhonePopupLifecycle(); } }); } async function terminateBrowserPhoneSessionLocally() { const session = state.browserPhone.session; if (!session) { return; } try { const SessionState = window.SIP?.SessionState; if (state.browserPhone.incoming && typeof session.reject === 'function') { await session.reject(); return; } if (session.state === SessionState.Established && typeof session.bye === 'function') { await session.bye(); return; } if (typeof session.cancel === 'function') { await session.cancel(); return; } if (typeof session.dispose === 'function') { session.dispose(); } } catch { if (typeof session.dispose === 'function') { session.dispose(); } } } async function claimLiveCallById(callId, options = {}) { const operatorExtension = (options.operatorExtension || browserPhoneConfig()?.operator_extension || '').trim(); const payload = {}; if (operatorExtension) { payload.operator_extension = operatorExtension; } const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/claim`, { method: 'POST', body: JSON.stringify(payload), }); state.browserPhone.popupCallId = data.call_id; state.browserPhone.endingCallId = ''; state.browserPhone.warning = ''; applyClaimedLiveCall(data); refreshLiveCallsInBackground(); return data; } async function hangupLiveCallById(callId, options = {}) { const { optimistic = true } = options; const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/hangup`, { method: 'POST', body: JSON.stringify({}), }); if (optimistic) { moveLiveCallToRecent(data); refreshLiveCallsInBackground(); } else { state.browserPhone.endingCallId = callId; updateBrowserPhoneUi(); refreshLiveCallsInBackground(); } return data; } async function bestEffortBrowserAutoClaim(options = {}) { return bestEffortBrowserAutoClaimWithOptions(options); } function browserPhoneAutoClaimSnapshot(items = state.liveCalls.items) { const byId = (callId) => { if (!callId) { return null; } return (items || []).find((item) => item.call_id === callId) || null; }; const trackedIds = [ state.browserPhone.popupCallId, state.browserPhone.autoClaimCallId, selectedLiveCallId(), ].filter(Boolean); for (const callId of trackedIds) { const found = byId(callId); if (!found) { continue; } if (found.claimed_by_user === state.user) { return { mode: 'owned', call: found }; } if (isClaimableLiveCall(found)) { return { mode: 'claim', call: found }; } } const candidates = browserPhoneClaimCandidatesFrom(items); const ownedCandidates = candidates.filter((item) => item.claimed_by_user === state.user); if (ownedCandidates.length === 1) { return { mode: 'owned', call: ownedCandidates[0] }; } if (ownedCandidates.length > 1) { const bestOwned = browserPhoneBestCandidateMatch(ownedCandidates); if (bestOwned) { return { mode: 'owned', call: bestOwned }; } } const unclaimedCandidates = candidates.filter((item) => !item.claimed_by_user); if (unclaimedCandidates.length === 1) { return { mode: 'claim', call: unclaimedCandidates[0] }; } if (unclaimedCandidates.length > 1) { const bestUnclaimed = browserPhoneBestCandidateMatch(unclaimedCandidates); if (bestUnclaimed) { return { mode: 'claim', call: bestUnclaimed }; } } return null; } async function bestEffortBrowserAutoClaimWithOptions(options = {}) { const { refreshAttempts = 2, retryDelayMs = 150 } = options; if (state.browserPhone.autoClaimInFlight) { return false; } state.browserPhone.autoClaimInFlight = true; updateBrowserPhoneUi(); const config = browserPhoneConfig(); const operatorExtension = config?.operator_extension || ''; const handleMatch = async (match) => { if (!match?.call) { return false; } if (match.mode === 'owned') { state.browserPhone.autoClaimCallId = match.call.call_id; state.browserPhone.popupCallId = match.call.call_id; state.browserPhone.warning = ''; return true; } if (match.mode === 'claim') { try { const claimed = await claimLiveCallById(match.call.call_id, { operatorExtension }); state.browserPhone.autoClaimCallId = claimed.call_id; state.browserPhone.popupCallId = claimed.call_id; state.browserPhone.warning = ''; log('Browser softphone выполнил auto-claim', { call_id: claimed.call_id, extension: operatorExtension || claimed.operator_extension }); return true; } catch (err) { setBrowserPhoneWarning('Звонок соединён, но авто-claim не выполнился. Нажмите «Принять в работу».'); log('Browser softphone не смог выполнить auto-claim', { error: describeLiveCallError(err.message) }); return false; } } return false; }; try { const localMatch = browserPhoneAutoClaimSnapshot(); if (await handleMatch(localMatch)) { return true; } for (let attempt = 0; attempt < refreshAttempts; attempt += 1) { if (attempt > 0 && retryDelayMs > 0) { await new Promise((resolve) => window.setTimeout(resolve, retryDelayMs)); } await loadLiveCalls(false, { preserveStateOnError: true }); const refreshedMatch = browserPhoneAutoClaimSnapshot(); if (await handleMatch(refreshedMatch)) { return true; } } setBrowserPhoneWarning('Не удалось однозначно сопоставить звонок. Нажмите «Принять в работу» вручную.'); log('Browser softphone требует ручного claim', { candidates: browserPhoneClaimCandidates().length }); return false; } finally { state.browserPhone.autoClaimInFlight = false; updateBrowserPhoneUi(); } } async function fetchBrowserSoftphoneConfig() { try { const data = await api('asterisk-bridge', 'asterisk/browser-softphone/config'); state.browserPhone.config = data; state.browserPhone.status = data.enabled ? 'Готов к подключению' : 'Отключён в окружении'; } catch (err) { state.browserPhone.config = null; state.browserPhone.status = 'Не настроен'; log('Browser softphone config недоступен', { error: describeLiveCallError(err.message) }); } finally { updateBrowserPhoneUi(); } } async function connectBrowserSoftphone() { if (state.browserPhone.connecting || state.browserPhone.connected) { return; } if (!browserPhoneLibraryAvailable()) { setBrowserPhoneStatus('SIP.js asset не загружен'); return; } if (!browserPhoneConfig()) { await fetchBrowserSoftphoneConfig(); } const config = browserPhoneConfig(); if (!config?.enabled) { updateBrowserPhoneUi(); return; } try { state.browserPhone.connecting = true; state.browserPhone.settingsOpen = false; state.browserPhone.warning = ''; setBrowserPhoneStatus('Подключаем browser softphone...'); await ensureBrowserPhoneMediaAccess(); await ensureBrowserPhoneAudioContextResumed(); await loadBrowserPhoneDevices(); const uri = window.SIP.UserAgent.makeURI(config.sip_uri); if (!uri) { throw new Error('Invalid browser SIP URI'); } const userAgent = new window.SIP.UserAgent({ uri, authorizationUsername: config.authorization_username, authorizationPassword: config.password, displayName: config.display_name, transportOptions: { server: config.ws_url }, sessionDescriptionHandlerFactoryOptions: { constraints: browserPhoneAudioConstraints(), peerConnectionConfiguration: { iceServers: Array.isArray(config.ice_servers) ? config.ice_servers : [], iceCandidatePoolSize: 4, bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require', }, }, }); userAgent.delegate = { onInvite: (invitation) => { if (!ensureBrowserSessionFreshForInvite()) { invitation.reject().catch(() => {}); log('Browser softphone отклонил входящий INVITE: предыдущая session ещё активна'); return; } bindBrowserPhoneSession(invitation); log('Browser softphone получил входящий звонок', { remote: browserPhoneIncomingLabel(invitation) }); }, }; const registerer = new window.SIP.Registerer(userAgent); await userAgent.start(); await registerer.register(); state.browserPhone.ua = userAgent; state.browserPhone.registerer = registerer; state.browserPhone.connected = true; setBrowserPhoneStatus('Зарегистрирован'); } catch (err) { state.browserPhone.connected = false; state.browserPhone.ua = null; state.browserPhone.registerer = null; setBrowserPhoneStatus('Ошибка регистрации'); log('Browser softphone не подключился', { error: err.message }); } finally { state.browserPhone.connecting = false; updateBrowserPhoneUi(); } } async function disconnectBrowserSoftphone() { try { stopBrowserPhoneRingtone(); if (state.browserPhone.session) { await terminateBrowserPhoneSessionLocally(); } if (state.browserPhone.registerer) { await state.browserPhone.registerer.unregister().catch(() => {}); } if (state.browserPhone.ua) { await state.browserPhone.ua.stop().catch(() => {}); } } finally { state.browserPhone.connected = false; state.browserPhone.connecting = false; state.browserPhone.settingsOpen = false; state.browserPhone.ua = null; state.browserPhone.registerer = null; state.browserPhone.popupCallId = ''; state.browserPhone.endingCallId = ''; state.browserPhone.callPhase = ''; state.browserPhone.warning = ''; stopBrowserPhoneLocalStream(); resetBrowserPhoneSessionState(); setBrowserPhoneStatus(browserPhoneConfig()?.enabled ? 'Отключён' : 'Не настроен'); } } async function answerBrowserSoftphoneCall() { const session = state.browserPhone.session; if (!session || !state.browserPhone.incoming) { return; } try { stopBrowserPhoneRingtone(); state.browserPhone.warning = ''; state.browserPhone.incoming = false; state.browserPhone.callPhase = 'connecting'; setBrowserPhoneStatus('Соединяем browser media...'); updateBrowserPhoneUi(); const localMediaStream = await ensureBrowserPhoneLocalStream(); await session.accept({ sessionDescriptionHandlerOptions: { constraints: browserPhoneAudioConstraints(), localMediaStream, }, }); const hasImmediateMatch = Boolean(browserPhoneAutoClaimSnapshot()); void bestEffortBrowserAutoClaimWithOptions({ refreshAttempts: hasImmediateMatch ? 0 : 2, retryDelayMs: 150, }); } catch (err) { state.browserPhone.incoming = true; state.browserPhone.callPhase = 'incoming'; startBrowserPhoneRingtone().catch(() => {}); setBrowserPhoneWarning('Не удалось ответить на звонок в браузере.'); setBrowserPhoneStatus('Входящий звонок'); log('Ответ в браузерном телефоне не выполнен', { error: err.message }); } } async function rejectBrowserSoftphoneCall() { if (!state.browserPhone.session || !state.browserPhone.incoming) { return; } stopBrowserPhoneRingtone(); state.browserPhone.warning = ''; await terminateBrowserPhoneSessionLocally(); resetBrowserPhoneSessionState(); state.browserPhone.callPhase = ''; if (state.browserPhone.connected) { setBrowserPhoneStatus('Зарегистрирован'); } } function toggleBrowserSoftphoneMute() { if (!state.browserPhone.session) { return; } state.browserPhone.muted = !state.browserPhone.muted; applyBrowserMuteState(); updateBrowserPhoneUi(); } function syncLiveCallActionButtons() { const loadActionsBtn = $('loadLiveCallActionsBtn'); const refreshBtn = $('loadLiveCallsBtn'); const buttons = [loadActionsBtn, refreshBtn].filter(Boolean); const hasCall = Boolean(selectedLiveCallId()); const pendingAction = state.liveCalls.pendingAction || ''; buttons.forEach((button) => { if (!button.dataset.defaultLabel) { button.dataset.defaultLabel = button.textContent; } button.textContent = button.dataset.defaultLabel; }); loadActionsBtn.disabled = !hasCall || Boolean(pendingAction); refreshBtn.disabled = Boolean(pendingAction); updateBrowserPhoneUi(); } async function runLiveCallAction(actionName, handler) { if (state.liveCalls.pendingAction) { log('Дождитесь завершения предыдущего действия по звонку'); return null; } state.liveCalls.pendingAction = actionName; syncLiveCallActionButtons(); try { return await handler(); } finally { state.liveCalls.pendingAction = ''; syncLiveCallActionButtons(); } } async function claimBrowserPopupCall() { const popupCall = browserPhoneActivePopupCall(); if (!popupCall) { setBrowserPhoneWarning('Не удалось найти активный звонок для claim.'); return; } await runLiveCallAction('claim', async () => { try { const data = await claimLiveCallById(popupCall.call_id); state.browserPhone.popupCallId = data.call_id; state.browserPhone.warning = ''; log('Звонок принят в работу', { call_id: data.call_id, claimed_by: data.claimed_by_user, extension: data.operator_extension, }); } catch (err) { setBrowserPhoneWarning(describeLiveCallError(err.message)); log('Не удалось принять звонок в работу', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) }); } }); } async function hangupBrowserPopupCall() { const popupCall = browserPhoneActivePopupCall(); if (!popupCall && !state.browserPhone.session) { setBrowserPhoneWarning('Не удалось найти активный звонок для завершения.'); return; } await runLiveCallAction('hangup', async () => { const callId = popupCall?.call_id || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId || selectedLiveCallId(); try { state.browserPhone.endingCallId = callId; state.browserPhone.callPhase = 'ending'; state.browserPhone.warning = ''; updateBrowserPhoneUi(); if (state.browserPhone.session) { await terminateBrowserPhoneSessionLocally(); resetBrowserPhoneSessionState(); state.browserPhone.endingCallId = callId; state.browserPhone.popupCallId = callId; state.browserPhone.callPhase = 'ending'; updateBrowserPhoneUi(); } if (!callId) { throw new Error('hangup failed: call_id is missing'); } await hangupLiveCallById(callId, { optimistic: false }); log('Отправлена команда завершения звонка', { call_id: callId }); } catch (err) { state.browserPhone.callPhase = 'error'; state.browserPhone.endingCallId = callId || state.browserPhone.endingCallId; setBrowserPhoneWarning(describeLiveCallError(err.message)); refreshLiveCallsInBackground(); log('Не удалось завершить звонок', { call_id: callId || popupCall?.call_id || '', error: describeLiveCallError(err.message) }); } }); } async function transferBrowserPopupCall() { const popupCall = browserPhoneActivePopupCall(); const targetType = $('browserPhoneTransferTargetType').value; const targetValue = $('browserPhoneTransferTargetValue').value.trim(); if (!popupCall) { setBrowserPhoneWarning('Не удалось найти активный звонок для передачи.'); return; } if (!targetValue) { setBrowserPhoneWarning('Укажите target для передачи звонка.'); return; } await runLiveCallAction('transfer', async () => { try { const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(popupCall.call_id)}/blind-transfer`, { method: 'POST', body: JSON.stringify({ target_type: targetType, target_value: targetValue }), }); if (state.browserPhone.session) { await terminateBrowserPhoneSessionLocally(); resetBrowserPhoneSessionState(); } state.browserPhone.popupCallId = ''; state.browserPhone.endingCallId = ''; state.browserPhone.warning = ''; moveLiveCallToRecent(data); refreshLiveCallsInBackground(); log('Blind transfer выполнен', { call_id: data.call_id, target_type: targetType, target_value: targetValue, }); } catch (err) { setBrowserPhoneWarning(describeLiveCallError(err.message)); log('Не удалось передать звонок', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) }); } }); } function updateLiveCallSelector(items) { const select = $('liveCallIdSelect'); const previous = state.liveCalls.selectedCallId || select.value || ''; const sorted = [...items]; select.innerHTML = sorted.length ? sorted .map((item) => { const summary = voiceSummaryForItem(item); const label = `${voiceCustomerDisplayName(item, summary)} | ${item.call_id}${item.interaction_id ? ` | ${item.interaction_id}` : ''}`; return ``; }) .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 = item.call_id === state.liveCalls.selectedCallId; const summary = voiceSummaryForItem(item); const caller = voiceCustomerDisplayName(item, summary); const aiMeta = voiceAiStatusMeta(item); const customerId = liveCallCustomerId(item); const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status); const nameStatusLine = nameStatusMeta ? `

    имя: ${escapeHtml(nameStatusMeta.label)}

    ` : ''; const nameSourceLabel = formatVoiceCustomerNameSource(summary?.customer_name_source); const nameSourceLine = nameSourceLabel ? `

    источник имени: ${escapeHtml(nameSourceLabel)}

    ` : ''; const languageLabel = formatVoiceStartLanguage(summary?.voice_start_language); const languageLine = languageLabel ? `

    язык старта: ${escapeHtml(languageLabel)}

    ` : ''; const badges = [ `${escapeHtml(item.queue_code || item.queue_id)}`, `${escapeHtml(telephonyLabel(item.telephony_status))}`, ]; if (aiMeta) { badges.push(`${escapeHtml(aiMeta.label)}`); } if (nameStatusMeta) { badges.push(`${escapeHtml(nameStatusMeta.shortLabel)}`); } 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))}

    `; const actions = [ customerId ? `` : '', customerId ? `` : '', ].filter(Boolean); return `
    ${badges.join('')}

    ${escapeHtml(caller)}

    ${escapeHtml(voiceCustomerCallSubtitle(item))}

    обращение: ${escapeHtml(item.interaction_id || 'не найдено')}

    взял в работу: ${escapeHtml(item.claimed_by_user || '-')}

    внутренний номер: ${escapeHtml(item.operator_extension || '-')}

    начат: ${escapeHtml(formatIsoShort(item.started_at))}

    ${timingLine} ${nameStatusLine} ${nameSourceLine} ${languageLine} ${aiReasonLine} ${targetLine} ${hangupLine}

    запись: ${item.has_recording ? 'да' : 'нет'}

    ${actions.length ? `
    ${actions.join('')}
    ` : ''}
    `; } 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) { activeItems.forEach((item) => ensureVoiceAiSummaryLoaded(item)); recentItems.forEach((item) => ensureVoiceAiSummaryLoaded(item)); $('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); updateLiveCallNameEditorsUi(); syncBrowserPhonePopupLifecycle(); renderCustomerSpotlight(); renderUnifiedInbox(); } function applyClaimedLiveCall(item) { const activeItems = [...state.liveCalls.items]; const existingIndex = activeItems.findIndex((entry) => entry.call_id === item.call_id); if (existingIndex >= 0) { activeItems[existingIndex] = item; } else { activeItems.unshift(item); } const recentItems = state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id); applyLiveCallCollections(activeItems, recentItems, { selectedCallId: item.call_id }); } function moveLiveCallToRecent(item) { const activeItems = state.liveCalls.items.filter((entry) => entry.call_id !== item.call_id); const recentItems = [item, ...state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id)]; const nextSelectedCallId = state.liveCalls.selectedCallId === item.call_id ? (activeItems[0]?.call_id || '') : state.liveCalls.selectedCallId; applyLiveCallCollections(activeItems, recentItems, { selectedCallId: nextSelectedCallId }); } function refreshLiveCallsInBackground() { window.setTimeout(() => { loadLiveCalls(false, { preserveStateOnError: true }); }, 0); } async function loadLiveCalls(logResult = true, options = {}) { const { preserveStateOnError = false } = options; try { const [activeData, recentData] = await Promise.all([ api('asterisk-bridge', 'asterisk/live-calls'), api('asterisk-bridge', 'asterisk/recent-calls'), ]); const items = Array.isArray(activeData) ? activeData : []; const recentItems = Array.isArray(recentData) ? recentData : []; applyLiveCallCollections(items, recentItems); if (logResult) { log('Живые звонки обновлены', { active: items.length, recent: recentItems.length }); } } catch (err) { if (!preserveStateOnError) { applyLiveCallCollections([], [], { selectedCallId: '' }); $('liveCallStatusHint').textContent = 'Сервис live-calls недоступен.'; } if (logResult) { log('Не удалось загрузить звонки', { error: describeLiveCallError(err.message) }); } } finally { syncLiveCallActionButtons(); } } async function claimLiveCall() { await claimBrowserPopupCall(); } async function hangupLiveCall() { await hangupBrowserPopupCall(); } async function blindTransferLiveCall() { await transferBrowserPopupCall(); } async function loadLiveCallActions() { const callId = selectedLiveCallId(); if (!callId) { $('liveCallActionsOutput').textContent = 'Выберите звонок.'; return; } try { const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/actions`); setOutput('liveCallActionsOutput', data); log('Загружен action log по звонку', { call_id: callId, actions: data.length }); } catch (err) { $('liveCallActionsOutput').textContent = err.message; log('Не удалось загрузить action log', { call_id: callId, error: err.message }); } } function startLiveCallsPolling() { if (state.liveCalls.pollTimer) { window.clearInterval(state.liveCalls.pollTimer); state.liveCalls.pollTimer = null; } state.liveCalls.pollTimer = window.setInterval(() => { loadLiveCalls(false); }, 3000); } async function loadInteractions() { try { const data = await api('interaction', 'interactions'); state.interactions = Array.isArray(data) ? data : []; if (!data.length) { setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.'); renderCustomerSpotlight(); renderUnifiedInbox(); return; } $('interactionTable').innerHTML = renderInteractionBoard(data); renderCustomerSpotlight(); renderUnifiedInbox(); } catch (err) { state.interactions = []; setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.'); renderCustomerSpotlight(); renderUnifiedInbox(); log('Не удалось загрузить обращения', { error: err.message }); } } window.assignInteraction = async function assignInteraction(id) { const assignee = getDefaultAssignee(); try { await api('interaction', `interactions/${id}/assign`, { method: 'PATCH', body: JSON.stringify({ assignee }), }); await loadInteractions(); log('Обращение назначено', { id, assignee }); } catch (err) { log('Не удалось назначить обращение', { error: err.message }); } }; window.escalateInteraction = async function escalateInteraction(id) { const targetQueueId = getDefaultEscalationQueue(); try { await api('interaction', `interactions/${id}/escalate`, { method: 'POST', body: JSON.stringify({ target_queue_id: targetQueueId }), }); await loadInteractions(); log('Обращение передано на 2 линию', { id, target_queue_id: targetQueueId }); } catch (err) { log('Не удалось передать обращение', { error: err.message }); } }; window.closeInteraction = async function closeInteraction(id) { try { await api('interaction', `interactions/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status: 'closed' }), }); await loadInteractions(); log('Обращение закрыто', { id }); } catch (err) { log('Не удалось закрыть обращение', { error: err.message }); } }; async function loadSupervisor() { try { const data = await api('supervisor', 'supervisor/realtime'); renderSupervisorSummary(data); setOutput('supervisorOutput', data); log('Данные супервизора обновлены', { agents: data?.agents?.total || 0, queue: data?.queues?.[0]?.queue_id || 'нет', }); } catch (err) { $('supervisorSummary').innerHTML = renderSummaryCard('Супервизор', 'нет данных', 'Сервис не ответил'); $('supervisorOutput').textContent = err.message; log('Не удалось обновить супервизора', { error: err.message }); } } async function loadKpi() { try { const data = await api('reporting', 'reports/kpi'); renderKpiSummary(data); setOutput('kpiOutput', data); log('KPI обновлены', data?.kpi || {}); } catch (err) { $('kpiSummary').innerHTML = renderSummaryCard('KPI', 'нет данных', 'Сервис не ответил'); $('kpiOutput').textContent = err.message; log('Не удалось обновить KPI', { error: err.message }); } } async function handleCustomerActionClick(event) { const button = event.target.closest('[data-customer-action]'); if (!button) { return; } const customer = selectedCustomer(); if (!customer) { return; } if (button.dataset.customerAction === 'edit-name') { openCustomerProfileNameEditor(customer.customer_id); return; } if (button.dataset.customerAction === 'cancel-name-edit') { closeCustomerProfileNameEditor(); return; } if (button.dataset.customerAction === 'workspace') { $('interactionCustomerId').value = customer.customer_id; window.location.hash = '#workspace'; log('Клиент открыт в рабочем столе', { customer_id: customer.customer_id }); return; } if (button.dataset.customerAction === 'telegram') { const threadId = button.dataset.threadId || ''; if (!threadId) { return; } window.location.hash = '#telegram-page'; await selectTelegramThread(threadId); log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId }); return; } if (button.dataset.customerAction === 'interaction') { const interactionId = button.dataset.interactionId || ''; $('interactionCustomerId').value = customer.customer_id; window.location.hash = '#workspace'; log('Открыт кейс клиента', { customer_id: customer.customer_id, interaction_id: interactionId || '-' }); return; } if (button.dataset.customerAction === 'call') { const callId = button.dataset.callId || ''; if (!callId) { return; } focusLiveCall(callId); window.location.hash = '#calls'; log('Открыт звонок клиента', { customer_id: customer.customer_id, call_id: callId }); } } function handleCustomerProfileNameInput(event) { const input = event.target?.closest?.('[data-customer-name-input]'); if (!input) { return; } state.customers.nameEditor.draft = input.value || ''; state.customers.nameEditor.flash = ''; if (!state.customers.nameEditor.error) { return; } state.customers.nameEditor.error = ''; const form = input.closest('[data-customer-name-form]'); const status = form?.querySelector?.('[data-customer-name-status]'); if (status) { status.textContent = 'Имя сохранится в профиле клиента и voice-контуре.'; status.classList.remove('error'); } } function handleCustomerProfileNameSubmit(event) { const form = event.target?.closest?.('[data-customer-name-form]'); if (!form) { return; } event.preventDefault(); saveCustomerProfileName().catch((err) => { state.customers.nameEditor.saving = false; state.customers.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; renderCustomerSpotlight(); }); } function wire() { $('loginBtn').addEventListener('click', login); $('corporateLoginBtn').addEventListener('click', startCorporateLogin); $('refreshBtn').addEventListener('click', async () => { await refreshUnifiedInbox(); if (featureEnabled('whatsapp')) { await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true }); } log('Данные на экране обновлены'); }); $('refreshUnifiedInboxBtn').addEventListener('click', () => { refreshUnifiedInbox().catch((err) => { log('Не удалось обновить единую очередь', { error: err.message }); }); }); $('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick); $('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm()); $('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false)); $('createCustomerBtn').addEventListener('click', createCustomer); $('exportCustomersBtn').addEventListener('click', exportCustomersCsv); $('customerPrevPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page - 1)); $('customerNextPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page + 1)); $('customerQuery').addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchCustomers(); } }); $('customerList').addEventListener('click', (event) => { const row = event.target.closest('[data-customer-id]'); if (!row) { return; } openCustomerProfile(row.dataset.customerId || ''); }); $('customerList').addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } const row = event.target.closest('[data-customer-id]'); if (!row) { return; } event.preventDefault(); openCustomerProfile(row.dataset.customerId || ''); }); $('customerSpotlight')?.addEventListener('click', handleCustomerActionClick); $('customerSpotlight')?.addEventListener('input', handleCustomerProfileNameInput); $('customerSpotlight')?.addEventListener('submit', handleCustomerProfileNameSubmit); $('customerProfileContent').addEventListener('click', handleCustomerActionClick); $('customerProfileContent').addEventListener('input', handleCustomerProfileNameInput); $('customerProfileContent').addEventListener('submit', handleCustomerProfileNameSubmit); $('customerProfileBackBtn').addEventListener('click', () => { window.location.hash = '#customers-page'; }); $('loadTelegramThreadsBtn').addEventListener('click', () => loadTelegramThreads(true, { preserveSelection: true, preserveOnError: true })); $('telegramThreadSearch').addEventListener('input', (event) => { state.telegram.searchQuery = event.target.value || ''; renderTelegramWorkspace(); }); $('telegramHeaderSearchBtn').addEventListener('click', () => { toggleTelegramMessageSearch(); }); $('telegramMessageSearch').addEventListener('input', (event) => { state.telegram.messageSearchQuery = event.target.value || ''; renderTelegramWorkspace(); }); $('telegramMessageSearch').addEventListener('keydown', (event) => { if (event.key !== 'Escape') { return; } event.preventDefault(); if (telegramMessageSearchQuery()) { state.telegram.messageSearchQuery = ''; renderTelegramWorkspace(); return; } toggleTelegramMessageSearch(false); }); $('telegramMessageSearchClearBtn').addEventListener('click', () => { if (telegramMessageSearchQuery()) { state.telegram.messageSearchQuery = ''; renderTelegramWorkspace(); $('telegramMessageSearch').focus(); return; } toggleTelegramMessageSearch(false); }); $('telegramHeaderCallBtn').addEventListener('click', () => { log('Voice call action для Telegram пока не назначен'); }); $('telegramHeaderMenuBtn').addEventListener('click', () => { toggleTelegramOperatorTray(); }); $('telegramThreadsList').addEventListener('click', (event) => { const button = event.target.closest('[data-telegram-thread-id]'); if (!button) { return; } selectTelegramThread(button.dataset.telegramThreadId || ''); }); $('telegramThreadsList').addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } const button = event.target.closest('[data-telegram-thread-id]'); if (!button) { return; } event.preventDefault(); selectTelegramThread(button.dataset.telegramThreadId || ''); }); $('telegramClaimThreadBtn').addEventListener('click', claimTelegramThread); $('telegramReturnToAiBtn').addEventListener('click', returnTelegramThreadToAi); $('telegramCloseThreadBtn').addEventListener('click', closeTelegramThread); $('telegramEscalateThreadBtn').addEventListener('click', escalateTelegramThread); $('telegramSendReplyBtn').addEventListener('click', sendTelegramReply); $('telegramReplyText').addEventListener('input', () => { syncTelegramComposerHeight(); syncTelegramActionButtons(); }); $('telegramReplyText').addEventListener('keydown', (event) => { if (event.key !== 'Enter') { return; } if (event.shiftKey) { return; } event.preventDefault(); sendTelegramReply(); }); if (featureEnabled('whatsapp')) { $('whatsappSearchInput').addEventListener('input', (event) => { state.whatsapp.searchQuery = event.target.value || ''; renderWhatsappWorkspace(); }); $('whatsappFilterBar').addEventListener('click', (event) => { const button = event.target.closest('[data-whatsapp-filter]'); if (!button) { return; } setWhatsappFilter(button.dataset.whatsappFilter || 'all'); }); $('whatsappClaimBtn').addEventListener('click', () => { claimWhatsappThread().catch(() => {}); }); $('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi); $('whatsappChatList').addEventListener('click', (event) => { const button = event.target.closest('[data-whatsapp-chat-id]'); if (!button) { return; } selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); }); $('whatsappChatList').addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } const button = event.target.closest('[data-whatsapp-chat-id]'); if (!button) { return; } event.preventDefault(); selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); }); document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => { button.addEventListener('click', handleWhatsappUiAction); }); $('whatsappSendBtn').addEventListener('click', sendWhatsappMessage); $('whatsappComposerInput').addEventListener('input', (event) => { state.whatsapp.composerText = event.target.value || ''; syncWhatsappComposerUi(); }); $('whatsappComposerInput').addEventListener('keydown', (event) => { if (event.key !== 'Enter' || event.shiftKey) { return; } event.preventDefault(); sendWhatsappMessage(); }); } $('createInteractionBtn').addEventListener('click', createInteraction); $('loadInteractionsBtn').addEventListener('click', loadInteractions); $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true)); $('loadLiveCallActionsBtn').addEventListener('click', loadLiveCallActions); $('liveCallsTable').addEventListener('click', handleLiveCallTableClick); $('liveCallNameInput').addEventListener('input', (event) => { state.liveCalls.nameEditor.draft = event.target.value || ''; if (state.liveCalls.nameEditor.error) { state.liveCalls.nameEditor.error = ''; } updateLiveCallNameEditorsUi(); }); $('liveCallNameSaveBtn').addEventListener('click', () => { saveLiveCallCustomerName().catch((err) => { state.liveCalls.nameEditor.saving = false; state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; updateLiveCallNameEditorsUi(); }); }); $('liveCallNameCancelBtn').addEventListener('click', closeLiveCallNameEditor); $('browserPhoneStatusBtn').addEventListener('click', toggleBrowserPhoneSettings); $('browserPhoneCallSettingsBtn').addEventListener('click', toggleBrowserPhoneSettings); $('browserPhoneConnectBtn').addEventListener('click', connectBrowserSoftphone); $('browserPhoneDisconnectBtn').addEventListener('click', disconnectBrowserSoftphone); $('browserPhoneMuteBtn').addEventListener('click', toggleBrowserSoftphoneMute); $('browserPhoneAnswerBtn').addEventListener('click', answerBrowserSoftphoneCall); $('browserPhoneRejectBtn').addEventListener('click', rejectBrowserSoftphoneCall); $('browserPhoneCallClaimBtn').addEventListener('click', claimBrowserPopupCall); $('browserPhoneEditNameBtn').addEventListener('click', () => { const popupCall = browserPhoneActivePopupCall(); if (popupCall?.call_id) { openLiveCallNameEditor(popupCall.call_id, 'popup'); } }); $('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall); $('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall); $('browserPhoneNameInput').addEventListener('input', (event) => { state.liveCalls.nameEditor.draft = event.target.value || ''; if (state.liveCalls.nameEditor.error) { state.liveCalls.nameEditor.error = ''; } updateLiveCallNameEditorsUi(); }); $('browserPhoneNameSaveBtn').addEventListener('click', () => { saveLiveCallCustomerName().catch((err) => { state.liveCalls.nameEditor.saving = false; state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.'; updateLiveCallNameEditorsUi(); }); }); $('browserPhoneNameCancelBtn').addEventListener('click', closeLiveCallNameEditor); $('browserPhoneMicSelect').addEventListener('change', () => { state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value; stopBrowserPhoneLocalStream(); if (state.browserPhone.incoming) { prepareBrowserPhoneAnswerMedia(); } }); $('browserPhoneSpeakerSelect').addEventListener('change', () => { state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value; syncBrowserPhoneSpeakerSink(); }); $('browserPhoneTransferTargetValue').addEventListener('input', updateBrowserPhoneUi); $('browserPhoneTransferTargetType').addEventListener('change', updateBrowserPhoneUi); $('liveCallIdSelect').addEventListener('change', () => { state.liveCalls.selectedCallId = $('liveCallIdSelect').value; syncLiveCallActionButtons(); }); $('logoutBtn').addEventListener('click', logout); window.addEventListener('message', handleOidcMessage); window.addEventListener('hashchange', handleOperatorHashChange); if (navigator.mediaDevices?.addEventListener) { navigator.mediaDevices.addEventListener('devicechange', () => { loadBrowserPhoneDevices().catch(() => {}); }); } } async function init() { if (!restoreStoredSession()) { window.location.href = '/'; return; } await loadOperatorConfig(); wire(); applyOperatorViewFromHash(); $('defaultAssignee').value = DEMO_ASSIGNEE; $('defaultEscalationQueue').value = DEMO_QUEUE; $('telegramEscalationQueue').value = DEMO_QUEUE; updateSessionInfo(); syncLiveCallActionButtons(); renderUnifiedInbox(); 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();