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;