Add operator unified messenger MVP
This commit is contained in:
@@ -155,7 +155,8 @@ def test_operator_ui_contains_unified_inbox_panel():
|
||||
assert "function openUnifiedInboxItem(button)" in app_js
|
||||
assert "function handleUnifiedInboxClick(event)" in app_js
|
||||
assert "function refreshUnifiedInbox()" in app_js
|
||||
assert "window.location.hash = '#telegram-page';" in app_js
|
||||
assert "window.location.hash = '#messages';" in app_js
|
||||
assert "await selectMessengerConversation(messengerConversationId('telegram', threadId));" in app_js
|
||||
assert "window.location.hash = '#calls';" in app_js
|
||||
assert "window.location.hash = '#interactions';" in app_js
|
||||
|
||||
@@ -210,7 +211,8 @@ def test_operator_ui_contains_telegram_workspace_page():
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'id="telegramView"' in index_html
|
||||
assert 'data-operator-view="telegram"' in index_html
|
||||
assert 'data-operator-view-link="telegram"' in index_html
|
||||
assert 'data-operator-view-link="telegram"' not in index_html
|
||||
assert 'data-debug-channel="telegram"' in index_html
|
||||
assert 'href="#telegram-page"' in index_html
|
||||
assert 'id="loadTelegramThreadsBtn"' in index_html
|
||||
assert 'id="telegramThreadsList"' in index_html
|
||||
@@ -235,6 +237,37 @@ def test_operator_ui_contains_telegram_workspace_page():
|
||||
assert "function sendTelegramReply" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_contains_unified_messenger_mvp():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
assert 'data-operator-view-link="messages"' in index_html
|
||||
assert 'href="#messages"' in index_html
|
||||
assert 'id="messagesView"' in index_html
|
||||
assert 'data-operator-view="messages"' in index_html
|
||||
assert 'id="messengerConversationList"' in index_html
|
||||
assert 'id="messengerMessagesTimeline"' in index_html
|
||||
assert 'id="messengerReplyText"' in index_html
|
||||
assert 'id="messengerSendReplyBtn"' in index_html
|
||||
assert 'id="messengerClaimBtn"' in index_html
|
||||
assert 'id="messengerCloseBtn"' in index_html
|
||||
assert 'id="messengerOpenProfileBtn"' in index_html
|
||||
assert 'id="messengerContextAiSummary"' in index_html
|
||||
assert 'Telegram' in index_html
|
||||
assert "messenger: {" in app_js
|
||||
assert "function messengerConversationFromTelegramThread(thread)" in app_js
|
||||
assert "function buildMessengerConversations()" in app_js
|
||||
assert "function renderMessengerWorkspace()" in app_js
|
||||
assert "function claimMessengerConversation()" in app_js
|
||||
assert "function sendMessengerReply()" in app_js
|
||||
assert "function closeMessengerConversation()" in app_js
|
||||
assert "api('telegram', 'integrations/telegram/threads')" in app_js
|
||||
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/messages" in app_js
|
||||
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/claim" in app_js
|
||||
assert "integrations/telegram/threads/${encodeURIComponent(conversation.thread_id)}/close" in app_js
|
||||
assert "setInterval(() => {" in app_js
|
||||
assert "}, 5000);" in app_js
|
||||
|
||||
|
||||
def test_operator_ui_contains_live_call_control_block():
|
||||
app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8")
|
||||
index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8")
|
||||
@@ -355,8 +388,8 @@ def test_operator_ui_surfaces_voice_name_state_across_call_views():
|
||||
assert ".micro-badge.name-confirmed {" in styles_css
|
||||
assert ".micro-badge.name-followup {" in styles_css
|
||||
assert ".micro-badge.name-missing {" in styles_css
|
||||
assert "/operator/assets/styles.css?v=track23-customer-profile-name-meta1" in index_html
|
||||
assert "/operator/assets/app.js?v=track42-customer-profile-name-meta1" in index_html
|
||||
assert "/operator/assets/styles.css?v=track24-messenger-mvp1" in index_html
|
||||
assert "/operator/assets/app.js?v=track43-messenger-mvp1" in index_html
|
||||
|
||||
|
||||
def test_operator_ui_hides_whatsapp_behind_feature_flag():
|
||||
|
||||
+712
-5
@@ -62,6 +62,17 @@ const state = {
|
||||
pendingAction: '',
|
||||
pollTimer: null,
|
||||
},
|
||||
messenger: {
|
||||
conversations: [],
|
||||
selectedConversationId: '',
|
||||
messages: [],
|
||||
selectedSummary: null,
|
||||
activeFilter: 'all',
|
||||
composerText: '',
|
||||
backendError: '',
|
||||
pendingAction: '',
|
||||
pollTimer: null,
|
||||
},
|
||||
whatsapp: {
|
||||
chats: [],
|
||||
selectedChatId: '',
|
||||
@@ -377,6 +388,13 @@ const UNIFIED_INBOX_COLUMNS = [
|
||||
{ key: 'calls', title: 'Активные звонки', emptyMessage: 'Активных звонков сейчас нет.' },
|
||||
];
|
||||
|
||||
const MESSENGER_FILTERS = {
|
||||
all: 'Все',
|
||||
new: 'Новые',
|
||||
mine: 'Мои',
|
||||
ai: 'AI handoff',
|
||||
};
|
||||
|
||||
const LIVE_TELEPHONY_LABELS = {
|
||||
ringing: 'Звонит',
|
||||
claimed: 'Взято',
|
||||
@@ -400,6 +418,7 @@ const VOICE_AI_STATE_META = {
|
||||
|
||||
const OPERATOR_VIEW_IDS = {
|
||||
workspace: 'workspaceView',
|
||||
messages: 'messagesView',
|
||||
customers: 'customersView',
|
||||
'customer-profile': 'customerProfileView',
|
||||
telegram: 'telegramView',
|
||||
@@ -553,6 +572,7 @@ function logout() {
|
||||
window.clearInterval(state.telegram.pollTimer);
|
||||
state.telegram.pollTimer = null;
|
||||
}
|
||||
stopMessengerPolling();
|
||||
stopWhatsappPolling();
|
||||
clearStoredSession();
|
||||
window.location.href = '/';
|
||||
@@ -2412,7 +2432,9 @@ function applyTelegramCollections(threads, options = {}) {
|
||||
if (!state.telegram.selectedThreadId) {
|
||||
state.telegram.selectedThreadSummary = null;
|
||||
}
|
||||
syncMessengerConversations({ preserveSelection: true });
|
||||
renderTelegramWorkspace();
|
||||
renderMessengerWorkspace();
|
||||
renderCustomerSpotlight();
|
||||
renderUnifiedInbox();
|
||||
}
|
||||
@@ -2652,10 +2674,628 @@ function startTelegramPolling() {
|
||||
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;
|
||||
}
|
||||
@@ -3422,6 +4062,9 @@ function operatorHashState(hash = window.location.hash) {
|
||||
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: '' };
|
||||
}
|
||||
@@ -3470,6 +4113,16 @@ function applyOperatorViewFromHash(options = {}) {
|
||||
$(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(() => {
|
||||
@@ -4129,8 +4782,8 @@ async function openUnifiedInboxItem(button) {
|
||||
const customerId = button.dataset.customerId || '';
|
||||
|
||||
if (kind === 'telegram') {
|
||||
window.location.hash = '#telegram-page';
|
||||
await selectTelegramThread(threadId);
|
||||
window.location.hash = '#messages';
|
||||
await selectMessengerConversation(messengerConversationId('telegram', threadId));
|
||||
return;
|
||||
}
|
||||
if (kind === 'call') {
|
||||
@@ -6395,9 +7048,9 @@ async function handleCustomerActionClick(event) {
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
window.location.hash = '#telegram-page';
|
||||
await selectTelegramThread(threadId);
|
||||
log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId });
|
||||
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') {
|
||||
@@ -6455,6 +7108,9 @@ function wire() {
|
||||
$('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 });
|
||||
}
|
||||
@@ -6505,6 +7161,56 @@ function wire() {
|
||||
$('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 || '';
|
||||
@@ -6723,6 +7429,7 @@ async function init() {
|
||||
updateSessionInfo();
|
||||
syncLiveCallActionButtons();
|
||||
renderUnifiedInbox();
|
||||
renderMessengerWorkspace();
|
||||
if (featureEnabled('whatsapp')) {
|
||||
renderWhatsappWorkspace();
|
||||
}
|
||||
|
||||
+119
-3
@@ -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=track23-customer-profile-name-meta1" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track24-messenger-mvp1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -31,8 +31,9 @@
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Страницы</div>
|
||||
<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="messages" href="#messages"><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-debug-channel="telegram" href="#telegram-page" hidden><span class="nav-bullet"></span>Telegram</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>
|
||||
@@ -176,6 +177,121 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="messagesView" class="operator-view hidden" data-operator-view="messages">
|
||||
<article class="panel reveal panel-span-full messenger-panel" id="messengerWorkspace">
|
||||
<div class="messenger-layout">
|
||||
<aside class="messenger-list-pane">
|
||||
<div class="messenger-pane-head">
|
||||
<div>
|
||||
<p class="eyebrow">Unified messenger</p>
|
||||
<h2>Сообщения</h2>
|
||||
<p class="hint">Единое окно для Telegram сейчас и WhatsApp позже.</p>
|
||||
</div>
|
||||
<button id="messengerRefreshBtn" class="btn ghost" type="button">Повторить</button>
|
||||
</div>
|
||||
<div class="messenger-filters" aria-label="Фильтры сообщений">
|
||||
<button class="messenger-filter active" data-messenger-filter="all" type="button">Все</button>
|
||||
<button class="messenger-filter" data-messenger-filter="new" type="button">Новые</button>
|
||||
<button class="messenger-filter" data-messenger-filter="mine" type="button">Мои</button>
|
||||
<button class="messenger-filter" data-messenger-filter="ai" type="button">AI handoff</button>
|
||||
</div>
|
||||
<div id="messengerConversationList" class="messenger-conversation-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="messenger-chat-pane">
|
||||
<header class="messenger-chat-header">
|
||||
<div class="messenger-chat-identity">
|
||||
<div id="messengerConversationAvatar" class="telegram-thread-avatar">TG</div>
|
||||
<div class="messenger-chat-copy">
|
||||
<div id="messengerConversationTitle" class="messenger-chat-title">Выберите диалог</div>
|
||||
<div id="messengerConversationMeta" class="messenger-chat-meta">Выберите диалог слева.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messenger-chat-actions">
|
||||
<span id="messengerChannelBadge" class="messenger-channel-badge">Telegram</span>
|
||||
<button id="messengerClaimBtn" class="btn ghost" type="button">Взять в работу</button>
|
||||
<button id="messengerCloseBtn" class="btn ghost" type="button">Закрыть</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="messengerAiSummaryCard" class="telegram-ai-summary messenger-ai-summary hidden">
|
||||
<div class="telegram-ai-summary-head">
|
||||
<div>
|
||||
<div class="telegram-ai-summary-title">AI summary</div>
|
||||
<div class="telegram-ai-summary-caption">Короткий контекст перед ручным ответом.</div>
|
||||
</div>
|
||||
<div class="telegram-ai-summary-side">
|
||||
<div id="messengerAiSummaryStatus"></div>
|
||||
<div id="messengerAiSummaryMeta" class="telegram-ai-summary-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="messengerAiSummaryContent" class="telegram-ai-summary-grid"></div>
|
||||
</section>
|
||||
|
||||
<div id="messengerBackendError" class="messenger-error hidden">
|
||||
<span>Backend сообщений недоступен.</span>
|
||||
<button id="messengerRetryBtn" class="btn ghost" type="button">Повторить</button>
|
||||
</div>
|
||||
|
||||
<div class="messenger-chat-surface">
|
||||
<div id="messengerMessagesTimeline" class="telegram-message-timeline messenger-message-timeline"></div>
|
||||
</div>
|
||||
|
||||
<footer class="messenger-composer-wrap">
|
||||
<div class="telegram-composer-shell messenger-composer-shell">
|
||||
<textarea id="messengerReplyText" placeholder="Написать ответ клиенту..."></textarea>
|
||||
<button id="messengerSendReplyBtn" class="telegram-send-btn" type="button" aria-label="Отправить">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 3 3.9 10.09a1 1 0 0 0 .06 1.87l6.5 2.27 2.27 6.5a1 1 0 0 0 1.87.06L21 3Z" />
|
||||
<path d="M10.5 14.5 21 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="messengerComposerHint" class="telegram-composer-hint">Ответ доступен после выбора и принятия диалога в работу.</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="messenger-context-pane">
|
||||
<div class="messenger-context-card">
|
||||
<p class="eyebrow">Клиент</p>
|
||||
<h3 id="messengerContextName">Диалог не выбран</h3>
|
||||
<dl class="messenger-context-list">
|
||||
<div>
|
||||
<dt>Канал</dt>
|
||||
<dd id="messengerContextChannel">Telegram</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Статус</dt>
|
||||
<dd id="messengerContextStatus">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Клиент</dt>
|
||||
<dd id="messengerContextCustomer">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Обращение</dt>
|
||||
<dd id="messengerContextInteraction">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button id="messengerOpenProfileBtn" class="btn" type="button">Открыть профиль</button>
|
||||
</div>
|
||||
<div class="messenger-context-card compact">
|
||||
<p class="eyebrow">Последнее сообщение</p>
|
||||
<p id="messengerContextPreview" class="messenger-context-preview">Выберите диалог слева.</p>
|
||||
</div>
|
||||
<div class="messenger-context-card compact">
|
||||
<p class="eyebrow">AI summary</p>
|
||||
<p id="messengerContextAiSummary" class="messenger-context-preview">Сводка появится после AI handoff.</p>
|
||||
</div>
|
||||
<div class="messenger-context-card compact">
|
||||
<p class="eyebrow">Быстрые действия</p>
|
||||
<button id="messengerContextOpenProfileBtn" class="btn ghost" type="button">Профиль клиента</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="customersView" class="operator-view hidden" data-operator-view="customers">
|
||||
<section class="page-hero reveal customers-page-hero">
|
||||
<h1>Список лидов</h1>
|
||||
@@ -640,6 +756,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=track42-customer-profile-name-meta1"></script>
|
||||
<script src="/operator/assets/app.js?v=track43-messenger-mvp1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1426,6 +1426,338 @@ textarea::placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messenger-panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #eef3f8;
|
||||
height: calc(100dvh - 152px);
|
||||
min-height: 640px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.messenger-layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr) 320px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messenger-list-pane,
|
||||
.messenger-context-pane {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messenger-list-pane {
|
||||
border-right: 1px solid #dde5ef;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messenger-context-pane {
|
||||
border-left: 1px solid #dde5ef;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.messenger-pane-head {
|
||||
padding: 18px 18px 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.messenger-pane-head h2 {
|
||||
margin: 3px 0 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.messenger-filters {
|
||||
padding: 0 14px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.messenger-filter {
|
||||
border: 1px solid #dbe5f2;
|
||||
border-radius: 999px;
|
||||
background: #f7f9fc;
|
||||
color: #536179;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messenger-filter.active {
|
||||
background: #10213d;
|
||||
border-color: #10213d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.messenger-conversation-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.messenger-empty-state {
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
color: #667085;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.messenger-conversation-card {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
background: transparent;
|
||||
color: #10213d;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.messenger-conversation-card:hover {
|
||||
background: #f6f9fd;
|
||||
}
|
||||
|
||||
.messenger-conversation-card.selected {
|
||||
background: linear-gradient(135deg, #eaf2ff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.messenger-conversation-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.messenger-conversation-head,
|
||||
.messenger-conversation-preview-row,
|
||||
.messenger-conversation-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.messenger-conversation-head,
|
||||
.messenger-conversation-preview-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.messenger-conversation-name,
|
||||
.messenger-conversation-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messenger-conversation-name {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.messenger-conversation-time {
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.messenger-conversation-preview {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.messenger-conversation-badges {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.messenger-channel-badge,
|
||||
.messenger-status-badge {
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.messenger-channel-badge {
|
||||
background: #e6f4fb;
|
||||
color: #0088cc;
|
||||
}
|
||||
|
||||
.messenger-channel-badge.compact {
|
||||
min-height: 21px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.messenger-status-badge {
|
||||
background: #edf2ff;
|
||||
color: #445274;
|
||||
}
|
||||
|
||||
.messenger-status-badge.owner {
|
||||
background: #ecfdf3;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.messenger-chat-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at 12% 12%, rgba(51, 102, 232, 0.08), transparent 24%),
|
||||
#e7edf5;
|
||||
}
|
||||
|
||||
.messenger-chat-header {
|
||||
min-height: 78px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid rgba(202, 212, 226, 0.8);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.messenger-chat-identity,
|
||||
.messenger-chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messenger-chat-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messenger-chat-title {
|
||||
font-family: "Manrope", "IBM Plex Sans", sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.messenger-chat-meta {
|
||||
margin-top: 3px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.messenger-ai-summary {
|
||||
margin: 12px 16px 0;
|
||||
}
|
||||
|
||||
.messenger-error {
|
||||
margin: 12px 16px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 14px;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.messenger-chat-surface {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messenger-message-timeline {
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.18)),
|
||||
repeating-linear-gradient(45deg, rgba(51, 102, 232, 0.04) 0 10px, transparent 10px 20px);
|
||||
}
|
||||
|
||||
.messenger-composer-wrap {
|
||||
padding: 0 16px 16px;
|
||||
background: rgba(231, 237, 245, 0.84);
|
||||
}
|
||||
|
||||
.messenger-composer-shell {
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
background: #fff;
|
||||
border: 1px solid #dce5f0;
|
||||
box-shadow: 0 12px 30px rgba(16, 33, 61, 0.08);
|
||||
}
|
||||
|
||||
.messenger-composer-shell textarea:disabled {
|
||||
background: #f6f8fb;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.messenger-context-card {
|
||||
border: 1px solid #dbe5f2;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 28px rgba(16, 33, 61, 0.05);
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.messenger-context-card h3 {
|
||||
margin: 4px 0 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.messenger-context-card.compact {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.messenger-context-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.messenger-context-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.messenger-context-list dt {
|
||||
color: #8390a3;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.messenger-context-list dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #10213d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.messenger-context-preview {
|
||||
margin: 4px 0 0;
|
||||
color: #536179;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.telegram-panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -5332,10 +5664,35 @@ body.analytics-drilldown-open {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.messenger-layout,
|
||||
.telegram-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.messenger-list-pane,
|
||||
.messenger-context-pane {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.messenger-list-pane {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.messenger-context-pane {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.messenger-chat-header,
|
||||
.messenger-chat-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.messenger-chat-surface {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.telegram-threads-pane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user