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