diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py index 29b5110..2f92391 100644 --- a/tests/test_gateway_ui.py +++ b/tests/test_gateway_ui.py @@ -169,6 +169,20 @@ def test_operator_ui_loads_backend_customer_history_for_profile(): assert "ensureCustomerHistoryLoaded(customer.customer_id).catch(() => {});" in app_js +def test_operator_ui_customer_profile_supports_case_and_call_shortcuts(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + styles_css = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "styles.css").read_text(encoding="utf-8") + assert "function customerHistoryEventActions(event, context = {})" in app_js + assert "function renderCustomerHistoryActions(event, context = {})" in app_js + assert 'data-customer-action="interaction"' in app_js + assert 'data-customer-action="call"' in app_js + assert "Открыть кейс" in app_js + assert "Открыть звонок" in app_js + assert "focusLiveCall(callId);" in app_js + assert ".customer-history-actions {" in styles_css + assert ".customer-history-link {" in styles_css + + def test_operator_ui_contains_leads_table_front(): 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") diff --git a/ui/operator/app.js b/ui/operator/app.js index 9458c0b..bbf9a0e 100644 --- a/ui/operator/app.js +++ b/ui/operator/app.js @@ -866,6 +866,7 @@ function buildCustomerHistoryEvents(customer) { item.assigned_to ? `владелец ${item.assigned_to}` : '', item.queue_id ? `queue ${item.queue_id}` : '', ].filter(Boolean).join(' • '), + interaction_id: item.interaction_id, }); }); @@ -880,6 +881,8 @@ function buildCustomerHistoryEvents(customer) { thread.claimed_by_user ? `владелец ${thread.claimed_by_user}` : '', thread.status ? statusMeta(thread.status).label : '', ].filter(Boolean).join(' • '), + interaction_id: thread.interaction_id, + thread_id: thread.thread_id, }); }); @@ -897,6 +900,8 @@ function buildCustomerHistoryEvents(customer) { message.operator_user ? `оператор ${message.operator_user}` : '', message.delivery_status || '', ].filter(Boolean).join(' • '), + interaction_id: activeThread.interaction_id, + thread_id: activeThread.thread_id, }); }); } @@ -913,6 +918,8 @@ function buildCustomerHistoryEvents(customer) { item.operator_extension ? `внутр. ${item.operator_extension}` : '', item.claimed_by_user ? `владелец ${item.claimed_by_user}` : '', ].filter(Boolean).join(' • '), + interaction_id: item.interaction_id, + call_id: item.call_id, }); const aiMeta = voiceAiStatusMeta(item); if (aiMeta) { @@ -926,6 +933,8 @@ function buildCustomerHistoryEvents(customer) { item.voice_session_id ? `голос ${item.voice_session_id}` : '', item.ai_session_id ? `ai ${item.ai_session_id}` : '', ].filter(Boolean).join(' • '), + interaction_id: item.interaction_id, + call_id: item.call_id, }); } }); @@ -941,6 +950,72 @@ function buildCustomerHistoryEvents(customer) { .slice(0, 9); } +function latestItemByTime(items, fields = []) { + const source = Array.isArray(items) ? items.filter(Boolean) : []; + if (!source.length) { + return null; + } + return [...source].sort((left, right) => { + const rightTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(right?.[field])), 0); + const leftTime = fields.reduce((max, field) => Math.max(max, customerHistoryTimeValue(left?.[field])), 0); + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + return String(right?.interaction_id || right?.thread_id || right?.call_id || '').localeCompare( + String(left?.interaction_id || left?.thread_id || left?.call_id || ''), + ); + })[0] || null; +} + +function customerHistoryEventActions(event, context = {}) { + const threadIds = context.threadIds instanceof Set ? context.threadIds : new Set(); + const callIds = context.callIds instanceof Set ? context.callIds : new Set(); + const actions = []; + if (event?.thread_id && threadIds.has(event.thread_id)) { + actions.push({ + action: 'telegram', + label: 'К Telegram', + threadId: event.thread_id, + }); + } + if (event?.call_id && callIds.has(event.call_id)) { + actions.push({ + action: 'call', + label: 'К звонку', + callId: event.call_id, + }); + } + if (event?.interaction_id) { + actions.push({ + action: 'interaction', + label: 'К кейсу', + interactionId: event.interaction_id, + }); + } + return actions; +} + +function renderCustomerHistoryActions(event, context = {}) { + const actions = customerHistoryEventActions(event, context); + if (!actions.length) { + return ''; + } + return ` +
+ ${actions.map((action) => ` + + `).join('')} +
+ `; +} + function emptyCustomerSpotlightMarkup(title, description) { return `
@@ -959,6 +1034,8 @@ function customerSpotlightMarkup(customer, options = {}) { const score = customerLeadScore(customer, index); const historyPayload = customerHistoryPayload(customer.customer_id); const historySummary = historyPayload?.summary || null; + const currentThreads = customerTelegramThreads(customer); + const currentLiveCalls = customerLiveCalls(customer); const interactions = Array.isArray(historyPayload?.interactions) ? historyPayload.interactions : customerInteractions(customer); @@ -983,11 +1060,15 @@ function customerSpotlightMarkup(customer, options = {}) { ...(liveCalls.length ? ['voice'] : []), ])]; const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : []; + const latestInteraction = latestItemByTime(interactions, ['updated_at', 'created_at']); const primaryThread = historySummary?.primary_telegram_thread_id - ? 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; + ? currentThreads.find((item) => item.thread_id === historySummary.primary_telegram_thread_id) || null + : latestItemByTime(currentThreads, ['last_message_at', 'updated_at', 'created_at']); + const primaryCall = latestItemByTime(currentLiveCalls, ['last_transition_at', 'ended_at', 'connected_at', 'started_at', 'updated_at']); + const historyActionContext = { + threadIds: new Set(currentThreads.map((item) => item.thread_id).filter(Boolean)), + callIds: new Set(currentLiveCalls.map((item) => item.call_id).filter(Boolean)), + }; const historyStatusText = customerHistoryPending(customer.customer_id) ? 'Обновляем ленту клиента из backend...' : state.customers.historyErrorsById[customer.customer_id] @@ -1025,6 +1106,7 @@ function customerSpotlightMarkup(customer, options = {}) {
${escapeHtml(event.body)}
${event.note ? `
${escapeHtml(event.note)}
` : ''} + ${renderCustomerHistoryActions(event, historyActionContext)} `; @@ -1079,7 +1161,9 @@ function customerSpotlightMarkup(customer, options = {}) {
+ +
@@ -3849,15 +3933,16 @@ function focusLiveCall(callId) { if (!callId) { return; } + const activeCall = state.liveCalls.items.find((item) => item.call_id === callId) || null; + const relatedCall = activeCall || state.liveCalls.recentItems.find((item) => item.call_id === callId) || null; const select = $('liveCallIdSelect'); if (select) { - select.value = callId; + select.value = activeCall ? 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); + ensureVoiceAiSummaryLoaded(relatedCall); } async function openUnifiedInboxItem(button) { @@ -5347,7 +5432,7 @@ function updateLiveCallSelector(items) { } function renderLiveCallCard(item, { recent = false } = {}) { - const isSelected = !recent && item.call_id === state.liveCalls.selectedCallId; + const isSelected = item.call_id === state.liveCalls.selectedCallId; const caller = item.caller_number || item.caller_name || 'неизвестно'; const aiMeta = voiceAiStatusMeta(item); const badges = [ @@ -5664,6 +5749,23 @@ async function handleCustomerActionClick(event) { window.location.hash = '#telegram-page'; await selectTelegramThread(threadId); log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId }); + return; + } + if (button.dataset.customerAction === 'interaction') { + const interactionId = button.dataset.interactionId || ''; + $('interactionCustomerId').value = customer.customer_id; + window.location.hash = '#workspace'; + log('Открыт кейс клиента', { customer_id: customer.customer_id, interaction_id: interactionId || '-' }); + return; + } + if (button.dataset.customerAction === 'call') { + const callId = button.dataset.callId || ''; + if (!callId) { + return; + } + focusLiveCall(callId); + window.location.hash = '#calls'; + log('Открыт звонок клиента', { customer_id: customer.customer_id, call_id: callId }); } } diff --git a/ui/operator/styles.css b/ui/operator/styles.css index 50cae3e..e87ab0e 100644 --- a/ui/operator/styles.css +++ b/ui/operator/styles.css @@ -1291,6 +1291,20 @@ textarea::placeholder { font-size: 12px; } +.customer-history-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.customer-history-link { + min-height: 36px; + padding: 0 12px; + border-radius: 12px; + font-size: 12px; +} + .customer-history-event.voice .customer-history-mark { background: #e8f0ff; border-color: #cfdcff;