Files
call-center/ui/admin/app.js
T

1241 lines
48 KiB
JavaScript

const state = {
user: 'admin',
role: 'admin',
token: null,
authSource: 'local',
fullName: null,
logLines: [],
oidc: {
enabled: false,
loginPath: '/auth/oidc/start?return_mode=popup',
providerLabel: 'Keycloak',
},
voiceNameConfig: null,
};
const $ = (id) => document.getElementById(id);
const SESSION_STORAGE_KEY = 'cc_session';
const ROLE_LABELS = {
admin: 'Администратор',
supervisor: 'Супервизор',
operator: 'Оператор',
analyst: 'Аналитик',
};
function destinationForRole(role) {
if (role === 'admin') {
return '/admin';
}
if (role === 'supervisor') {
return '/supervisor';
}
if (role === 'analyst') {
return '/analyst';
}
return '/operator';
}
function applyRoleNavigation() {
document.querySelectorAll('[data-shell]').forEach((link) => {
const allowed = (link.dataset.roles || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none';
});
}
function ensurePageAccess() {
if (state.role !== 'admin') {
window.location.href = destinationForRole(state.role);
return false;
}
return true;
}
function persistSession() {
window.localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
access_token: state.token,
user: state.user,
role: state.role,
auth_source: state.authSource,
full_name: state.fullName,
}),
);
}
function clearStoredSession() {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
function restoreStoredSession() {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
if (!payload?.access_token) {
clearStoredSession();
return false;
}
state.token = payload.access_token;
state.user = payload.user || state.user;
state.role = payload.role || state.role;
state.authSource = payload.auth_source || 'local';
state.fullName = payload.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = state.role;
if ($('authPanel')) {
$('authPanel').style.display = 'none';
}
updateProfileMeta();
applyRoleNavigation();
return true;
} catch {
clearStoredSession();
return false;
}
}
function logout() {
clearStoredSession();
window.location.href = '/';
}
function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'admin';
state.role = $('sessionRole').value || 'admin';
}
function log(message, payload) {
const ts = new Date().toLocaleTimeString();
const suffix = payload ? ` ${JSON.stringify(payload)}` : '';
state.logLines.unshift(`[${ts}] ${message}${suffix}`);
state.logLines = state.logLines.slice(0, 16);
$('eventLog').textContent = state.logLines.join('\n');
}
function updateProfileMeta() {
const profileName = $('profileName');
const profileRole = $('profileRole');
if (!profileName || !profileRole) {
return;
}
profileName.textContent = state.fullName || state.user || 'Экран администратора';
profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь';
}
function updateSessionInfo(extra = '') {
if (!state.token) {
$('sessionInfo').textContent = 'Токен: отсутствует';
return;
}
const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный';
$('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`;
}
function applyAuthenticatedSession(data) {
state.token = data.access_token;
state.role = data.role;
state.user = data.username || state.user;
state.authSource = data.auth_source || 'local';
state.fullName = data.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = data.role;
updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : '');
updateProfileMeta();
persistSession();
applyRoleNavigation();
}
async function api(service, path, options = {}) {
syncSessionFromInputs();
const headers = {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers });
let data;
try {
data = await response.json();
} catch {
data = { error: 'Сервис вернул ответ не в JSON-формате' };
}
if (!response.ok) {
if (response.status === 401 && state.token) {
state.token = null;
state.authSource = 'local';
clearStoredSession();
updateSessionInfo('требуется повторный вход');
window.location.href = '/';
}
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function checkGateway() {
try {
const response = await fetch('/health');
const data = await response.json();
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
} catch {
$('gatewayStatus').textContent = 'Шлюз: недоступен';
}
}
async function loadOidcConfig() {
try {
const response = await fetch('/proxy/auth/auth/oidc/config');
const data = await response.json();
state.oidc.enabled = Boolean(data.enabled);
state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup';
state.oidc.providerLabel = data.provider_label || 'Keycloak';
$('corporateLoginBtn').disabled = !state.oidc.enabled;
$('corporateLoginBtn').textContent = state.oidc.enabled
? `Корпоративный вход (${state.oidc.providerLabel})`
: 'Корпоративный вход недоступен';
} catch {
$('corporateLoginBtn').disabled = true;
$('corporateLoginBtn').textContent = 'Корпоративный вход недоступен';
}
}
function startCorporateLogin() {
if (!state.oidc.enabled) {
log('Корпоративный вход недоступен');
return;
}
const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
if (!popup) {
log('Не удалось открыть окно корпоративного входа');
return;
}
log('Открыт корпоративный вход', { провайдер: state.oidc.providerLabel });
}
function handleOidcMessage(event) {
if (!event?.data || typeof event.data !== 'object') {
return;
}
if (event.data.type === 'oidc-login' && event.data.access_token) {
applyAuthenticatedSession(event.data);
log('Корпоративный вход выполнен', { user: state.user, role: state.role });
return;
}
if (event.data.type === 'oidc-error') {
log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' });
updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`);
}
}
async function login() {
syncSessionFromInputs();
try {
const data = await api('auth', 'auth/login', {
method: 'POST',
body: JSON.stringify({
username: state.user,
password: $('sessionPassword').value,
}),
});
applyAuthenticatedSession({ ...data, username: state.user, auth_source: data.auth_source || 'local' });
log('Локальный вход выполнен', { user: state.user, role: data.role });
} catch (err) {
$('sessionInfo').textContent = `Ошибка входа: ${err.message}`;
log('Вход не выполнен', { error: err.message });
}
}
function queueRulePayload(channelId, slaSeconds) {
return [
{
channel: channelId,
priority: 3,
strategy: 'round_robin',
sla_seconds: Number(slaSeconds || 30),
},
];
}
function defaultIvrFlowDocument() {
return {
nodes: [
{
node_id: 'root',
prompt_text: 'Саламатсыз ба! Қазақ тілін таңдау үшін бір цифрын теріңіз. Здравствуйте! Добро пожаловать в контакт-центр. Для русского языка нажмите цифру два.',
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: 'menu_ru',
prompt_text: 'Для отдела продаж нажмите 1. Для службы поддержки нажмите 2.',
prompt_audio_key: 'ivr/demo-menu-ru',
is_terminal: false,
invalid_target_node_id: 'menu_ru',
no_input_target_node_id: 'menu_ru',
options: [
{ digit: '1', target_node_id: 'sales_ru' },
{ digit: '2', target_node_id: 'support_ru' },
],
},
{
node_id: 'menu_kz',
prompt_text: 'Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз.',
prompt_audio_key: 'ivr/demo-menu-kz',
is_terminal: false,
invalid_target_node_id: 'menu_kz',
no_input_target_node_id: 'menu_kz',
options: [
{ digit: '1', target_node_id: 'sales_kz' },
{ digit: '2', target_node_id: 'support_kz' },
],
},
{
node_id: 'sales_ru',
prompt_text: 'Переводим в отдел продаж.',
prompt_audio_key: 'ivr/demo-sales-ru',
is_terminal: true,
outcome_code: 'sales_route_ru',
resolved_queue_id: 'sales_line',
resolved_queue_code: 'ivr_sales_ai_ru',
options: [],
},
{
node_id: 'support_ru',
prompt_text: 'Переводим в службу поддержки.',
prompt_audio_key: 'ivr/demo-support-ru',
is_terminal: true,
outcome_code: 'support_route_ru',
resolved_queue_id: 'support_line',
resolved_queue_code: 'ivr_support_ai_ru',
options: [],
},
{
node_id: 'sales_kz',
prompt_text: 'Сату бөліміне қосып жатырмыз.',
prompt_audio_key: 'ivr/demo-sales-kz',
is_terminal: true,
outcome_code: 'sales_route_kz',
resolved_queue_id: 'sales_line',
resolved_queue_code: 'ivr_sales_ai_kz',
options: [],
},
{
node_id: 'support_kz',
prompt_text: 'Қолдау қызметіне қосып жатырмыз.',
prompt_audio_key: 'ivr/demo-support-kz',
is_terminal: true,
outcome_code: 'support_route_kz',
resolved_queue_id: 'support_line',
resolved_queue_code: 'ivr_support_ai_kz',
options: [],
},
],
};
}
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(defaultVoiceStartIvrFlowDocument(), null, 2);
}
}
function renderUsers(items) {
if (!items.length) {
$('usersList').innerHTML = '<li class="empty-state">Пользователи пока не найдены.</li>';
return;
}
$('usersList').innerHTML = items
.map((item) => `<li><strong>${item.username}</strong><br/>${item.full_name}<br/>роль: ${item.role}<br/>ID: ${item.user_id}</li>`)
.join('');
}
function syncUserEditFields(user) {
if (!user) {
return;
}
$('userLookupId').value = user.user_id || $('userLookupId').value;
$('editUserFullName').value = user.full_name || $('editUserFullName').value;
$('editUserRole').value = user.role || $('editUserRole').value;
}
function renderQueues(items) {
if (!items.length) {
$('queuesList').innerHTML = '<li class="empty-state">Очереди пока не найдены.</li>';
$('routeQueueId').value = '';
$('updateQueueId').value = '';
$('ivrQueueId').value = '';
$('ivrTestQueueId').value = '';
return;
}
$('queuesList').innerHTML = items
.map((item) => `<li><strong>${item.name}</strong><br/>ID: ${item.queue_id}<br/>правил: ${(item.rules || []).length}</li>`)
.join('');
}
function renderIvrFlows(items) {
if (!items.length) {
$('ivrFlowsList').innerHTML = '<li class="empty-state">IVR-сценарии пока не настроены.</li>';
return;
}
$('ivrFlowsList').innerHTML = items
.map(
(item) =>
`<li><strong>${item.name}</strong><br/>ID: ${item.flow_id}<br/>очередь: ${item.queue_id}<br/>активен: ${item.is_active}</li>`,
)
.join('');
}
function renderAsteriskEvents(items) {
if (!items.length) {
$('asteriskEventsList').innerHTML = '<li class="empty-state">Bridge-события Asterisk не найдены.</li>';
return;
}
$('asteriskEventsList').innerHTML = items
.map(
(item) =>
`<li><strong>${item.ami_event_name}</strong><br/>событие: ${item.bridge_event_id}<br/>звонок: ${item.call_id}<br/>статус: ${item.forward_status}</li>`,
)
.join('');
}
function syncAsteriskEventFields(item) {
if (!item) {
return;
}
$('asteriskBridgeEventId').value = item.bridge_event_id || $('asteriskBridgeEventId').value;
}
function syncIvrFlowFields(flow) {
if (!flow) {
return;
}
$('ivrFlowId').value = flow.flow_id || $('ivrFlowId').value;
$('ivrName').value = flow.name || $('ivrName').value;
$('ivrDescription').value = flow.description || $('ivrDescription').value;
$('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 || defaultVoiceStartIvrFlowDocument(), null, 2);
}
function syncIvrSessionFields(payload) {
const session = payload?.session || payload;
if (!session) {
return;
}
$('ivrSessionId').value = session.session_id || $('ivrSessionId').value;
$('ivrTestCallId').value = session.call_id || $('ivrTestCallId').value;
$('ivrTestQueueId').value = session.queue_id || $('ivrTestQueueId').value;
$('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 || '{}');
}
async function loadUsers() {
try {
const data = await api('auth', 'users');
renderUsers(data);
if (data.length && !$('userLookupId').value.trim()) {
$('userLookupId').value = data[0].user_id;
}
log('Список пользователей обновлён', { count: data.length });
} catch (err) {
$('usersList').innerHTML = '<li class="empty-state">Не удалось загрузить пользователей.</li>';
log('Не удалось загрузить пользователей', { error: err.message });
}
}
async function loadUserDetail() {
const userId = $('userLookupId').value.trim();
if (!userId) {
$('userDetailOutput').textContent = 'Укажите user_id для загрузки карточки.';
return;
}
try {
const data = await api('auth', `users/${encodeURIComponent(userId)}`);
$('userDetailOutput').textContent = JSON.stringify(data, null, 2);
syncUserEditFields(data);
log('Карточка пользователя загружена', { user_id: data.user_id, role: data.role });
} catch (err) {
$('userDetailOutput').textContent = err.message;
log('Не удалось загрузить карточку пользователя', { error: err.message });
}
}
async function createUser() {
try {
const data = await api('auth', 'users', {
method: 'POST',
body: JSON.stringify({
username: $('newUsername').value.trim() || 'wave2_admin_demo',
password: $('newPassword').value,
full_name: $('newFullName').value.trim() || 'Wave 2 Demo User',
role: $('newUserRole').value,
}),
});
$('userDetailOutput').textContent = JSON.stringify(data, null, 2);
syncUserEditFields(data);
$('editUserPassword').value = '';
log('Пользователь создан', { username: data.username, role: data.role });
await loadUsers();
} catch (err) {
$('userDetailOutput').textContent = err.message;
log('Не удалось создать пользователя', { error: err.message });
}
}
async function updateUser() {
const userId = $('userLookupId').value.trim();
if (!userId) {
$('userDetailOutput').textContent = 'Укажите user_id для обновления пользователя.';
return;
}
const payload = {
full_name: $('editUserFullName').value.trim() || null,
role: $('editUserRole').value || null,
};
const password = $('editUserPassword').value;
if (password) {
payload.password = password;
}
try {
const data = await api('auth', `users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
$('userDetailOutput').textContent = JSON.stringify(data, null, 2);
syncUserEditFields(data);
$('editUserPassword').value = '';
log('Пользователь обновлён', { user_id: data.user_id, role: data.role });
await loadUsers();
} catch (err) {
$('userDetailOutput').textContent = err.message;
log('Не удалось обновить пользователя', { error: err.message });
}
}
async function deleteUser() {
const userId = $('userLookupId').value.trim();
if (!userId) {
$('userDetailOutput').textContent = 'Укажите user_id для удаления пользователя.';
return;
}
try {
const data = await api('auth', `users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
$('userDetailOutput').textContent = JSON.stringify(data, null, 2);
$('userLookupId').value = '';
$('editUserPassword').value = '';
log('Пользователь удалён', { user_id: data.user_id });
await loadUsers();
} catch (err) {
$('userDetailOutput').textContent = err.message;
log('Не удалось удалить пользователя', { error: err.message });
}
}
async function loadQueues() {
try {
const data = await api('routing', 'queues');
renderQueues(data);
if (data.length) {
const first = data[0];
if (!$('routeQueueId').value.trim()) {
$('routeQueueId').value = first.queue_id;
}
if (!$('updateQueueId').value.trim()) {
$('updateQueueId').value = first.queue_id;
}
if (!$('ivrQueueId').value.trim()) {
$('ivrQueueId').value = first.queue_id;
}
if (!$('ivrTestQueueId').value.trim()) {
$('ivrTestQueueId').value = first.queue_id;
}
}
log('Список очередей обновлён', { count: data.length });
} catch (err) {
$('queuesList').innerHTML = '<li class="empty-state">Не удалось загрузить очереди.</li>';
log('Не удалось загрузить очереди', { error: err.message });
}
}
async function createQueue() {
try {
const payload = {
name: $('queueName').value.trim() || 'Демонстрационная очередь',
description: $('queueDescription').value.trim() || '',
rules: queueRulePayload($('queueChannel').value, $('queueSla').value),
};
const data = await api('routing', 'queues', {
method: 'POST',
body: JSON.stringify(payload),
});
$('routeQueueId').value = data.queue_id;
$('updateQueueId').value = data.queue_id;
if (!$('ivrQueueId').value.trim()) {
$('ivrQueueId').value = data.queue_id;
}
if (!$('ivrTestQueueId').value.trim()) {
$('ivrTestQueueId').value = data.queue_id;
}
$('queueUpdateOutput').textContent = JSON.stringify(data, null, 2);
log('Очередь создана', { queue_id: data.queue_id, channel: $('queueChannel').value });
await loadQueues();
} catch (err) {
$('queueUpdateOutput').textContent = err.message;
log('Не удалось создать очередь', { error: err.message });
}
}
async function updateQueueRules() {
const queueId = $('updateQueueId').value.trim();
if (!queueId) {
$('queueUpdateOutput').textContent = 'Укажите queue_id для обновления.';
return;
}
try {
const payload = {
name: $('updateQueueName').value.trim() || 'Демонстрационная очередь обновлена',
description: $('updateQueueDescription').value.trim() || '',
rules: queueRulePayload($('updateQueueChannel').value, $('updateQueueSla').value),
};
const data = await api('routing', `queues/${encodeURIComponent(queueId)}/rules`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
$('queueUpdateOutput').textContent = JSON.stringify(data, null, 2);
$('routeQueueId').value = data.queue_id;
log('Правила очереди обновлены', { queue_id: data.queue_id, rules: (data.rules || []).length });
await loadQueues();
} catch (err) {
$('queueUpdateOutput').textContent = err.message;
log('Не удалось обновить очередь', { error: err.message });
}
}
async function deleteQueue() {
const queueId = $('updateQueueId').value.trim();
if (!queueId) {
$('queueUpdateOutput').textContent = 'Укажите queue_id для удаления очереди.';
return;
}
try {
const data = await api('routing', `queues/${encodeURIComponent(queueId)}`, {
method: 'DELETE',
});
$('queueUpdateOutput').textContent = JSON.stringify(data, null, 2);
if ($('routeQueueId').value.trim() === queueId) {
$('routeQueueId').value = '';
}
if ($('ivrQueueId').value.trim() === queueId) {
$('ivrQueueId').value = '';
}
if ($('ivrTestQueueId').value.trim() === queueId) {
$('ivrTestQueueId').value = '';
}
$('updateQueueId').value = '';
log('Очередь удалена', { queue_id: data.queue_id });
await loadQueues();
} catch (err) {
$('queueUpdateOutput').textContent = err.message;
log('Не удалось удалить очередь', { error: err.message });
}
}
async function previewRoute() {
try {
const queueId = encodeURIComponent($('routeQueueId').value.trim());
const channel = encodeURIComponent($('routeChannel').value);
const priority = Number($('routePriority').value || 3);
const data = await api('routing', `queues/${queueId}/route?channel=${channel}&priority=${priority}`, {
method: 'POST',
});
$('routeOutput').textContent = JSON.stringify(data, null, 2);
log('Маршрут рассчитан', { queue_id: data.queue_id, assignee: data.assignee });
} catch (err) {
$('routeOutput').textContent = err.message;
log('Не удалось рассчитать маршрут', { error: err.message });
}
}
async function loadIvrFlows() {
try {
const data = await api('ivr', 'ivr/flows');
renderIvrFlows(data);
if (data.length && !$('ivrFlowId').value.trim()) {
syncIvrFlowFields(data[0]);
}
log('IVR-сценарии загружены', { count: data.length });
} catch (err) {
$('ivrFlowsList').innerHTML = '<li class="empty-state">Не удалось загрузить IVR-сценарии.</li>';
$('ivrFlowOutput').textContent = err.message;
log('Не удалось загрузить IVR-сценарии', { error: err.message });
}
}
async function loadIvrFlowDetail() {
const flowId = $('ivrFlowId').value.trim();
if (!flowId) {
$('ivrFlowOutput').textContent = 'Укажите ID сценария для открытия IVR.';
return;
}
try {
const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}`);
syncIvrFlowFields(data);
$('ivrFlowOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сценарий загружен', { flow_id: data.flow_id, active: data.is_active });
} catch (err) {
$('ivrFlowOutput').textContent = err.message;
log('Не удалось загрузить IVR-сценарий', { error: err.message });
}
}
async function createIvrFlow() {
try {
const data = await api('ivr', 'ivr/flows', {
method: 'POST',
body: JSON.stringify({
name: $('ivrName').value.trim() || 'Демонстрационный IVR-сценарий',
description: $('ivrDescription').value.trim() || '',
queue_id: $('ivrQueueId').value.trim(),
entry_node_id: $('ivrEntryNodeId').value.trim() || 'root',
flow_json: parseIvrFlowJson(),
is_active: $('ivrIsActive').checked,
}),
});
syncIvrFlowFields(data);
$('ivrFlowOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сценарий создан', { flow_id: data.flow_id, queue_id: data.queue_id });
await loadIvrFlows();
} catch (err) {
$('ivrFlowOutput').textContent = err.message;
log('Не удалось создать IVR-сценарий', { error: err.message });
}
}
async function updateIvrFlow() {
const flowId = $('ivrFlowId').value.trim();
if (!flowId) {
$('ivrFlowOutput').textContent = 'Укажите ID сценария для обновления IVR.';
return;
}
try {
const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}`, {
method: 'PATCH',
body: JSON.stringify({
name: $('ivrName').value.trim() || null,
description: $('ivrDescription').value.trim() || null,
entry_node_id: $('ivrEntryNodeId').value.trim() || null,
flow_json: parseIvrFlowJson(),
is_active: $('ivrIsActive').checked,
}),
});
syncIvrFlowFields(data);
$('ivrFlowOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сценарий обновлён', { flow_id: data.flow_id, version: data.version });
await loadIvrFlows();
} catch (err) {
$('ivrFlowOutput').textContent = err.message;
log('Не удалось обновить IVR-сценарий', { error: err.message });
}
}
async function activateIvrFlow() {
const flowId = $('ivrFlowId').value.trim();
if (!flowId) {
$('ivrFlowOutput').textContent = 'Укажите ID сценария для активации IVR.';
return;
}
try {
const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}/activate`, {
method: 'POST',
});
syncIvrFlowFields(data);
$('ivrFlowOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сценарий активирован', { flow_id: data.flow_id });
await loadIvrFlows();
} catch (err) {
$('ivrFlowOutput').textContent = err.message;
log('Не удалось активировать IVR-сценарий', { error: err.message });
}
}
async function startIvrSession() {
try {
const data = await api('ivr', 'ivr/sessions/start', {
method: 'POST',
body: JSON.stringify({
call_id: $('ivrTestCallId').value.trim() || 'demo_call_ivr',
queue_id: $('ivrTestQueueId').value.trim(),
interaction_id: $('ivrTestInteractionId').value.trim() || null,
}),
});
syncIvrSessionFields(data);
$('ivrSessionOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сессия запущена', { session_id: data.session.session_id, flow_id: data.session.flow_id });
} catch (err) {
$('ivrSessionOutput').textContent = err.message;
log('Не удалось запустить IVR-сессию', { error: err.message });
}
}
async function sendIvrDigit() {
const sessionId = $('ivrSessionId').value.trim();
if (!sessionId) {
$('ivrSessionOutput').textContent = 'Укажите ID сессии для отправки DTMF.';
return;
}
try {
const data = await api('ivr', `ivr/sessions/${encodeURIComponent(sessionId)}/dtmf`, {
method: 'POST',
body: JSON.stringify({
digit: ($('ivrDigit').value.trim() || '2').slice(0, 1),
}),
});
syncIvrSessionFields(data);
$('ivrSessionOutput').textContent = JSON.stringify(data, null, 2);
log('DTMF обработан', {
session_id: data.session.session_id,
completed: data.completed,
outcome_code: data.session.outcome_code,
});
} catch (err) {
$('ivrSessionOutput').textContent = err.message;
log('Не удалось обработать DTMF', { error: err.message });
}
}
async function loadIvrSession() {
const sessionId = $('ivrSessionId').value.trim();
if (!sessionId) {
$('ivrSessionOutput').textContent = 'Укажите ID сессии для загрузки IVR.';
return;
}
try {
const data = await api('ivr', `ivr/sessions/${encodeURIComponent(sessionId)}`);
syncIvrSessionFields(data);
$('ivrSessionOutput').textContent = JSON.stringify(data, null, 2);
log('IVR-сессия загружена', { session_id: data.session_id, status: data.status });
} catch (err) {
$('ivrSessionOutput').textContent = err.message;
log('Не удалось загрузить IVR-сессию', { error: err.message });
}
}
async function previewIvrRoute() {
const queueId = $('ivrTestQueueId').value.trim();
const sessionId = $('ivrSessionId').value.trim();
if (!queueId || !sessionId) {
$('ivrRouteOutput').textContent = 'Укажите ID очереди и ID сессии для предпросмотра IVR-маршрута.';
return;
}
try {
const data = await api(
'routing',
`queues/${encodeURIComponent(queueId)}/route?channel=voice&priority=3&ivr_session_id=${encodeURIComponent(sessionId)}`,
{
method: 'POST',
},
);
$('ivrRouteOutput').textContent = JSON.stringify(data, null, 2);
log('Предпросмотр IVR-маршрута готов', {
original_queue_id: data.original_queue_id || queueId,
resolved_queue_id: data.resolved_queue_id || data.queue_id,
});
} catch (err) {
$('ivrRouteOutput').textContent = err.message;
log('Не удалось показать IVR-маршрут', { error: err.message });
}
}
async function loadAsteriskStatus() {
try {
const data = await api('asterisk-bridge', 'asterisk/status');
$('asteriskStatusOutput').textContent = JSON.stringify(data, null, 2);
log('Статус моста Asterisk загружен', {
status: data.status,
ami_connected: data.ami_connected,
});
} catch (err) {
$('asteriskStatusOutput').textContent = err.message;
log('Не удалось загрузить статус моста Asterisk', { error: err.message });
}
}
async function loadAsteriskEvents() {
try {
const filter = $('asteriskEventStatus').value;
const suffix = filter ? `?status=${encodeURIComponent(filter)}` : '';
const data = await api('asterisk-bridge', `asterisk/events${suffix}`);
renderAsteriskEvents(data);
if (data.length && !$('asteriskBridgeEventId').value.trim()) {
syncAsteriskEventFields(data[0]);
}
log('Bridge-события Asterisk загружены', { count: data.length, status: filter || 'all' });
} catch (err) {
$('asteriskEventsList').innerHTML = '<li class="empty-state">Не удалось загрузить bridge-события Asterisk.</li>';
$('asteriskEventOutput').textContent = err.message;
log('Не удалось загрузить bridge-события Asterisk', { error: err.message });
}
}
async function loadAsteriskEventDetail() {
const bridgeEventId = $('asteriskBridgeEventId').value.trim();
if (!bridgeEventId) {
$('asteriskEventOutput').textContent = 'Укажите ID bridge-события для открытия.';
return;
}
try {
const data = await api('asterisk-bridge', `asterisk/events/${encodeURIComponent(bridgeEventId)}`);
syncAsteriskEventFields(data);
$('asteriskEventOutput').textContent = JSON.stringify(data, null, 2);
log('Bridge-событие Asterisk загружено', {
bridge_event_id: data.bridge_event_id,
status: data.forward_status,
});
} catch (err) {
$('asteriskEventOutput').textContent = err.message;
log('Не удалось загрузить bridge-событие Asterisk', { error: err.message });
}
}
async function retryAsteriskEvent() {
const bridgeEventId = $('asteriskBridgeEventId').value.trim();
if (!bridgeEventId) {
$('asteriskEventOutput').textContent = 'Укажите ID bridge-события для повтора.';
return;
}
try {
const data = await api('asterisk-bridge', `asterisk/events/${encodeURIComponent(bridgeEventId)}/retry`, {
method: 'POST',
});
syncAsteriskEventFields(data);
$('asteriskEventOutput').textContent = JSON.stringify(data, null, 2);
log('Bridge-событие Asterisk отправлено на повтор', {
bridge_event_id: data.bridge_event_id,
status: data.forward_status,
});
await loadAsteriskEvents();
} catch (err) {
$('asteriskEventOutput').textContent = err.message;
log('Не удалось повторить bridge-событие Asterisk', { error: err.message });
}
}
async function reconnectAsterisk() {
try {
const data = await api('asterisk-bridge', 'asterisk/reconnect', {
method: 'POST',
});
$('asteriskStatusOutput').textContent = JSON.stringify(data, null, 2);
log('Запрошено переподключение моста Asterisk', {
status: data.status,
ami_connected: data.ami_connected,
});
} catch (err) {
$('asteriskStatusOutput').textContent = err.message;
log('Не удалось запросить переподключение Asterisk', { error: err.message });
}
}
function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await Promise.all([
loadUsers(),
loadQueues(),
loadIvrFlows(),
loadVoiceNameConfig(),
loadAsteriskStatus(),
loadAsteriskEvents(),
]);
log('Данные админ-консоли обновлены');
});
$('loadUsersBtn').addEventListener('click', loadUsers);
$('loadUserDetailBtn').addEventListener('click', loadUserDetail);
$('createUserBtn').addEventListener('click', createUser);
$('updateUserBtn').addEventListener('click', updateUser);
$('deleteUserBtn').addEventListener('click', deleteUser);
$('loadQueuesBtn').addEventListener('click', loadQueues);
$('createQueueBtn').addEventListener('click', createQueue);
$('updateQueueBtn').addEventListener('click', updateQueueRules);
$('deleteQueueBtn').addEventListener('click', deleteQueue);
$('previewRouteBtn').addEventListener('click', previewRoute);
$('loadIvrFlowsBtn').addEventListener('click', loadIvrFlows);
$('createIvrFlowBtn').addEventListener('click', createIvrFlow);
$('loadIvrFlowDetailBtn').addEventListener('click', loadIvrFlowDetail);
$('updateIvrFlowBtn').addEventListener('click', updateIvrFlow);
$('activateIvrFlowBtn').addEventListener('click', activateIvrFlow);
$('startIvrSessionBtn').addEventListener('click', startIvrSession);
$('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);
$('retryAsteriskEventBtn').addEventListener('click', retryAsteriskEvent);
$('reconnectAsteriskBtn').addEventListener('click', reconnectAsterisk);
$('logoutBtn').addEventListener('click', logout);
window.addEventListener('message', handleOidcMessage);
}
async function init() {
if (!restoreStoredSession()) {
window.location.href = '/';
return;
}
if (!ensurePageAccess()) {
return;
}
wire();
ensureDefaultIvrFlowJson();
updateSessionInfo();
await Promise.all([
checkGateway(),
loadOidcConfig(),
loadUsers(),
loadQueues(),
loadIvrFlows(),
loadVoiceNameConfig(),
loadAsteriskStatus(),
loadAsteriskEvents(),
]);
log('Админ-консоль готова');
}
init();