Add voice name flow controls and analytics

This commit is contained in:
Yera All
2026-04-05 03:26:18 +05:00
parent d15fcf7129
commit d959c2b2f0
31 changed files with 6410 additions and 152 deletions
+484 -16
View File
@@ -22,6 +22,15 @@
pendingAction: '',
aiSummaries: {},
aiSummaryPending: {},
nameEditor: {
open: false,
mode: 'panel',
callId: '',
customerId: '',
draft: '',
saving: false,
error: '',
},
},
customers: {
items: [],
@@ -3796,14 +3805,17 @@ function buildUnifiedInboxTelegramItem(thread) {
}
function buildUnifiedInboxCallItem(item) {
const summary = voiceSummaryForItem(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 caller = voiceCustomerDisplayName(item, summary);
const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status);
const badges = [
renderUnifiedInboxBadge('Голос', 'channel'),
renderUnifiedInboxBadge(telephonyLabel(item.telephony_status), 'assignee'),
aiMeta ? renderUnifiedInboxBadge(aiMeta.label, aiMeta.className) : '',
nameStatusMeta ? renderUnifiedInboxBadge(nameStatusMeta.shortLabel, nameStatusMeta.className) : '',
item.claimed_by_user ? renderUnifiedInboxBadge(item.claimed_by_user, 'owner') : '',
].filter(Boolean);
return {
@@ -3812,14 +3824,16 @@ function buildUnifiedInboxCallItem(item) {
bucket: 'calls',
sortValue: unifiedInboxSortValue(item.started_at, item.updated_at),
title: caller,
subtitle: item.call_id,
subtitle: voiceCustomerCallSubtitle(item),
badges,
metaLines: [
`Клиент: ${customerDisplayName(customerId)}`,
`Контакт: ${voiceCustomerCallSubtitle(item)}`,
summary ? `Имя: ${voiceCustomerNameStateLine(summary) || 'без подтверждения'}` : '',
`Обращение: ${item.interaction_id || 'не найдено'}`,
`Начат: ${formatIsoShort(item.started_at || item.connected_at)}`,
item.ai_handoff_reason ? `AI: ${item.ai_handoff_reason}` : `Статус: ${telephonyLabel(item.telephony_status)}`,
],
].filter(Boolean),
customerId,
interactionId: item.interaction_id || '',
callId: item.call_id,
@@ -3945,6 +3959,27 @@ function focusLiveCall(callId) {
ensureVoiceAiSummaryLoaded(relatedCall);
}
function handleLiveCallTableClick(event) {
const button = event.target.closest('[data-live-call-action]');
if (!button) {
return;
}
const callId = button.dataset.callId || '';
if (!callId) {
return;
}
if (button.dataset.liveCallAction === 'edit-name') {
openLiveCallNameEditor(callId, 'panel');
return;
}
if (button.dataset.liveCallAction === 'customer') {
const customerId = button.dataset.customerId || '';
if (customerId) {
openCustomerProfile(customerId);
}
}
}
async function openUnifiedInboxItem(button) {
const kind = button.dataset.inboxKind || '';
const interactionId = button.dataset.interactionId || '';
@@ -4008,6 +4043,223 @@ function selectedLiveCallItem() {
return state.liveCalls.items.find((item) => item.call_id === callId) || null;
}
function liveCallById(callId) {
if (!callId) {
return null;
}
return state.liveCalls.items.find((item) => item.call_id === callId)
|| state.liveCalls.recentItems.find((item) => item.call_id === callId)
|| null;
}
function liveCallCustomerId(item) {
return interactionById(item?.interaction_id || '')?.customer_id || '';
}
function closeLiveCallNameEditor() {
state.liveCalls.nameEditor.open = false;
state.liveCalls.nameEditor.callId = '';
state.liveCalls.nameEditor.customerId = '';
state.liveCalls.nameEditor.draft = '';
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = '';
updateLiveCallNameEditorsUi();
}
function openLiveCallNameEditor(callId, mode = 'panel') {
const item = liveCallById(callId);
const customerId = liveCallCustomerId(item);
if (!item || !customerId) {
log('Нельзя исправить имя: звонок не привязан к клиенту', { call_id: callId || '-' });
return;
}
state.liveCalls.nameEditor.open = true;
state.liveCalls.nameEditor.mode = mode;
state.liveCalls.nameEditor.callId = item.call_id;
state.liveCalls.nameEditor.customerId = customerId;
state.liveCalls.nameEditor.draft = voiceCustomerDisplayName(item);
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = '';
if (mode === 'panel') {
focusLiveCall(item.call_id);
}
updateLiveCallNameEditorsUi();
}
function applyCustomerNamePatchLocally({ customerId, callId, displayName }) {
const normalizedName = String(displayName || '').trim();
if (!customerId || !normalizedName) {
return;
}
state.customers.items = state.customers.items.map((item) => (
item.customer_id === customerId ? { ...item, display_name: normalizedName } : item
));
const history = state.customers.historyById[customerId];
if (history?.customer) {
history.customer.display_name = normalizedName;
}
const interactionIds = new Set(
state.interactions
.filter((item) => item.customer_id === customerId)
.map((item) => item.interaction_id)
.filter(Boolean),
);
const patchCall = (item) => {
if (!item) {
return item;
}
const matchesCall = callId && item.call_id === callId;
const matchesCustomer = interactionIds.has(item.interaction_id);
if (!matchesCall && !matchesCustomer) {
return item;
}
return {
...item,
caller_name: normalizedName,
};
};
state.liveCalls.items = state.liveCalls.items.map(patchCall);
state.liveCalls.recentItems = state.liveCalls.recentItems.map(patchCall);
if (history?.live_calls) {
history.live_calls = history.live_calls.map((item) => ({
...item,
caller_name: interactionIds.has(item.interaction_id) || item.call_id === callId ? normalizedName : item.caller_name,
}));
}
const affectedCallIds = new Set(
[...state.liveCalls.items, ...state.liveCalls.recentItems]
.filter((item) => interactionIds.has(item.interaction_id) || item.call_id === callId)
.map((item) => item.call_id)
.filter(Boolean),
);
affectedCallIds.forEach((id) => {
if (!state.liveCalls.aiSummaries[id]) {
return;
}
state.liveCalls.aiSummaries[id] = {
...state.liveCalls.aiSummaries[id],
customer_name_status: 'name_obtained',
customer_name_value: normalizedName,
customer_name_source: 'manual',
};
});
renderCustomerList();
refreshVoiceSummaryDependentViews();
}
async function saveLiveCallCustomerName() {
const { customerId, callId } = state.liveCalls.nameEditor;
const displayName = String(state.liveCalls.nameEditor.draft || '').trim().replace(/\s+/g, ' ');
if (!customerId || !callId) {
return;
}
if (displayName.length < 2) {
state.liveCalls.nameEditor.error = 'Введите имя клиента минимум из 2 символов.';
updateLiveCallNameEditorsUi();
return;
}
state.liveCalls.nameEditor.saving = true;
state.liveCalls.nameEditor.error = '';
updateLiveCallNameEditorsUi();
try {
const data = await api('customer', `customers/${encodeURIComponent(customerId)}`, {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName, source: 'manual' }),
});
applyCustomerNamePatchLocally({
customerId,
callId,
displayName: data?.display_name || displayName,
});
closeLiveCallNameEditor();
ensureCustomerHistoryLoaded(customerId, { force: true }).catch(() => {});
loadVoiceAiSummary(callId, { force: true, silent: true }).catch(() => {});
refreshLiveCallsInBackground();
log('Имя клиента обновлено оператором', { customer_id: customerId, call_id: callId, name: data?.display_name || displayName });
} catch (err) {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
}
}
function liveCallNameEditorMeta(item, customerId) {
if (!item || !customerId) {
return 'Выберите звонок, связанный с клиентом, чтобы исправить имя.';
}
return `${voiceCustomerCallSubtitle(item)} • клиент ${customerId}`;
}
function updateLiveCallNameEditorsUi() {
const panel = $('liveCallNameEditor');
const panelInput = $('liveCallNameInput');
const panelMeta = $('liveCallNameEditorMeta');
const panelStatus = $('liveCallNameStatus');
const panelSave = $('liveCallNameSaveBtn');
const panelCancel = $('liveCallNameCancelBtn');
const popup = $('browserPhoneNameEditor');
const popupInput = $('browserPhoneNameInput');
const popupMeta = $('browserPhoneNameHint');
const popupStatus = $('browserPhoneNameStatus');
const popupSave = $('browserPhoneNameSaveBtn');
const popupCancel = $('browserPhoneNameCancelBtn');
const editor = state.liveCalls.nameEditor;
const item = liveCallById(editor.callId);
const meta = liveCallNameEditorMeta(item, editor.customerId);
const statusText = editor.error || (editor.saving ? 'Сохраняем имя клиента...' : '');
if (panel) {
const panelVisible = editor.open && editor.mode === 'panel';
panel.classList.toggle('hidden', !panelVisible);
if (panelInput) {
if (panelInput.value !== editor.draft) {
panelInput.value = editor.draft;
}
panelInput.disabled = editor.saving;
}
if (panelMeta) {
panelMeta.textContent = meta;
}
if (panelStatus) {
panelStatus.textContent = statusText;
panelStatus.classList.toggle('error', Boolean(editor.error));
}
if (panelSave) {
panelSave.disabled = editor.saving || !editor.customerId;
panelSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя';
}
if (panelCancel) {
panelCancel.disabled = editor.saving;
}
}
if (popup) {
const popupVisible = editor.open && editor.mode === 'popup';
popup.classList.toggle('hidden', !popupVisible);
if (popupInput) {
if (popupInput.value !== editor.draft) {
popupInput.value = editor.draft;
}
popupInput.disabled = editor.saving;
}
if (popupMeta) {
popupMeta.textContent = meta;
}
if (popupStatus) {
popupStatus.textContent = statusText;
popupStatus.classList.toggle('hidden', !statusText);
popupStatus.classList.toggle('error', Boolean(editor.error));
}
if (popupSave) {
popupSave.disabled = editor.saving || !editor.customerId;
popupSave.textContent = editor.saving ? 'Сохраняем...' : 'Сохранить имя';
}
if (popupCancel) {
popupCancel.disabled = editor.saving;
}
}
}
function isClaimableLiveCall(item) {
if (!item) {
return false;
@@ -4088,7 +4340,7 @@ async function loadVoiceAiSummary(callId, options = {}) {
return null;
} finally {
delete state.liveCalls.aiSummaryPending[callId];
updateBrowserPhoneUi();
refreshVoiceSummaryDependentViews();
}
}
@@ -4117,6 +4369,135 @@ function renderVoiceAiSummaryField(label, value) {
`;
}
function formatVoiceCustomerNameStatus(status) {
switch (String(status || '').trim()) {
case 'name_obtained':
return 'Подтверждено';
case 'name_followup_required':
return 'Нужно уточнить';
case 'name_not_obtained':
return 'Не подтверждено';
default:
return String(status || '').trim();
}
}
function voiceCustomerNameStatusMeta(status) {
switch (String(status || '').trim()) {
case 'name_obtained':
return { label: 'Имя подтверждено', shortLabel: 'Имя подтверждено', className: 'name-confirmed' };
case 'name_followup_required':
return { label: 'Имя нужно уточнить', shortLabel: 'Уточнить имя', className: 'name-followup' };
case 'name_not_obtained':
return { label: 'Имя не подтверждено', shortLabel: 'Без подтверждения', className: 'name-missing' };
default: {
const fallback = String(status || '').trim();
return fallback ? { label: fallback, shortLabel: fallback, className: 'name-missing' } : null;
}
}
}
function formatVoiceCustomerNameSource(source) {
switch (String(source || '').trim()) {
case 'known_customer':
return 'Из карточки клиента';
case 'voice_start':
return 'Стартовый этап';
case 'voice_followup':
return 'Уточнил AI';
case 'manual':
return 'Оператор';
case 'external_identity':
return 'Из voice identity';
default:
return String(source || '').trim();
}
}
function formatVoiceStartLanguage(language) {
switch (String(language || '').trim()) {
case 'ru':
return 'Русский';
case 'kz':
return 'Қазақша';
default:
return String(language || '').trim();
}
}
function voiceSummaryForItem(item) {
const callId = String(item?.call_id || '').trim();
return callId ? voiceAiSummaryForCall(callId) : null;
}
function voiceCustomerDisplayName(item, summary = voiceSummaryForItem(item)) {
const preferredName = String(summary?.customer_name_value || '').trim();
if (preferredName) {
return preferredName;
}
const callerName = String(item?.caller_name || '').trim();
if (callerName) {
return callerName;
}
const callerNumber = String(item?.caller_number || '').trim();
if (callerNumber) {
return callerNumber;
}
const callId = String(item?.call_id || '').trim();
if (callId) {
return callId;
}
return 'Неизвестный абонент';
}
function voiceCustomerCallSubtitle(item) {
const parts = [
String(item?.caller_number || '').trim(),
String(item?.call_id || '').trim(),
].filter(Boolean);
return parts.join(' • ') || 'Звонок без номера';
}
function voiceCustomerNameStateLine(summary) {
if (!summary) {
return '';
}
const parts = [];
const statusMeta = voiceCustomerNameStatusMeta(summary.customer_name_status);
if (statusMeta?.label) {
parts.push(statusMeta.label);
}
const sourceLabel = formatVoiceCustomerNameSource(summary.customer_name_source);
if (sourceLabel) {
parts.push(sourceLabel);
}
const languageLabel = formatVoiceStartLanguage(summary.voice_start_language);
if (languageLabel) {
parts.push(languageLabel);
}
return parts.join(' • ');
}
function voiceCustomerIncomingMeta(item, summary = voiceSummaryForItem(item)) {
const parts = [];
const callerNumber = String(item?.caller_number || '').trim();
if (callerNumber) {
parts.push(`Номер: ${callerNumber}`);
}
const nameState = voiceCustomerNameStateLine(summary);
if (nameState) {
parts.push(nameState);
}
return parts.join(' • ') || `call_id: ${String(item?.call_id || '—').trim() || '—'}`;
}
function refreshVoiceSummaryDependentViews() {
updateLiveCallSelector(state.liveCalls.items);
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
renderUnifiedInbox();
updateBrowserPhoneUi();
}
function renderVoiceAiTranscript(summary) {
const segments = Array.isArray(summary?.transcript_segments)
? summary.transcript_segments.filter((segment) => segment && String(segment.text || '').trim())
@@ -4183,6 +4564,10 @@ function renderVoiceAiSummary(summary, pending = false) {
</div>
</div>
<div class="voice-ai-summary-grid">
${renderVoiceAiSummaryField('Имя клиента', summary?.customer_name_value)}
${renderVoiceAiSummaryField('Статус имени', formatVoiceCustomerNameStatus(summary?.customer_name_status))}
${renderVoiceAiSummaryField('Источник имени', formatVoiceCustomerNameSource(summary?.customer_name_source))}
${renderVoiceAiSummaryField('Язык старта', formatVoiceStartLanguage(summary?.voice_start_language))}
${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)}
${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)}
${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)}
@@ -4488,11 +4873,14 @@ function browserPhoneCallSummary(item) {
if (!item) {
return 'Ожидаем карточку звонка из bridge...';
}
const summary = voiceSummaryForItem(item);
const aiMeta = voiceAiStatusMeta(item);
const nameState = voiceCustomerNameStateLine(summary);
const parts = [
item.queue_code || item.queue_id || 'queue?',
item.interaction_id || 'interaction?',
item.operator_extension ? `ext ${item.operator_extension}` : '',
nameState,
aiMeta ? aiMeta.label : '',
].filter(Boolean);
return parts.join(' • ');
@@ -4567,6 +4955,7 @@ function updateBrowserPhoneUi() {
const answerBtn = $('browserPhoneAnswerBtn');
const rejectBtn = $('browserPhoneRejectBtn');
const claimBtn = $('browserPhoneCallClaimBtn');
const editNameBtn = $('browserPhoneEditNameBtn');
const transferBtn = $('browserPhoneCallTransferBtn');
const hangupBtn = $('browserPhoneCallHangupBtn');
const transferType = $('browserPhoneTransferTargetType');
@@ -4579,6 +4968,8 @@ function updateBrowserPhoneUi() {
const popupAiSummary = $('browserPhoneAiSummary');
const popupTimer = $('browserPhoneCallTimer');
const popupWarning = $('browserPhoneCallWarning');
const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null;
const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false;
$('browserPhoneRegistrationState').textContent = state.browserPhone.status;
$('browserPhoneOperatorExtension').textContent = config?.operator_extension || '—';
@@ -4601,6 +4992,7 @@ function updateBrowserPhoneUi() {
const active = popupPhase === 'in-call';
const canClaim = Boolean(popupCall && isClaimableLiveCall(popupCall) && !incoming && !connecting && !ending && !state.browserPhone.autoClaimInFlight);
const canControl = Boolean(popupCall && canControlLiveCall(popupCall) && !connecting && !ending);
const popupCustomerId = liveCallCustomerId(popupCall);
overlay.classList.toggle('hidden', !browserPhonePopupVisible());
popupState.textContent = errorState
@@ -4614,18 +5006,18 @@ function updateBrowserPhoneUi() {
: active
? 'Разговор в браузере'
: 'Browser call';
popupTitle.textContent = popupCall?.caller_number || popupCall?.caller_name || browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', '');
popupIncomingText.textContent = state.browserPhone.session
? browserPhoneIncomingLabel(state.browserPhone.session)
: popupCall
? `call_id: ${popupCall.call_id}`
popupTitle.textContent = popupCall
? voiceCustomerDisplayName(popupCall, popupAiSummaryData)
: browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', '');
popupIncomingText.textContent = popupCall
? voiceCustomerIncomingMeta(popupCall, popupAiSummaryData)
: state.browserPhone.session
? browserPhoneIncomingLabel(state.browserPhone.session)
: 'SIP invite ещё не поступал.';
popupMeta.textContent = browserPhoneCallSummary(popupCall);
if (popupCall) {
ensureVoiceAiSummaryLoaded(popupCall);
}
const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null;
const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false;
popupAiSummary.innerHTML = renderVoiceAiSummary(popupAiSummaryData, popupAiSummaryPending);
popupAiSummary.classList.toggle('hidden', !popupAiSummary.innerHTML.trim());
popupTimer.textContent = incoming
@@ -4641,6 +5033,7 @@ function updateBrowserPhoneUi() {
answerBtn.classList.toggle('hidden', !incoming);
rejectBtn.classList.toggle('hidden', !incoming);
claimBtn.classList.toggle('hidden', !canClaim);
editNameBtn.classList.toggle('hidden', !popupCall || !popupCustomerId);
muteBtn.classList.toggle('hidden', !(active || connecting));
transferBtn.classList.toggle('hidden', !(canControl || ending || errorState));
hangupBtn.classList.toggle('hidden', !(popupCall || hasSession || ending || errorState || connecting));
@@ -4650,12 +5043,18 @@ function updateBrowserPhoneUi() {
answerBtn.disabled = !incoming || Boolean(pendingAction);
rejectBtn.disabled = !incoming || Boolean(pendingAction);
claimBtn.disabled = !canClaim || Boolean(pendingAction) || state.browserPhone.autoClaimInFlight;
editNameBtn.disabled = !popupCall || !popupCustomerId || state.liveCalls.nameEditor.saving;
muteBtn.disabled = !hasSession;
muteBtn.textContent = state.browserPhone.muted ? 'Включить микрофон' : 'Выключить микрофон';
transferBtn.disabled = !canControl || Boolean(pendingAction) || ending || connecting || !transferValue.value.trim();
hangupBtn.disabled = (!popupCall && !hasSession) || Boolean(pendingAction) || ending;
transferType.disabled = !canControl || Boolean(pendingAction) || connecting;
transferValue.disabled = !canControl || Boolean(pendingAction) || connecting;
if (state.liveCalls.nameEditor.mode === 'popup' && (!popupCall || state.liveCalls.nameEditor.callId !== popupCall.call_id)) {
closeLiveCallNameEditor();
} else {
updateLiveCallNameEditorsUi();
}
}
function renderBrowserDeviceOptions(selectId, devices, preferredId, placeholder) {
@@ -5413,7 +5812,11 @@ function updateLiveCallSelector(items) {
const sorted = [...items];
select.innerHTML = sorted.length
? sorted
.map((item) => `<option value="${escapeHtml(item.call_id)}">${escapeHtml(item.call_id)} | ${escapeHtml(item.interaction_id)}</option>`)
.map((item) => {
const summary = voiceSummaryForItem(item);
const label = `${voiceCustomerDisplayName(item, summary)} | ${item.call_id}${item.interaction_id ? ` | ${item.interaction_id}` : ''}`;
return `<option value="${escapeHtml(item.call_id)}">${escapeHtml(label)}</option>`;
})
.join('')
: '<option value="">Нет активных звонков</option>';
if (sorted.length) {
@@ -5433,8 +5836,22 @@ function updateLiveCallSelector(items) {
function renderLiveCallCard(item, { recent = false } = {}) {
const isSelected = item.call_id === state.liveCalls.selectedCallId;
const caller = item.caller_number || item.caller_name || 'неизвестно';
const summary = voiceSummaryForItem(item);
const caller = voiceCustomerDisplayName(item, summary);
const aiMeta = voiceAiStatusMeta(item);
const customerId = liveCallCustomerId(item);
const nameStatusMeta = voiceCustomerNameStatusMeta(summary?.customer_name_status);
const nameStatusLine = nameStatusMeta
? `<p class="card-meta-line">имя: ${escapeHtml(nameStatusMeta.label)}</p>`
: '';
const nameSourceLabel = formatVoiceCustomerNameSource(summary?.customer_name_source);
const nameSourceLine = nameSourceLabel
? `<p class="card-meta-line">источник имени: ${escapeHtml(nameSourceLabel)}</p>`
: '';
const languageLabel = formatVoiceStartLanguage(summary?.voice_start_language);
const languageLine = languageLabel
? `<p class="card-meta-line">язык старта: ${escapeHtml(languageLabel)}</p>`
: '';
const badges = [
`<span class="micro-badge queue">${escapeHtml(item.queue_code || item.queue_id)}</span>`,
`<span class="micro-badge assignee">${escapeHtml(telephonyLabel(item.telephony_status))}</span>`,
@@ -5442,6 +5859,9 @@ function renderLiveCallCard(item, { recent = false } = {}) {
if (aiMeta) {
badges.push(`<span class="micro-badge ${escapeHtml(aiMeta.className)}">${escapeHtml(aiMeta.label)}</span>`);
}
if (nameStatusMeta) {
badges.push(`<span class="micro-badge ${escapeHtml(nameStatusMeta.className)}">${escapeHtml(nameStatusMeta.shortLabel)}</span>`);
}
if (recent && item.terminal_action) {
badges.push(`<span class="micro-badge terminal">${escapeHtml(terminalActionLabel(item))}</span>`);
}
@@ -5460,20 +5880,28 @@ function renderLiveCallCard(item, { recent = false } = {}) {
const timingLine = recent
? `<p class="card-meta-line">завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}</p>`
: `<p class="card-meta-line">соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}</p>`;
const actions = [
customerId ? `<button type="button" class="btn ghost" data-live-call-action="edit-name" data-call-id="${escapeHtml(item.call_id)}">Исправить имя</button>` : '',
customerId ? `<button type="button" class="btn ghost" data-live-call-action="customer" data-call-id="${escapeHtml(item.call_id)}" data-customer-id="${escapeHtml(customerId)}">К клиенту</button>` : '',
].filter(Boolean);
return `
<article class="pipeline-card live-call-card ${recent ? 'closed' : ''} ${isSelected ? 'selected' : ''}">
<div class="card-badges">${badges.join('')}</div>
<h3 class="card-title">${escapeHtml(item.call_id)}</h3>
<p class="card-subtitle">обращение: ${escapeHtml(item.interaction_id)}</p>
<p class="card-meta-line">абонент: ${escapeHtml(caller)}</p>
<h3 class="card-title">${escapeHtml(caller)}</h3>
<p class="card-subtitle">${escapeHtml(voiceCustomerCallSubtitle(item))}</p>
<p class="card-meta-line">обращение: ${escapeHtml(item.interaction_id || 'не найдено')}</p>
<p class="card-meta-line">взял в работу: ${escapeHtml(item.claimed_by_user || '-')}</p>
<p class="card-meta-line">внутренний номер: ${escapeHtml(item.operator_extension || '-')}</p>
<p class="card-meta-line">начат: ${escapeHtml(formatIsoShort(item.started_at))}</p>
${timingLine}
${nameStatusLine}
${nameSourceLine}
${languageLine}
${aiReasonLine}
${targetLine}
${hangupLine}
<p class="card-meta-line">запись: ${item.has_recording ? 'да' : 'нет'}</p>
${actions.length ? `<div class="live-call-card-actions">${actions.join('')}</div>` : ''}
</article>
`;
}
@@ -5494,6 +5922,8 @@ function renderLiveCallColumn(title, items, emptyMessage, options = {}) {
}
function renderLiveCallsTable(activeItems, recentItems) {
activeItems.forEach((item) => ensureVoiceAiSummaryLoaded(item));
recentItems.forEach((item) => ensureVoiceAiSummaryLoaded(item));
$('liveCallsTable').innerHTML = `
<div class="pipeline-board live-calls-board">
${renderLiveCallColumn('Активные звонки', activeItems, 'Активных звонков нет.')}
@@ -5538,6 +5968,7 @@ function applyLiveCallCollections(activeItems, recentItems, options = {}) {
}
updateLiveCallSelector(state.liveCalls.items);
renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems);
updateLiveCallNameEditorsUi();
syncBrowserPhonePopupLifecycle();
renderCustomerSpotlight();
renderUnifiedInbox();
@@ -5950,6 +6381,22 @@ function wire() {
$('loadInteractionsBtn').addEventListener('click', loadInteractions);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true));
$('loadLiveCallActionsBtn').addEventListener('click', loadLiveCallActions);
$('liveCallsTable').addEventListener('click', handleLiveCallTableClick);
$('liveCallNameInput').addEventListener('input', (event) => {
state.liveCalls.nameEditor.draft = event.target.value || '';
if (state.liveCalls.nameEditor.error) {
state.liveCalls.nameEditor.error = '';
}
updateLiveCallNameEditorsUi();
});
$('liveCallNameSaveBtn').addEventListener('click', () => {
saveLiveCallCustomerName().catch((err) => {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
});
});
$('liveCallNameCancelBtn').addEventListener('click', closeLiveCallNameEditor);
$('browserPhoneStatusBtn').addEventListener('click', toggleBrowserPhoneSettings);
$('browserPhoneCallSettingsBtn').addEventListener('click', toggleBrowserPhoneSettings);
$('browserPhoneConnectBtn').addEventListener('click', connectBrowserSoftphone);
@@ -5958,8 +6405,29 @@ function wire() {
$('browserPhoneAnswerBtn').addEventListener('click', answerBrowserSoftphoneCall);
$('browserPhoneRejectBtn').addEventListener('click', rejectBrowserSoftphoneCall);
$('browserPhoneCallClaimBtn').addEventListener('click', claimBrowserPopupCall);
$('browserPhoneEditNameBtn').addEventListener('click', () => {
const popupCall = browserPhoneActivePopupCall();
if (popupCall?.call_id) {
openLiveCallNameEditor(popupCall.call_id, 'popup');
}
});
$('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall);
$('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall);
$('browserPhoneNameInput').addEventListener('input', (event) => {
state.liveCalls.nameEditor.draft = event.target.value || '';
if (state.liveCalls.nameEditor.error) {
state.liveCalls.nameEditor.error = '';
}
updateLiveCallNameEditorsUi();
});
$('browserPhoneNameSaveBtn').addEventListener('click', () => {
saveLiveCallCustomerName().catch((err) => {
state.liveCalls.nameEditor.saving = false;
state.liveCalls.nameEditor.error = err.message || 'Не удалось сохранить имя клиента.';
updateLiveCallNameEditorsUi();
});
});
$('browserPhoneNameCancelBtn').addEventListener('click', closeLiveCallNameEditor);
$('browserPhoneMicSelect').addEventListener('change', () => {
state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value;
stopBrowserPhoneLocalStream();
+27 -2
View File
@@ -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=track19-unified-inbox1" />
<link rel="stylesheet" href="/operator/assets/styles.css?v=track21-voice-name-edit1" />
</head>
<body>
<div class="app-shell">
@@ -563,6 +563,18 @@
<div class="inline-form compact live-call-inspector">
<select id="liveCallIdSelect"></select>
</div>
<div id="liveCallNameEditor" class="live-call-name-editor hidden">
<div class="live-call-name-editor-head">
<div class="live-call-name-editor-title">Исправить имя клиента</div>
<p id="liveCallNameEditorMeta" class="hint">Выберите звонок, связанный с клиентом, чтобы исправить имя.</p>
</div>
<div class="live-call-name-editor-form">
<input id="liveCallNameInput" type="text" placeholder="Введите имя клиента" />
<button id="liveCallNameSaveBtn" class="btn" type="button">Сохранить имя</button>
<button id="liveCallNameCancelBtn" class="btn ghost" type="button">Отмена</button>
</div>
<p id="liveCallNameStatus" class="hint"></p>
</div>
<p class="hint">Здесь остаются живые и недавние звонки, а оперативные действия по ним вынесены во всплывающее окно.</p>
<p class="hint" id="liveCallStatusHint">Пока нет активных звонков.</p>
<div id="liveCallsTable" class="table"></div>
@@ -593,6 +605,18 @@
<p id="browserPhoneCallWarning" class="call-window-warning hidden"></p>
</div>
<div id="browserPhoneAiSummary" class="call-window-ai-summary hidden"></div>
<div id="browserPhoneNameEditor" class="call-window-name-editor hidden">
<div class="call-window-name-head">
<div class="voice-summary-label">Исправить имя клиента</div>
<p id="browserPhoneNameHint" class="call-window-meta subtle">Имя сохранится в профиле клиента и voice-контуре.</p>
</div>
<div class="call-window-name-form">
<input id="browserPhoneNameInput" type="text" placeholder="Введите имя клиента" />
<button id="browserPhoneNameSaveBtn" class="btn" type="button">Сохранить имя</button>
<button id="browserPhoneNameCancelBtn" class="btn ghost" type="button">Отмена</button>
</div>
<p id="browserPhoneNameStatus" class="call-window-meta subtle hidden"></p>
</div>
<div class="call-window-transfer">
<select id="browserPhoneTransferTargetType">
<option value="extension">внутренний номер</option>
@@ -604,6 +628,7 @@
<button id="browserPhoneAnswerBtn" class="btn" type="button">Ответить</button>
<button id="browserPhoneRejectBtn" class="btn ghost" type="button">Отклонить</button>
<button id="browserPhoneCallClaimBtn" class="btn ghost" type="button">Принять в работу</button>
<button id="browserPhoneEditNameBtn" class="btn ghost" type="button">Исправить имя</button>
<button id="browserPhoneMuteBtn" class="btn ghost" type="button">Выключить микрофон</button>
<button id="browserPhoneCallTransferBtn" class="btn ghost" type="button">Передать</button>
<button id="browserPhoneCallHangupBtn" class="btn danger" type="button">Завершить</button>
@@ -615,6 +640,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=track38-unified-inbox1"></script>
<script src="/operator/assets/app.js?v=track40-voice-name-edit1"></script>
</body>
</html>
+141
View File
@@ -3078,6 +3078,55 @@ textarea::placeholder {
box-shadow: 0 14px 26px rgba(51, 102, 232, 0.12);
}
.live-call-card-actions {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.live-call-card-actions .btn {
min-height: 38px;
padding: 0 14px;
font-size: 13px;
}
.live-call-name-editor {
margin: 14px 0;
padding: 14px;
border-radius: 18px;
border: 1px solid var(--primary-line);
background: linear-gradient(180deg, #ffffff, #f8fbff);
display: grid;
gap: 10px;
}
.live-call-name-editor-head {
display: grid;
gap: 4px;
}
.live-call-name-editor-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.live-call-name-editor-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
}
.live-call-name-editor-form input {
min-width: 0;
}
.hint.error,
.call-window-meta.error {
color: var(--danger);
}
.card-badges {
display: flex;
flex-wrap: wrap;
@@ -3164,6 +3213,24 @@ textarea::placeholder {
color: #6b7280;
}
.micro-badge.name-confirmed {
background: rgba(16, 185, 129, 0.14);
border-color: rgba(16, 185, 129, 0.28);
color: #117c5f;
}
.micro-badge.name-followup {
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.28);
color: #b66900;
}
.micro-badge.name-missing {
background: rgba(148, 163, 184, 0.16);
border-color: rgba(148, 163, 184, 0.3);
color: #55657a;
}
.card-title {
margin: 0;
font-size: 18px;
@@ -3873,6 +3940,43 @@ textarea::placeholder {
linear-gradient(180deg, #f9fbff, #f1f6ff);
}
.analytics-voice-panel {
display: grid;
gap: 14px;
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(40, 116, 78, 0.12);
background:
radial-gradient(circle at top right, rgba(40, 116, 78, 0.08), transparent 34%),
linear-gradient(180deg, #fbfefb, #f3faf5);
}
.voice-name-analytics-overview {
margin-bottom: 0;
}
.voice-name-card {
border-style: solid;
border-color: rgba(40, 116, 78, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 250, 246, 0.95));
}
.voice-name-funnel-grid {
grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(72px, 0.8fr));
}
.voice-name-language-grid {
grid-template-columns: minmax(110px, 1fr) repeat(5, minmax(82px, 0.8fr));
}
.voice-name-queue-grid {
grid-template-columns: minmax(170px, 1.4fr) repeat(5, minmax(82px, 0.8fr));
}
.voice-name-handoff-grid {
grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(82px, 0.8fr));
}
.analytics-agent-panel {
display: grid;
gap: 14px;
@@ -4647,6 +4751,38 @@ body.analytics-drilldown-open {
margin-bottom: 14px;
}
.call-window-name-editor {
margin-bottom: 14px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(9, 16, 28, 0.42);
border: 1px solid rgba(255, 255, 255, 0.08);
display: grid;
gap: 10px;
}
.call-window-name-head {
display: grid;
gap: 4px;
}
.call-window-name-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
}
.call-window-name-form input {
min-width: 0;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: #fff;
}
.call-window-name-form input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
.voice-ai-summary {
padding: 14px 16px;
border-radius: 18px;
@@ -5077,6 +5213,11 @@ body.analytics-drilldown-open {
grid-template-columns: 1fr;
}
.call-window-name-form,
.live-call-name-editor-form {
grid-template-columns: 1fr;
}
.call-window-actions {
flex-direction: column;
}