Improve operator CRM flows and UI

This commit is contained in:
Yera All
2026-04-03 23:47:34 +05:00
parent 8e60ce0f39
commit 78918a38fd
10 changed files with 1446 additions and 118 deletions
+601 -86
View File
@@ -4,6 +4,9 @@
token: null,
authSource: 'local',
fullName: null,
features: {
whatsapp: false,
},
logLines: [],
interactions: [],
oidc: {
@@ -26,6 +29,9 @@
pageSize: 7,
selectedCustomerId: '',
leadFormOpen: false,
historyById: {},
pendingHistoryById: {},
historyErrorsById: {},
},
telegram: {
threads: [],
@@ -346,6 +352,14 @@ const BOARD_COLUMNS = [
{ key: 'closed', title: 'Закрытые' },
];
const UNIFIED_INBOX_COLUMNS = [
{ key: 'new', title: 'Новые', emptyMessage: 'Новых задач сейчас нет.' },
{ key: 'mine', title: 'Мои', emptyMessage: 'За вами пока ничего не закреплено.' },
{ key: 'ai', title: 'AI handoff', emptyMessage: 'Передач от AI сейчас нет.' },
{ key: 'escalated', title: 'Эскалации', emptyMessage: 'Эскалаций сейчас нет.' },
{ key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' },
];
const LIVE_TELEPHONY_LABELS = {
ringing: 'Звонит',
claimed: 'Взято',
@@ -373,9 +387,62 @@ const OPERATOR_VIEW_IDS = {
'customer-profile': 'customerProfileView',
telegram: 'telegramView',
whatsapp: 'whatsappView',
'voice-debug': 'voiceDebugView',
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';
@@ -469,6 +536,7 @@ function logout() {
window.clearInterval(state.telegram.pollTimer);
state.telegram.pollTimer = null;
}
stopWhatsappPolling();
clearStoredSession();
window.location.href = '/';
}
@@ -612,9 +680,26 @@ function customerProfileHash(customerId = state.customers.selectedCustomerId) {
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;
@@ -663,6 +748,43 @@ function customerLiveCalls(customer) {
});
}
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) => {
@@ -835,23 +957,48 @@ function customerSpotlightMarkup(customer, options = {}) {
const source = customerLeadSourceMeta(customer, index);
const leadStatus = customerLeadStatusMeta(customer, index);
const score = customerLeadScore(customer, index);
const interactions = customerInteractions(customer);
const threads = customerTelegramThreads(customer);
const liveCalls = customerLiveCalls(customer);
const historyEvents = buildCustomerHistoryEvents(customer);
const historyPayload = customerHistoryPayload(customer.customer_id);
const historySummary = historyPayload?.summary || null;
const interactions = Array.isArray(historyPayload?.interactions)
? historyPayload.interactions
: customerInteractions(customer);
const threads = Array.isArray(historyPayload?.telegram_threads)
? historyPayload.telegram_threads
: customerTelegramThreads(customer);
const liveCalls = Array.isArray(historyPayload?.live_calls)
? historyPayload.live_calls
: customerLiveCalls(customer);
const historyEvents = Array.isArray(historyPayload?.history) && historyPayload.history.length
? historyPayload.history
: buildCustomerHistoryEvents(customer);
const latestEvent = historyEvents[0] || null;
const openCases = interactions.filter((item) => item.status !== 'closed').length;
const activeChannels = [...new Set([
...interactions.map((item) => item.channel).filter(Boolean),
...(threads.length ? ['telegram'] : []),
...(liveCalls.length ? ['voice'] : []),
])];
const openCases = Number.isFinite(Number(historySummary?.open_cases))
? Number(historySummary.open_cases)
: interactions.filter((item) => item.status !== 'closed').length;
const activeChannels = Array.isArray(historySummary?.active_channels) && historySummary.active_channels.length
? historySummary.active_channels
: [...new Set([
...interactions.map((item) => item.channel).filter(Boolean),
...(threads.length ? ['telegram'] : []),
...(liveCalls.length ? ['voice'] : []),
])];
const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : [];
const primaryThread = [...threads].sort((left, right) => {
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at);
})[0] || null;
const primaryThread = historySummary?.primary_telegram_thread_id
? threads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null
: [...threads].sort((left, right) => {
return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at);
})[0] || null;
const historyStatusText = customerHistoryPending(customer.customer_id)
? 'Обновляем ленту клиента из backend...'
: state.customers.historyErrorsById[customer.customer_id]
? 'Показываем локальную историю, пока backend недоступен.'
: (latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения');
const summaryCards = [
renderSummaryCard('Контакты', String(interactions.length + threads.length + liveCalls.length), 'Все точки касания клиента'),
renderSummaryCard(
'Контакты',
String(Number.isFinite(Number(historySummary?.contact_points)) ? Number(historySummary.contact_points) : (interactions.length + threads.length + liveCalls.length)),
'Все точки касания клиента',
),
renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'),
renderSummaryCard(
'Каналы',
@@ -860,8 +1007,8 @@ function customerSpotlightMarkup(customer, options = {}) {
),
renderSummaryCard(
'Последний контакт',
latestEvent ? formatIsoShort(latestEvent.timestamp) : '—',
latestEvent ? latestEvent.title : 'Активность ещё не зафиксирована',
historySummary?.latest_event_at ? formatIsoShort(historySummary.latest_event_at) : (latestEvent ? formatIsoShort(latestEvent.timestamp) : '—'),
historySummary?.latest_event_title || latestEvent?.title || 'Активность ещё не зафиксирована',
),
].join('');
@@ -908,7 +1055,7 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-meta-grid">
<article class="customer-meta-card">
<div class="customer-meta-label">Основной номер</div>
<div class="customer-meta-value">${escapeHtml(phones[0] || 'Не указан')}</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">
@@ -942,7 +1089,7 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-kicker">Единая история клиента</div>
<h3>Все контакты в одной ленте</h3>
</div>
<div class="customer-history-caption">${escapeHtml(latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения')}</div>
<div class="customer-history-caption">${escapeHtml(historyStatusText)}</div>
</div>
<div class="customer-history-list">
${historyMarkup}
@@ -994,10 +1141,13 @@ function renderCustomerProfilePage() {
if (title) {
title.textContent = customer.display_name || 'Профиль клиента';
}
const historyPayload = customerHistoryPayload(customer.customer_id);
ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});
if (hint) {
const phones = customerPhones(customer);
hint.textContent = phones.length
? `Клиент ${customer.customer_id} • основной номер ${phones[0]}`
const primaryPhone = historyPayload?.summary?.primary_phone || phones[0] || '';
hint.textContent = primaryPhone
? `Клиент ${customer.customer_id} • основной номер ${primaryPhone}`
: `Клиент ${customer.customer_id} • омниканальный профиль`;
}
@@ -2037,6 +2187,7 @@ function applyTelegramCollections(threads, options = {}) {
}
renderTelegramWorkspace();
renderCustomerSpotlight();
renderUnifiedInbox();
}
async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) {
@@ -2588,6 +2739,9 @@ function renderWhatsappMessagesTimeline() {
}
function renderWhatsappWorkspace() {
if (!featureEnabled('whatsapp')) {
return;
}
ensureWhatsappSelection();
renderWhatsappChatList();
renderWhatsappContextPanel();
@@ -2634,6 +2788,9 @@ function renderWhatsappWorkspace() {
}
async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) {
if (!featureEnabled('whatsapp')) {
return [];
}
if (!threadId) {
state.whatsapp.selectedThreadSummary = null;
renderWhatsappWorkspace();
@@ -2655,6 +2812,9 @@ async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChat
}
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;
@@ -2692,6 +2852,9 @@ async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatI
}
async function loadWhatsappThreads(logResult = true, options = {}) {
if (!featureEnabled('whatsapp')) {
return [];
}
const {
preserveSelection = true,
preserveOnError = false,
@@ -2912,10 +3075,11 @@ async function sendWhatsappMessage() {
}
function startWhatsappPolling() {
if (state.whatsapp.pollTimer) {
window.clearInterval(state.whatsapp.pollTimer);
state.whatsapp.pollTimer = null;
if (!featureEnabled('whatsapp')) {
stopWhatsappPolling();
return;
}
stopWhatsappPolling();
state.whatsapp.pollTimer = window.setInterval(() => {
loadWhatsappThreads(false, {
preserveSelection: true,
@@ -2926,6 +3090,9 @@ function startWhatsappPolling() {
}
function handleWhatsappUiAction(event) {
if (!featureEnabled('whatsapp')) {
return;
}
const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно';
if (action === 'chats') {
return;
@@ -3034,8 +3201,8 @@ function operatorHashState(hash = window.location.hash) {
if (raw === 'whatsapp-page' || raw === 'whatsapp') {
return { view: 'whatsapp', anchor: '', customerId: '' };
}
if (raw === 'voice-debug' || raw === 'voice-debug-page') {
return { view: 'voice-debug', 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: '' };
@@ -3058,12 +3225,12 @@ function applyOperatorViewFromHash(options = {}) {
const { scroll = false } = options;
let { view, anchor, customerId } = operatorHashState();
const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`);
if (viewLink && viewLink.style.display === 'none') {
if (!isOperatorViewEnabled(view) || (viewLink && (viewLink.hidden || viewLink.style.display === 'none'))) {
view = 'workspace';
anchor = '';
if (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page') {
window.history.replaceState(null, '', '#workspace');
}
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;
@@ -3198,6 +3365,17 @@ async function checkGateway() {
}
}
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');
@@ -3304,6 +3482,7 @@ async function searchCustomers() {
state.customers.page = 1;
state.customers.selectedCustomerId = '';
renderCustomerList();
renderUnifiedInbox();
return;
}
if (!$('interactionCustomerId').value.trim()) {
@@ -3314,11 +3493,13 @@ async function searchCustomers() {
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 });
}
}
@@ -3413,6 +3594,321 @@ function renderInteractionBoard(items) {
`;
}
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 badges = [
renderUnifiedInboxBadge(channelLabel(item.channel), 'channel'),
item.queue_id ? renderUnifiedInboxBadge(item.queue_id, 'queue') : '',
item.assigned_to ? renderUnifiedInboxBadge(item.assigned_to, 'owner') : '',
].filter(Boolean);
return {
key: `interaction:${item.interaction_id}`,
kind: 'interaction',
bucket,
sortValue: unifiedInboxSortValue(item.updated_at, item.created_at),
title: item.subject || 'Обращение без темы',
subtitle: item.interaction_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(item.customer_id || '')}`,
`Статус: ${statusMeta(item.status).label}`,
`Обновлено: ${formatIsoShort(item.updated_at || item.created_at)}`,
],
customerId: item.customer_id || '',
interactionId: item.interaction_id,
};
}
function buildUnifiedInboxTelegramItem(thread) {
const bucket = unifiedInboxTelegramBucket(thread);
if (!bucket) {
return null;
}
const customerId = customerIdForInteractionId(thread.interaction_id);
const aiMeta = telegramThreadAiStateMeta(thread);
const unreadCount = telegramThreadUnreadCount(thread);
const badges = [
renderUnifiedInboxBadge('Telegram', 'channel'),
unreadCount > 0 ? renderUnifiedInboxBadge(`${unreadCount} new`, 'queue') : '',
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.tone) : '',
thread.claimed_by_user ? renderUnifiedInboxBadge(thread.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `telegram:${thread.thread_id}`,
kind: 'telegram',
bucket,
sortValue: unifiedInboxSortValue(thread.last_message_at, thread.updated_at, thread.created_at),
title: telegramThreadDisplayName(thread),
subtitle: telegramThreadSummary(thread),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
thread.last_message_preview ? `Последнее: ${thread.last_message_preview}` : 'Последнее сообщение пока не загружено',
`Статус: ${telegramThreadHeaderPresence(thread)}`,
],
customerId,
interactionId: thread.interaction_id || '',
threadId: thread.thread_id,
};
}
function buildUnifiedInboxCallItem(item) {
const aiMeta = voiceAiStatusMeta(item);
const interaction = interactionById(item.interaction_id || '');
const customerId = interaction?.customer_id || '';
const caller = item.caller_name || item.caller_number || item.call_id || 'Неизвестный абонент';
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '',
item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
key: `call:${item.call_id}`,
kind: 'call',
bucket: 'calls',
sortValue: unifiedInboxSortValue(item.started_at, item.updated_at),
title: caller,
subtitle: item.call_id,
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
],
customerId,
interactionId: item.interaction_id || '',
callId: item.call_id,
};
}
function buildUnifiedInboxCollections() {
const collections = {
new: [],
mine: [],
ai: [],
escalated: [],
calls: [],
};
const representedInteractionIds = new Set();
state.telegram.threads.forEach((thread) => {
if (thread?.interaction_id && thread.status !== 'closed') {
representedInteractionIds.add(thread.interaction_id);
}
const item = buildUnifiedInboxTelegramItem(thread);
if (item) {
collections[item.bucket].push(item);
}
});
state.liveCalls.items.forEach((call) => {
if (call?.interaction_id) {
representedInteractionIds.add(call.interaction_id);
}
const item = buildUnifiedInboxCallItem(call);
if (item) {
collections.calls.push(item);
}
});
state.interactions.forEach((item) => {
if (representedInteractionIds.has(item.interaction_id)) {
return;
}
const inboxItem = buildUnifiedInboxInteractionItem(item);
if (inboxItem) {
collections[inboxItem.bucket].push(inboxItem);
}
});
Object.values(collections).forEach((items) => {
items.sort((left, right) => right.sortValue - left.sortValue);
});
return collections;
}
function renderUnifiedInboxCard(item) {
const actions = [
`<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>` : '',
].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 select = $('liveCallIdSelect');
if (select) {
select.value = callId;
}
state.liveCalls.selectedCallId = callId;
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncLiveCallActionButtons();
const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null;
ensureVoiceAiSummaryLoaded(activeCall);
}
async function openUnifiedInboxItem(button) {
const kind = button.dataset.inboxKind || '';
const interactionId = button.dataset.interactionId || '';
const threadId = button.dataset.threadId || '';
const callId = button.dataset.callId || '';
const customerId = button.dataset.customerId || '';
if (kind === 'telegram') {
window.location.hash = '#telegram-page';
await selectTelegramThread(threadId);
return;
}
if (kind === 'call') {
focusLiveCall(callId);
window.location.hash = '#calls';
return;
}
if (customerId) {
$('interactionCustomerId').value = customerId;
}
window.location.hash = '#interactions';
log('Открыто обращение из единой очереди', { interaction_id: interactionId, customer_id: customerId || '-' });
}
function handleUnifiedInboxClick(event) {
const button = event.target.closest('[data-inbox-action]');
if (!button) {
return;
}
if (button.dataset.inboxAction === 'customer') {
openCustomerProfile(button.dataset.customerId || '');
return;
}
openUnifiedInboxItem(button).catch((err) => {
log('Не удалось открыть элемент единой очереди', { error: err.message });
});
}
async function refreshUnifiedInbox() {
await Promise.all([
searchCustomers(),
loadInteractions(),
loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }),
loadLiveCalls(false, { preserveStateOnError: true }),
selectedCustomer() ? ensureCustomerHistoryLoaded(selectedCustomer().customer_id, { force: true }) : Promise.resolve(null),
]);
log('Единая очередь обновлена', { total: Object.values(buildUnifiedInboxCollections()).reduce((count, items) => count + items.length, 0) });
}
function telephonyLabel(value) {
return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно';
}
@@ -4959,6 +5455,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) {
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
syncBrowserPhonePopupLifecycle();
renderCustomerSpotlight();
renderUnifiedInbox();
}
function applyClaimedLiveCall(item) {
@@ -5059,14 +5556,17 @@ async function loadInteractions() {
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 });
}
}
@@ -5171,13 +5671,18 @@ function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await searchCustomers();
await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
await loadLiveCalls(false);
await refreshUnifiedInbox();
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
log('Данные на экране обновлены');
});
$('refreshUnifiedInboxBtn').addEventListener('click', () => {
refreshUnifiedInbox().catch((err) => {
log('Не удалось обновить единую очередь', { error: err.message });
});
});
$('unifiedInboxBoard').addEventListener('click', handleUnifiedInboxClick);
$('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm());
$('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false));
$('createCustomerBtn').addEventListener('click', createCustomer);
@@ -5289,54 +5794,56 @@ function wire() {
event.preventDefault();
sendTelegramReply();
});
$('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();
});
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));
@@ -5383,6 +5890,7 @@ async function init() {
window.location.href = '/';
return;
}
await loadOperatorConfig();
wire();
applyOperatorViewFromHash();
$('defaultAssignee').value = DEMO_ASSIGNEE;
@@ -5390,7 +5898,10 @@ async function init() {
$('telegramEscalationQueue').value = DEMO_QUEUE;
updateSessionInfo();
syncLiveCallActionButtons();
renderWhatsappWorkspace();
renderUnifiedInbox();
if (featureEnabled('whatsapp')) {
renderWhatsappWorkspace();
}
renderTelegramWorkspace();
await Promise.all([checkGateway(), loadOidcConfig()]);
await fetchBrowserSoftphoneConfig();
@@ -5399,11 +5910,15 @@ async function init() {
await searchCustomers();
await loadInteractions();
await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true });
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
if (featureEnabled('whatsapp')) {
await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true });
}
await loadLiveCalls(false);
startLiveCallsPolling();
startTelegramPolling();
startWhatsappPolling();
if (featureEnabled('whatsapp')) {
startWhatsappPolling();
}
log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE });
}