Improve operator CRM flows and UI
This commit is contained in:
+601
-86
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+24
-12
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track17-whatsapp-live1" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track19-unified-inbox1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -33,8 +33,8 @@
|
||||
<a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a>
|
||||
<a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a>
|
||||
<a class="nav-link" data-operator-view-link="telegram" href="#telegram-page"><span class="nav-bullet"></span>Telegram</a>
|
||||
<a class="nav-link" data-operator-view-link="whatsapp" href="#whatsapp-page"><span class="nav-bullet"></span>WhatsApp</a>
|
||||
<a class="nav-link" data-operator-view-link="voice-debug" data-roles="admin,operator" href="#voice-debug"><span class="nav-bullet"></span>Голосовая диагностика</a>
|
||||
<a class="nav-link" data-operator-view-link="whatsapp" data-feature="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
|
||||
<a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -137,13 +137,25 @@
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel reveal panel-span-full unified-inbox-panel" id="unifiedInbox">
|
||||
<div class="unified-inbox-head">
|
||||
<div>
|
||||
<h2>Единая очередь</h2>
|
||||
<p class="hint">Новые задачи, мои диалоги, AI handoff, эскалации и живые звонки в одном месте.</p>
|
||||
</div>
|
||||
<button id="refreshUnifiedInboxBtn" class="btn ghost" type="button">Обновить очередь</button>
|
||||
</div>
|
||||
<div id="unifiedInboxSummary" class="summary-grid"></div>
|
||||
<div id="unifiedInboxBoard" class="unified-inbox-scroll"></div>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal panel-span-full" id="interactions">
|
||||
<h2>Обращения</h2>
|
||||
<div class="inline-form">
|
||||
<select id="interactionChannel">
|
||||
<option value="voice">Голос</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="whatsapp" data-feature-option="whatsapp" hidden disabled>WhatsApp</option>
|
||||
<option value="webchat">Веб-чат</option>
|
||||
<option value="email">Электронная почта</option>
|
||||
</select>
|
||||
@@ -362,7 +374,7 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp">
|
||||
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp" data-feature="whatsapp" hidden>
|
||||
<article class="panel reveal panel-span-full whatsapp-panel" id="whatsappWorkspace">
|
||||
<div class="whatsapp-shell">
|
||||
<aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation">
|
||||
@@ -535,15 +547,15 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="voiceDebugView" class="operator-view hidden" data-operator-view="voice-debug">
|
||||
<section id="callsView" class="operator-view hidden" data-operator-view="calls">
|
||||
<section class="page-hero reveal customers-page-hero">
|
||||
<h1>Голосовая диагностика</h1>
|
||||
<p class="hint">Технический экран живых и недавних звонков с трассировкой действий. Основной операторский сценарий остаётся во всплывающем окне звонка.</p>
|
||||
<h1>Звонки</h1>
|
||||
<p class="hint">Живые и недавние звонки, AI-handoff и журнал действий в одном месте. Основной take over остаётся во всплывающем окне браузерного телефона.</p>
|
||||
</section>
|
||||
|
||||
<article class="panel reveal panel-span-full technical-panel" id="liveCalls">
|
||||
<h2>Голосовой мониторинг</h2>
|
||||
<p class="hint">Этот экран нужен только для диагностики. Для обработки звонка оператор использует всплывающее окно звонка в браузере.</p>
|
||||
<h2>Мониторинг звонков</h2>
|
||||
<p class="hint">Здесь видны текущий статус, недавние вызовы и трассировка действий. Ответ, перевод и завершение звонка остаются во всплывающем окне.</p>
|
||||
<div class="inline-form compact">
|
||||
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
|
||||
<button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button>
|
||||
@@ -551,7 +563,7 @@
|
||||
<div class="inline-form compact live-call-inspector">
|
||||
<select id="liveCallIdSelect"></select>
|
||||
</div>
|
||||
<p class="hint">Здесь остаётся технический слой живых и недавних звонков, а управление перенесено во всплывающее окно.</p>
|
||||
<p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
|
||||
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
|
||||
<div id="liveCallsTable" class="table"></div>
|
||||
<details class="details-block">
|
||||
@@ -603,6 +615,6 @@
|
||||
|
||||
<script src="/operator/assets/access-guards.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/sip-0.21.2.min.js?v=track16-voice-transcript1"></script>
|
||||
<script src="/operator/assets/app.js?v=track36-operator-ru-polish"></script>
|
||||
<script src="/operator/assets/app.js?v=track38-unified-inbox1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+32
-1
@@ -2968,6 +2968,32 @@ textarea::placeholder {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.unified-inbox-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.unified-inbox-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.unified-inbox-scroll {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.unified-inbox-board {
|
||||
grid-template-columns: repeat(5, minmax(240px, 1fr));
|
||||
min-width: 1320px;
|
||||
}
|
||||
|
||||
.unified-inbox-card {
|
||||
min-height: 248px;
|
||||
}
|
||||
|
||||
.live-calls-board {
|
||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||
}
|
||||
@@ -4967,7 +4993,8 @@ body.analytics-drilldown-open {
|
||||
.analytics-compare-head,
|
||||
.analytics-subhead,
|
||||
.analytics-chart-meta,
|
||||
.analytics-narrative-meta {
|
||||
.analytics-narrative-meta,
|
||||
.unified-inbox-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -4985,6 +5012,10 @@ body.analytics-drilldown-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.unified-inbox-board {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-spotlight-grid,
|
||||
.customer-spotlight-grid.page,
|
||||
.customer-profile-summary-grid,
|
||||
|
||||
Reference in New Issue
Block a user