Add voice name flow controls and analytics
This commit is contained in:
+217
-6
@@ -10,6 +10,7 @@ const state = {
|
||||
loginPath: '/auth/oidc/start?return_mode=popup',
|
||||
providerLabel: 'Keycloak',
|
||||
},
|
||||
voiceNameConfig: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -290,8 +291,8 @@ function defaultIvrFlowDocument() {
|
||||
invalid_target_node_id: 'root',
|
||||
no_input_target_node_id: 'root',
|
||||
options: [
|
||||
{ digit: '1', target_node_id: 'menu_kz' },
|
||||
{ digit: '2', target_node_id: 'menu_ru' },
|
||||
{ digit: '1', target_node_id: 'voice_start_kz' },
|
||||
{ digit: '2', target_node_id: 'voice_start_ru' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -362,9 +363,60 @@ function defaultIvrFlowDocument() {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultVoiceStartIvrFlowDocument() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
node_id: 'root',
|
||||
prompt_text:
|
||||
'\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437. \u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.',
|
||||
prompt_sequence: [
|
||||
{
|
||||
prompt_audio_key: 'ivr/demo-language-kz',
|
||||
prompt_text:
|
||||
'\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437.',
|
||||
language: 'kz',
|
||||
},
|
||||
{
|
||||
prompt_audio_key: 'ivr/demo-language-ru',
|
||||
prompt_text:
|
||||
'\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.',
|
||||
language: 'ru',
|
||||
},
|
||||
],
|
||||
is_terminal: false,
|
||||
invalid_target_node_id: 'root',
|
||||
no_input_target_node_id: 'root',
|
||||
options: [
|
||||
{ digit: '1', target_node_id: 'voice_start_kz' },
|
||||
{ digit: '2', target_node_id: 'voice_start_ru' },
|
||||
],
|
||||
},
|
||||
{
|
||||
node_id: 'voice_start_kz',
|
||||
prompt_text: '\u049a\u043e\u04a3\u044b\u0440\u0430\u0443\u0434\u044b \u049b\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d\u0434\u0435\u0433\u0456 \u0431\u0430\u0441\u0442\u0430\u043f\u049b\u044b voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u0433\u0435 \u04e9\u0442\u043a\u0456\u0437\u0456\u043f \u0436\u0430\u0442\u044b\u0440\u043c\u044b\u0437.',
|
||||
is_terminal: true,
|
||||
outcome_code: 'voice_start_kz',
|
||||
resolved_queue_id: 'voice_start_kz_line',
|
||||
resolved_queue_code: 'voice_start_kz',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
node_id: 'voice_start_ru',
|
||||
prompt_text: '\u041f\u0435\u0440\u0435\u0434\u0430\u0435\u043c \u0437\u0432\u043e\u043d\u043e\u043a \u0432 \u0440\u0443\u0441\u0441\u043a\u043e\u044f\u0437\u044b\u0447\u043d\u044b\u0439 \u0441\u0442\u0430\u0440\u0442\u043e\u0432\u044b\u0439 voice-\u0441\u0446\u0435\u043d\u0430\u0440\u0438\u0439.',
|
||||
is_terminal: true,
|
||||
outcome_code: 'voice_start_ru',
|
||||
resolved_queue_id: 'voice_start_ru_line',
|
||||
resolved_queue_code: 'voice_start_ru',
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultIvrFlowJson() {
|
||||
if (!$('ivrFlowJson').value.trim()) {
|
||||
$('ivrFlowJson').value = JSON.stringify(defaultIvrFlowDocument(), null, 2);
|
||||
$('ivrFlowJson').value = JSON.stringify(defaultVoiceStartIvrFlowDocument(), null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +496,7 @@ function syncIvrFlowFields(flow) {
|
||||
$('ivrQueueId').value = flow.queue_id || $('ivrQueueId').value;
|
||||
$('ivrEntryNodeId').value = flow.entry_node_id || $('ivrEntryNodeId').value;
|
||||
$('ivrIsActive').checked = Boolean(flow.is_active);
|
||||
$('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultIvrFlowDocument(), null, 2);
|
||||
$('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultVoiceStartIvrFlowDocument(), null, 2);
|
||||
}
|
||||
|
||||
function syncIvrSessionFields(payload) {
|
||||
@@ -458,6 +510,146 @@ function syncIvrSessionFields(payload) {
|
||||
$('ivrTestInteractionId').value = session.interaction_id || $('ivrTestInteractionId').value;
|
||||
}
|
||||
|
||||
function serializeVoiceNameConfigForm() {
|
||||
return {
|
||||
enabled: $('voiceNameEnabled').checked,
|
||||
start: {
|
||||
ask_name_on_start: $('voiceNameAskOnStart').checked,
|
||||
known_customer_behavior: $('voiceNameKnownCustomerBehavior').value,
|
||||
unknown_customer_behavior: $('voiceNameUnknownCustomerBehavior').value,
|
||||
},
|
||||
downstream: {
|
||||
missing_name_behavior: $('voiceNameMissingNameBehavior').value,
|
||||
uncertain_name_behavior: $('voiceNameUncertainNameBehavior').value,
|
||||
finalize_on_explicit_name: $('voiceNameFinalizeOnExplicitName').checked,
|
||||
finalize_on_confirmation: $('voiceNameFinalizeOnConfirmation').checked,
|
||||
},
|
||||
texts: {
|
||||
ru: {
|
||||
start_prompt: $('voiceNameRuStartPrompt').value.trim(),
|
||||
personalized_greeting_template: $('voiceNameRuPersonalizedGreeting').value.trim(),
|
||||
confirmation_greeting_template: $('voiceNameRuConfirmationGreeting').value.trim(),
|
||||
inline_followup_prompt: $('voiceNameRuInlineFollowup').value.trim(),
|
||||
},
|
||||
kz: {
|
||||
start_prompt: $('voiceNameKzStartPrompt').value.trim(),
|
||||
personalized_greeting_template: $('voiceNameKzPersonalizedGreeting').value.trim(),
|
||||
confirmation_greeting_template: $('voiceNameKzConfirmationGreeting').value.trim(),
|
||||
inline_followup_prompt: $('voiceNameKzInlineFollowup').value.trim(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncVoiceNameConfigFields(payload) {
|
||||
const config = payload?.config || payload;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
$('voiceNameEnabled').checked = Boolean(config.enabled);
|
||||
$('voiceNameAskOnStart').checked = Boolean(config.start?.ask_name_on_start);
|
||||
$('voiceNameKnownCustomerBehavior').value = config.start?.known_customer_behavior || 'trust_and_handoff';
|
||||
$('voiceNameUnknownCustomerBehavior').value = config.start?.unknown_customer_behavior || 'ask_on_start';
|
||||
$('voiceNameMissingNameBehavior').value = config.downstream?.missing_name_behavior || 'ask_inline_once';
|
||||
$('voiceNameUncertainNameBehavior').value = config.downstream?.uncertain_name_behavior || 'confirm_then_finalize';
|
||||
$('voiceNameFinalizeOnExplicitName').checked = Boolean(config.downstream?.finalize_on_explicit_name);
|
||||
$('voiceNameFinalizeOnConfirmation').checked = Boolean(config.downstream?.finalize_on_confirmation);
|
||||
$('voiceNameRuStartPrompt').value = config.texts?.ru?.start_prompt || '';
|
||||
$('voiceNameRuPersonalizedGreeting').value = config.texts?.ru?.personalized_greeting_template || '';
|
||||
$('voiceNameRuConfirmationGreeting').value = config.texts?.ru?.confirmation_greeting_template || '';
|
||||
$('voiceNameRuInlineFollowup').value = config.texts?.ru?.inline_followup_prompt || '';
|
||||
$('voiceNameKzStartPrompt').value = config.texts?.kz?.start_prompt || '';
|
||||
$('voiceNameKzPersonalizedGreeting').value = config.texts?.kz?.personalized_greeting_template || '';
|
||||
$('voiceNameKzConfirmationGreeting').value = config.texts?.kz?.confirmation_greeting_template || '';
|
||||
$('voiceNameKzInlineFollowup').value = config.texts?.kz?.inline_followup_prompt || '';
|
||||
}
|
||||
|
||||
function renderVoiceNameConfigSummary(payload) {
|
||||
const config = payload?.config || payload;
|
||||
const source = payload?.source || 'defaults';
|
||||
const updatedAt = payload?.updated_at || 'не сохранялось';
|
||||
const knownLabels = {
|
||||
trust_and_handoff: 'известное имя сразу принимается и передаётся дальше',
|
||||
confirm_in_downstream: 'известное имя уходит на подтверждение в downstream',
|
||||
ask_on_start: 'известного клиента всё равно спрашиваем на старте',
|
||||
};
|
||||
const unknownLabels = {
|
||||
ask_on_start: 'неизвестного клиента спрашиваем на старте',
|
||||
skip_to_downstream: 'неизвестного клиента сразу передаём в downstream',
|
||||
};
|
||||
const missingLabels = {
|
||||
ask_inline_once: 'если имя не получено, AI спросит inline один раз',
|
||||
do_not_ask: 'если имя не получено, AI inline не спрашивает',
|
||||
};
|
||||
const uncertainLabels = {
|
||||
confirm_then_finalize: 'неуверенное имя нужно подтвердить',
|
||||
finalize_immediately: 'неуверенное имя считается финальным сразу',
|
||||
discard_and_collect: 'неуверенное имя сбрасывается и собирается заново',
|
||||
};
|
||||
$('voiceNameSummary').textContent = [
|
||||
`Сценарий: ${config?.enabled ? 'включён' : 'выключен'}`,
|
||||
`Стартовый вопрос про имя: ${config?.start?.ask_name_on_start ? 'да' : 'нет'}`,
|
||||
`Известный клиент: ${knownLabels[config?.start?.known_customer_behavior] || config?.start?.known_customer_behavior || 'не задано'}`,
|
||||
`Неизвестный клиент: ${unknownLabels[config?.start?.unknown_customer_behavior] || config?.start?.unknown_customer_behavior || 'не задано'}`,
|
||||
`Если имя не получено: ${missingLabels[config?.downstream?.missing_name_behavior] || config?.downstream?.missing_name_behavior || 'не задано'}`,
|
||||
`Если имя неуверенное: ${uncertainLabels[config?.downstream?.uncertain_name_behavior] || config?.downstream?.uncertain_name_behavior || 'не задано'}`,
|
||||
`Финализация по явному имени: ${config?.downstream?.finalize_on_explicit_name ? 'да' : 'нет'}`,
|
||||
`Финализация по подтверждению: ${config?.downstream?.finalize_on_confirmation ? 'да' : 'нет'}`,
|
||||
`Источник конфигурации: ${source}`,
|
||||
`Последнее обновление: ${updatedAt}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function loadVoiceNameConfig() {
|
||||
try {
|
||||
const data = await api('ai', 'ai/voice/config/name-collection');
|
||||
state.voiceNameConfig = data;
|
||||
syncVoiceNameConfigFields(data);
|
||||
renderVoiceNameConfigSummary(data);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice name collection загружены', {
|
||||
source: data.source,
|
||||
updated_at: data.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
$('voiceNameConfigOutput').textContent = err.message;
|
||||
log('Не удалось загрузить настройки voice name collection', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVoiceNameConfig() {
|
||||
try {
|
||||
const payload = serializeVoiceNameConfigForm();
|
||||
const data = await api('ai', 'ai/voice/config/name-collection', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
state.voiceNameConfig = data;
|
||||
syncVoiceNameConfigFields(data);
|
||||
renderVoiceNameConfigSummary(data);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(data, null, 2);
|
||||
log('Настройки voice name collection сохранены', {
|
||||
source: data.source,
|
||||
updated_at: data.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
$('voiceNameConfigOutput').textContent = err.message;
|
||||
log('Не удалось сохранить настройки voice name collection', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function resetVoiceNameConfigForm() {
|
||||
const snapshot = state.voiceNameConfig;
|
||||
if (!snapshot) {
|
||||
$('voiceNameConfigOutput').textContent = 'Сначала загрузите текущие настройки voice name collection.';
|
||||
return;
|
||||
}
|
||||
syncVoiceNameConfigFields(snapshot);
|
||||
renderVoiceNameConfigSummary(snapshot);
|
||||
$('voiceNameConfigOutput').textContent = JSON.stringify(snapshot, null, 2);
|
||||
log('Форма voice name collection сброшена к текущим настройкам');
|
||||
}
|
||||
|
||||
function parseIvrFlowJson() {
|
||||
ensureDefaultIvrFlowJson();
|
||||
return JSON.parse($('ivrFlowJson').value || '{}');
|
||||
@@ -980,7 +1172,14 @@ function wire() {
|
||||
$('loginBtn').addEventListener('click', login);
|
||||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
||||
$('refreshBtn').addEventListener('click', async () => {
|
||||
await Promise.all([loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]);
|
||||
await Promise.all([
|
||||
loadUsers(),
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
log('Данные админ-консоли обновлены');
|
||||
});
|
||||
$('loadUsersBtn').addEventListener('click', loadUsers);
|
||||
@@ -1002,6 +1201,9 @@ function wire() {
|
||||
$('sendIvrDigitBtn').addEventListener('click', sendIvrDigit);
|
||||
$('loadIvrSessionBtn').addEventListener('click', loadIvrSession);
|
||||
$('previewIvrRouteBtn').addEventListener('click', previewIvrRoute);
|
||||
$('loadVoiceNameConfigBtn').addEventListener('click', loadVoiceNameConfig);
|
||||
$('saveVoiceNameConfigBtn').addEventListener('click', saveVoiceNameConfig);
|
||||
$('resetVoiceNameConfigBtn').addEventListener('click', resetVoiceNameConfigForm);
|
||||
$('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus);
|
||||
$('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents);
|
||||
$('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail);
|
||||
@@ -1022,7 +1224,16 @@ async function init() {
|
||||
wire();
|
||||
ensureDefaultIvrFlowJson();
|
||||
updateSessionInfo();
|
||||
await Promise.all([checkGateway(), loadOidcConfig(), loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]);
|
||||
await Promise.all([
|
||||
checkGateway(),
|
||||
loadOidcConfig(),
|
||||
loadUsers(),
|
||||
loadQueues(),
|
||||
loadIvrFlows(),
|
||||
loadVoiceNameConfig(),
|
||||
loadAsteriskStatus(),
|
||||
loadAsteriskEvents(),
|
||||
]);
|
||||
log('Админ-консоль готова');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
||||
<a class="nav-link" href="#routes"><span class="nav-bullet"></span>Маршрутизация</a>
|
||||
<a class="nav-link" href="#ivr"><span class="nav-bullet"></span>IVR</a>
|
||||
<a class="nav-link" href="#voiceNameCollection"><span class="nav-bullet"></span>Voice AI</a>
|
||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -250,6 +251,81 @@
|
||||
<pre id="ivrRouteOutput" class="output">Предпросмотр IVR-маршрута появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="voiceNameCollection">
|
||||
<h2>Voice AI: сбор имени</h2>
|
||||
<p class="hint">Глобальная админ-настройка сценария определения имени клиента для voice_start и downstream AI.</p>
|
||||
<pre id="voiceNameSummary" class="output">Сводка по текущим настройкам появится здесь.</pre>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameEnabled" type="checkbox" checked /> Сценарий включён</label>
|
||||
<label class="checkbox-label"><input id="voiceNameAskOnStart" type="checkbox" checked /> Спрашивать имя на старте</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Известный клиент
|
||||
<select id="voiceNameKnownCustomerBehavior">
|
||||
<option value="trust_and_handoff">Сразу доверять имени и переводить дальше</option>
|
||||
<option value="confirm_in_downstream">Передавать имя на подтверждение в downstream</option>
|
||||
<option value="ask_on_start">Переспрашивать имя на старте</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Неизвестный клиент
|
||||
<select id="voiceNameUnknownCustomerBehavior">
|
||||
<option value="ask_on_start">Спрашивать имя на старте</option>
|
||||
<option value="skip_to_downstream">Сразу переводить в downstream</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Если имя не получено
|
||||
<select id="voiceNameMissingNameBehavior">
|
||||
<option value="ask_inline_once">Спросить inline один раз</option>
|
||||
<option value="do_not_ask">Не спрашивать inline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Если имя неуверенное
|
||||
<select id="voiceNameUncertainNameBehavior">
|
||||
<option value="confirm_then_finalize">Подтвердить и только потом финализировать</option>
|
||||
<option value="finalize_immediately">Считать имя финальным сразу</option>
|
||||
<option value="discard_and_collect">Сбросить кандидат и собирать заново</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnExplicitName" type="checkbox" checked /> Финализировать имя при явном ответе</label>
|
||||
<label class="checkbox-label"><input id="voiceNameFinalizeOnConfirmation" type="checkbox" checked /> Финализировать имя после подтверждения</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameRuStartPrompt">Тексты RU</label>
|
||||
<textarea id="voiceNameRuStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameRuPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameRuConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameRuInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="voiceNameKzStartPrompt">Тексты KZ</label>
|
||||
<textarea id="voiceNameKzStartPrompt" rows="2" class="json-textarea" placeholder="Стартовый prompt"></textarea>
|
||||
<textarea id="voiceNameKzPersonalizedGreeting" rows="3" class="json-textarea" placeholder="Персонализированное приветствие с {name}"></textarea>
|
||||
<textarea id="voiceNameKzConfirmationGreeting" rows="3" class="json-textarea" placeholder="Подтверждение имени с {name}"></textarea>
|
||||
<textarea id="voiceNameKzInlineFollowup" rows="2" class="json-textarea" placeholder="Inline follow-up"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button id="loadVoiceNameConfigBtn" class="btn ghost">Загрузить настройки</button>
|
||||
<button id="saveVoiceNameConfigBtn" class="btn">Сохранить настройки</button>
|
||||
<button id="resetVoiceNameConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
||||
</div>
|
||||
<pre id="voiceNameConfigOutput" class="output">Raw payload voice name collection появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="asterisk">
|
||||
<h2>Мост Asterisk</h2>
|
||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
||||
|
||||
+789
-2
File diff suppressed because it is too large
Load Diff
+74
-13
@@ -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=track44-analyst-encoding-fix" />
|
||||
<link rel="stylesheet" href="/operator/assets/styles.css?v=track45-voice-name-flow-v1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -32,6 +32,8 @@
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Разделы</div>
|
||||
<a class="nav-link" href="#analyticsPanel"><span class="nav-bullet"></span>Обзор</a>
|
||||
<a class="nav-link" href="#voiceNameAnalyticsPanel"><span class="nav-bullet"></span>Сбор имени</a>
|
||||
<a class="nav-link" href="#aiAnalyticsPanel"><span class="nav-bullet"></span>AI</a>
|
||||
<a class="nav-link" href="#agentAnalyticsPanel"><span class="nav-bullet"></span>Агенты</a>
|
||||
<a class="nav-link" href="#analyticsTrendBlock"><span class="nav-bullet"></span>Тренд</a>
|
||||
<a class="nav-link" href="#analyticsQueuesBlock"><span class="nav-bullet"></span>Очереди</a>
|
||||
@@ -40,7 +42,7 @@
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<strong>Рабочее место аналитика</strong>
|
||||
<p>Историческая картина по обращениям, каналам и очередям без оперативного мониторинга.</p>
|
||||
<p>Историческая картина по обращениям, каналам, очередям и качеству сценариев без перехода в операционный экран.</p>
|
||||
<p class="hint" id="sessionInfo">Сессия проверяется...</p>
|
||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
||||
</div>
|
||||
@@ -50,7 +52,7 @@
|
||||
<header class="workspace-topbar reveal">
|
||||
<div class="analytics-topbar-intro">
|
||||
<strong>Историческая аналитика сервиса</strong>
|
||||
<p>Периоды, каналы и очереди в одном окне, без перехода в операционный экран.</p>
|
||||
<p>Периоды, каналы, очереди, AI и агентские срезы в одном окне.</p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pill" id="gatewayStatus">Данные: проверка...</div>
|
||||
@@ -113,10 +115,10 @@
|
||||
<select id="analyticsChannel">
|
||||
<option value="all">Все каналы</option>
|
||||
<option value="voice">Голос</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Веб-чат</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="webchat">Веб-чат</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +160,62 @@
|
||||
<div id="analyticsComparePanel" class="analytics-compare-panel" hidden></div>
|
||||
<div id="analyticsOverview" class="summary-grid analytics-overview-grid"></div>
|
||||
|
||||
<section class="analytics-voice-panel" id="voiceNameAnalyticsPanel">
|
||||
<div class="analytics-subhead">
|
||||
<div>
|
||||
<p class="eyebrow">Сценарий имени</p>
|
||||
<h3>Сценарий сбора имени в голосе</h3>
|
||||
<p class="hint">Отдельная аналитика по тому, как сценарий берёт имя на старте, как помогает downstream AI и сколько звонков уходит оператору без подтверждённого имени.</p>
|
||||
</div>
|
||||
<div class="row analytics-trend-selector">
|
||||
<label for="voiceNameAnalyticsTrendMetric">Метрика</label>
|
||||
<select id="voiceNameAnalyticsTrendMetric">
|
||||
<option value="scenario_calls">Звонки в сценарии</option>
|
||||
<option value="start_capture_rate">Имя взято сразу</option>
|
||||
<option value="downstream_rescue_rate">Имя добрал downstream AI</option>
|
||||
<option value="handoff_unconfirmed_rate">Handoff без имени</option>
|
||||
<option value="manual_correction_rate">Ручное исправление</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="voiceNameAnalyticsEmptyState" class="analytics-empty-state" hidden></div>
|
||||
<div id="voiceNameAnalyticsOverview" class="summary-grid analytics-overview-grid voice-name-analytics-overview"></div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide">
|
||||
<h3>Тренд по voice name-flow</h3>
|
||||
<p class="hint">Динамика показывает, как меняется качество сбора имени по выбранному периоду, очереди и голосовому потоку.</p>
|
||||
<div id="voiceNameAnalyticsTrendChart" class="analytics-chart-shell"></div>
|
||||
</article>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Этапы сценария</h3>
|
||||
<p class="hint">Funnel помогает быстро увидеть, где чаще всего теряется имя: на старте, в follow-up или уже перед handoff.</p>
|
||||
<div id="voiceNameAnalyticsFunnel" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>По языкам</h3>
|
||||
<p class="hint">Сравнение сценария по языкам помогает увидеть, где стартовый сбор имени работает стабильнее, а где чаще требуется downstream AI.</p>
|
||||
<div id="voiceNameAnalyticsLanguageTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>По очередям</h3>
|
||||
<p class="hint">Срез по очередям показывает, в каких очередях voice name-flow чаще доходит до handoff без подтверждённого имени.</p>
|
||||
<div id="voiceNameAnalyticsQueueTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Handoff по имени</h3>
|
||||
<p class="hint">Здесь видно, сколько звонков оператор получил уже с подтверждённым именем, а сколько ушло без него.</p>
|
||||
<div id="voiceNameAnalyticsHandoffTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="analytics-ai-panel" id="aiAnalyticsPanel">
|
||||
<div class="analytics-subhead">
|
||||
<div>
|
||||
@@ -198,6 +256,7 @@
|
||||
<p class="hint">Сравнение полностью автоматических и затронутых оператором сессий по Telegram и WhatsApp без текстов сообщений, записей и AI-сводок.</p>
|
||||
<div id="aiAnalyticsChannelComparison" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Исходы AI-сессий</h3>
|
||||
@@ -224,6 +283,7 @@
|
||||
<div id="agentAnalyticsEmptyState" class="analytics-empty-state" hidden></div>
|
||||
<div id="agentAnalyticsOverview" class="summary-grid analytics-overview-grid agent-analytics-overview"></div>
|
||||
<div id="agentAnalyticsStateStrip" class="agent-analytics-state-strip"></div>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide">
|
||||
<div class="analytics-subhead">
|
||||
@@ -250,11 +310,13 @@
|
||||
<div id="agentAnalyticsTeamTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Агенты по выбранному периоду</h3>
|
||||
<p class="hint">Клик по строке открывает полноэкранную детализацию по обращениям выбранного агента без изменения фильтров витрины.</p>
|
||||
<div id="agentAnalyticsTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Смены</h3>
|
||||
@@ -286,7 +348,7 @@
|
||||
|
||||
<article class="analytics-subpanel">
|
||||
<h3>Каналы</h3>
|
||||
<p class="hint">Сравнение каналов по объему, обработке и доле ответа за выбранный период.</p>
|
||||
<p class="hint">Сравнение каналов по объёму, обработке и доле ответа за выбранный период.</p>
|
||||
<p class="hint analytics-drilldown-note">Нажмите на строку, чтобы открыть точную детализацию по обращениям этого канала.</p>
|
||||
<div id="analyticsChannelTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
@@ -295,11 +357,10 @@
|
||||
<div class="analytics-sections">
|
||||
<article class="analytics-subpanel analytics-subpanel-wide" id="analyticsQueuesBlock">
|
||||
<h3>Очереди</h3>
|
||||
<p class="hint">Очереди отсортированы по объему. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.</p>
|
||||
<p class="hint">Очереди отсортированы по объёму. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.</p>
|
||||
<p class="hint analytics-drilldown-note">Клик по строке открывает точный срез по очереди и не меняет фильтры витрины.</p>
|
||||
<div id="analyticsQueueTable" class="analytics-table-shell"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -319,9 +380,9 @@
|
||||
>
|
||||
<div class="analytics-drawer-head">
|
||||
<div>
|
||||
<p class="eyebrow">Детализация обращений</p>
|
||||
<p class="eyebrow">Детализация обращений</p>
|
||||
<h2 id="analyticsDrilldownTitle">Детализация обращений</h2>
|
||||
<p id="analyticsDrilldownMeta" class="hint">Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.</p>
|
||||
<p id="analyticsDrilldownMeta" class="hint">Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.</p>
|
||||
</div>
|
||||
<button id="analyticsDrilldownCloseBtn" class="btn ghost" type="button">Закрыть</button>
|
||||
</div>
|
||||
@@ -387,6 +448,6 @@
|
||||
<input id="sessionUser" type="hidden" value="analyst" />
|
||||
<input id="sessionRole" type="hidden" value="analyst" />
|
||||
|
||||
<script src="/analyst/assets/app.js?v=track44-analyst-encoding-fix"></script>
|
||||
<script src="/analyst/assets/app.js?v=track45-voice-name-flow-v1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -78,6 +78,45 @@
|
||||
"closed_without_operator_rate": 48.6
|
||||
}
|
||||
],
|
||||
"voice_name_flow": {
|
||||
"totals": {
|
||||
"scenario_calls": 196,
|
||||
"start_obtained": 118,
|
||||
"downstream_ai_obtained": 34,
|
||||
"followup_required": 18,
|
||||
"name_not_obtained": 26,
|
||||
"manual_corrected": 12,
|
||||
"handoff_confirmed_name": 14,
|
||||
"handoff_unconfirmed_name": 28,
|
||||
"needed_downstream": 78
|
||||
},
|
||||
"languages": [
|
||||
{
|
||||
"language": "ru",
|
||||
"share": 0.58,
|
||||
"start_capture_rate": 66.2,
|
||||
"downstream_rescue_rate": 47.4,
|
||||
"handoff_unconfirmed_rate": 31.8,
|
||||
"manual_correction_rate": 14.6
|
||||
},
|
||||
{
|
||||
"language": "kz",
|
||||
"share": 0.29,
|
||||
"start_capture_rate": 61.7,
|
||||
"downstream_rescue_rate": 51.3,
|
||||
"handoff_unconfirmed_rate": 28.4,
|
||||
"manual_correction_rate": 11.8
|
||||
},
|
||||
{
|
||||
"language": "unknown",
|
||||
"share": 0.13,
|
||||
"start_capture_rate": 44.8,
|
||||
"downstream_rescue_rate": 36.1,
|
||||
"handoff_unconfirmed_rate": 46.5,
|
||||
"manual_correction_rate": 23.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent_shift_rows": [
|
||||
{
|
||||
"shift_key": "night",
|
||||
|
||||
+484
-16
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user