Add customer profile shortcuts

This commit is contained in:
Yera All
2026-04-04 03:56:18 +05:00
parent ed76f89c9a
commit 14da622d36
3 changed files with 138 additions and 8 deletions
+14
View File
@@ -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")
+110 -8
View File
@@ -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 `
<div class="customer-history-actions">
${actions.map((action) => `
<button
class="btn ghost customer-history-link"
type="button"
data-customer-action="${escapeHtml(action.action)}"
data-thread-id="${escapeHtml(action.threadId || '')}"
data-call-id="${escapeHtml(action.callId || '')}"
data-interaction-id="${escapeHtml(action.interactionId || '')}"
>${escapeHtml(action.label)}</button>
`).join('')}
</div>
`;
}
function emptyCustomerSpotlightMarkup(title, description) {
return `
<div class="customer-spotlight-empty">
@@ -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 = {}) {
</div>
<div class="customer-history-copy">${escapeHtml(event.body)}</div>
${event.note ? `<div class="customer-history-note">${escapeHtml(event.note)}</div>` : ''}
${renderCustomerHistoryActions(event, historyActionContext)}
</div>
</article>
`;
@@ -1079,7 +1161,9 @@ function customerSpotlightMarkup(customer, options = {}) {
<div class="customer-profile-actions">
<button class="btn" type="button" data-customer-action="workspace">Открыть рабочий стол</button>
<button class="btn ghost" type="button" data-customer-action="interaction" data-interaction-id="${escapeHtml(latestInteraction?.interaction_id || '')}" ${latestInteraction ? '' : 'disabled'}>Открыть кейс</button>
<button class="btn ghost" type="button" data-customer-action="telegram" data-thread-id="${escapeHtml(primaryThread?.thread_id || '')}" ${primaryThread ? '' : 'disabled'}>Открыть Telegram</button>
<button class="btn ghost" type="button" data-customer-action="call" data-call-id="${escapeHtml(primaryCall?.call_id || '')}" ${primaryCall ? '' : 'disabled'}>Открыть звонок</button>
</div>
</section>
@@ -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 });
}
}
+14
View File
@@ -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;