.
This commit is contained in:
-1418
File diff suppressed because it is too large
Load Diff
@@ -1,506 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Администрирование</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">K</div>
|
|
||||||
<div>
|
|
||||||
<div class="brand-title">KonturCC</div>
|
|
||||||
<div class="brand-sub">Панель управления</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav-menu">
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Рабочие зоны</div>
|
|
||||||
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
|
||||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
|
||||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
|
||||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator,analyst" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
|
||||||
<a class="nav-link active" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Блоки</div>
|
|
||||||
<a class="nav-link" href="#users"><span class="nav-bullet"></span>Пользователи</a>
|
|
||||||
<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="#voiceTtsConfig"><span class="nav-bullet"></span>Voice TTS</a>
|
|
||||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<strong>Экран администратора</strong>
|
|
||||||
<p>Управление пользователями, очередями и правилами маршрутизации в одном экране.</p>
|
|
||||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="workspace">
|
|
||||||
<header class="workspace-topbar reveal">
|
|
||||||
<div class="search-box">
|
|
||||||
<input class="app-search" placeholder="Найти пользователя, очередь или правило..." />
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
|
|
||||||
<button class="icon-button" type="button" aria-label="Уведомления">•</button>
|
|
||||||
<div class="profile-chip">
|
|
||||||
<div class="avatar-badge">AD</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<div class="profile-name" id="profileName">Экран администратора</div>
|
|
||||||
<div class="profile-role" id="profileRole">Панель управления</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
|
||||||
<section class="page-hero reveal">
|
|
||||||
<p class="eyebrow">Административный контур</p>
|
|
||||||
<h1>Панель администратора</h1>
|
|
||||||
<p class="hint">Управление пользователями, очередями и предпросмотром маршрутизации без технического режима.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel auth reveal" id="authPanel">
|
|
||||||
<h2>Сессия</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пользователь</label>
|
|
||||||
<input id="sessionUser" value="admin" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Роль</label>
|
|
||||||
<select id="sessionRole">
|
|
||||||
<option value="admin" selected>Администратор</option>
|
|
||||||
<option value="supervisor">Супервизор</option>
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пароль</label>
|
|
||||||
<input id="sessionPassword" type="password" value="admin123" />
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn" class="btn">Войти</button>
|
|
||||||
<button id="corporateLoginBtn" class="btn ghost" type="button">Корпоративный вход</button>
|
|
||||||
<button id="refreshBtn" class="btn ghost">Обновить всё</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid">
|
|
||||||
<article class="panel reveal" id="users">
|
|
||||||
<h2>Пользователи</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="newUsername" placeholder="Логин" value="wave2_admin_demo" />
|
|
||||||
<select id="newUserRole">
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="supervisor">Супервизор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
<option value="admin">Администратор</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="newFullName" placeholder="ФИО" value="Wave 2 Demo User" />
|
|
||||||
<input id="newPassword" type="password" placeholder="Пароль" value="wave2pass" />
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadUsersBtn" class="btn ghost">Показать пользователей</button>
|
|
||||||
<button id="createUserBtn" class="btn">Создать пользователя</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="userLookupId" placeholder="ID пользователя для карточки" />
|
|
||||||
<button id="loadUserDetailBtn" class="btn ghost">Открыть карточку</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="editUserFullName" placeholder="Новое ФИО" value="Wave 2 Demo User Updated" />
|
|
||||||
<select id="editUserRole">
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="supervisor">Супервизор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
<option value="admin">Администратор</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="editUserPassword" type="password" placeholder="Новый пароль (необязательно)" />
|
|
||||||
<button id="updateUserBtn" class="btn ghost">Обновить пользователя</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="deleteUserBtn" class="btn ghost">Удалить пользователя</button>
|
|
||||||
</div>
|
|
||||||
<pre id="userDetailOutput" class="output">Карточка пользователя появится здесь.</pre>
|
|
||||||
<ul id="usersList" class="list"></ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="queues">
|
|
||||||
<h2>Очереди</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="queueName" placeholder="Название очереди" value="Демонстрационная очередь" />
|
|
||||||
<select id="queueChannel">
|
|
||||||
<option value="voice">Голос</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="webchat">Веб-чат</option>
|
|
||||||
<option value="email">Электронная почта</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="queueDescription" placeholder="Описание" value="Очередь для экрана администратора" />
|
|
||||||
<input id="queueSla" placeholder="SLA (сек)" type="number" value="45" />
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadQueuesBtn" class="btn ghost">Показать очереди</button>
|
|
||||||
<button id="createQueueBtn" class="btn">Создать очередь</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="updateQueueId" placeholder="ID очереди для обновления" />
|
|
||||||
<select id="updateQueueChannel">
|
|
||||||
<option value="voice">Голос</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="webchat">Веб-чат</option>
|
|
||||||
<option value="email">Электронная почта</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="updateQueueName" placeholder="Новое имя" value="Демонстрационная очередь обновлена" />
|
|
||||||
<input id="updateQueueDescription" placeholder="Новое описание" value="Обновлено из экрана администратора" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="updateQueueSla" placeholder="Новый SLA (сек)" type="number" value="60" />
|
|
||||||
<button id="updateQueueBtn" class="btn ghost">Обновить правила</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="deleteQueueBtn" class="btn ghost">Удалить очередь</button>
|
|
||||||
</div>
|
|
||||||
<pre id="queueUpdateOutput" class="output">Результат обновления очереди появится здесь.</pre>
|
|
||||||
<ul id="queuesList" class="list"></ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="routes">
|
|
||||||
<h2>Предпросмотр маршрута</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="routeQueueId" placeholder="ID очереди" />
|
|
||||||
<select id="routeChannel">
|
|
||||||
<option value="voice">Голос</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="webchat">Веб-чат</option>
|
|
||||||
<option value="email">Электронная почта</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="routePriority" placeholder="Приоритет" type="number" value="3" />
|
|
||||||
<button id="previewRouteBtn" class="btn">Рассчитать маршрут</button>
|
|
||||||
</div>
|
|
||||||
<pre id="routeOutput" class="output">Результат маршрутизации появится здесь.</pre>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="ivr">
|
|
||||||
<h2>IVR</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="ivrName" placeholder="Название сценария" value="Демонстрационный IVR-сценарий" />
|
|
||||||
<input id="ivrQueueId" placeholder="ID очереди" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="ivrDescription" placeholder="Описание" value="DTMF-сценарий для голосовой маршрутизации" />
|
|
||||||
<input id="ivrEntryNodeId" placeholder="ID входного узла" value="root" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="ivrFlowId" placeholder="ID сценария для просмотра или обновления" />
|
|
||||||
<label class="checkbox-label"><input id="ivrIsActive" type="checkbox" checked /> Активен</label>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="ivrFlowJson">JSON сценария</label>
|
|
||||||
<textarea id="ivrFlowJson" rows="12" class="json-textarea"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadIvrFlowsBtn" class="btn ghost">Показать сценарии</button>
|
|
||||||
<button id="createIvrFlowBtn" class="btn">Создать сценарий</button>
|
|
||||||
<button id="loadIvrFlowDetailBtn" class="btn ghost">Открыть сценарий</button>
|
|
||||||
<button id="updateIvrFlowBtn" class="btn ghost">Обновить сценарий</button>
|
|
||||||
<button id="activateIvrFlowBtn" class="btn ghost">Активировать сценарий</button>
|
|
||||||
</div>
|
|
||||||
<ul id="ivrFlowsList" class="list"></ul>
|
|
||||||
<pre id="ivrFlowOutput" class="output">Детали IVR-сценария появятся здесь.</pre>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="ivrTestCallId" placeholder="ID звонка" value="demo_call_ivr" />
|
|
||||||
<input id="ivrTestInteractionId" placeholder="ID обращения (необязательно)" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="ivrTestQueueId" placeholder="ID очереди для старта" />
|
|
||||||
<input id="ivrSessionId" placeholder="ID сессии" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="ivrDigit" placeholder="Цифра" value="2" />
|
|
||||||
<button id="startIvrSessionBtn" class="btn">Запустить сессию</button>
|
|
||||||
<button id="sendIvrDigitBtn" class="btn ghost">Отправить DTMF</button>
|
|
||||||
<button id="loadIvrSessionBtn" class="btn ghost">Открыть сессию</button>
|
|
||||||
<button id="previewIvrRouteBtn" class="btn ghost">Показать IVR-маршрут</button>
|
|
||||||
</div>
|
|
||||||
<pre id="ivrSessionOutput" class="output">Состояние IVR-сессии появится здесь.</pre>
|
|
||||||
<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="voiceTtsConfig">
|
|
||||||
<h2>Voice AI: TTS</h2>
|
|
||||||
<p class="hint">Выберите провайдера и голоса для RU и KZ. Для ElevenLabs в поле голоса хранится voice ID, для Brian уже есть готовый preset.</p>
|
|
||||||
<pre id="voiceTtsSummary" class="output">Сводка по voice TTS появится здесь.</pre>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
Активный провайдер
|
|
||||||
<select id="voiceTtsProvider">
|
|
||||||
<option value="yandex">Yandex SpeechKit</option>
|
|
||||||
<option value="elevenlabs">ElevenLabs</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="hint">RU и KZ можно настроить отдельно, а провайдер переключать из админки без правки env.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label>Yandex</label>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
RU preset
|
|
||||||
<select id="voiceTtsYandexRuVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
RU voice
|
|
||||||
<input id="voiceTtsYandexRuVoice" placeholder="jane" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
KZ preset
|
|
||||||
<select id="voiceTtsYandexKzVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
KZ voice
|
|
||||||
<input id="voiceTtsYandexKzVoice" placeholder="amira" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label>ElevenLabs</label>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
RU preset
|
|
||||||
<select id="voiceTtsElevenRuVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
RU voice ID
|
|
||||||
<input id="voiceTtsElevenRuVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
RU model
|
|
||||||
<input id="voiceTtsElevenRuModel" placeholder="eleven_v3" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
RU language_code
|
|
||||||
<input id="voiceTtsElevenRuLanguageCode" placeholder="ru" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
KZ preset
|
|
||||||
<select id="voiceTtsElevenKzVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
KZ voice ID
|
|
||||||
<input id="voiceTtsElevenKzVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
KZ model
|
|
||||||
<input id="voiceTtsElevenKzModel" placeholder="eleven_v3" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
KZ language_code
|
|
||||||
<input id="voiceTtsElevenKzLanguageCode" placeholder="kk" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label>OpenAI</label>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
RU preset
|
|
||||||
<select id="voiceTtsOpenAiRuVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
RU voice
|
|
||||||
<input id="voiceTtsOpenAiRuVoice" placeholder="alloy" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
RU model
|
|
||||||
<input id="voiceTtsOpenAiRuModel" placeholder="gpt-4o-mini-tts" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
RU language_code
|
|
||||||
<input id="voiceTtsOpenAiRuLanguageCode" placeholder="ru" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
KZ preset
|
|
||||||
<select id="voiceTtsOpenAiKzVoicePreset"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
KZ voice
|
|
||||||
<input id="voiceTtsOpenAiKzVoice" placeholder="alloy" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<label>
|
|
||||||
KZ model
|
|
||||||
<input id="voiceTtsOpenAiKzModel" placeholder="gpt-4o-mini-tts" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
KZ language_code
|
|
||||||
<input id="voiceTtsOpenAiKzLanguageCode" placeholder="kz" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadVoiceTtsConfigBtn" class="btn ghost">Загрузить настройки</button>
|
|
||||||
<button id="saveVoiceTtsConfigBtn" class="btn">Сохранить настройки</button>
|
|
||||||
<button id="resetVoiceTtsConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
|
||||||
</div>
|
|
||||||
<pre id="voiceTtsConfigOutput" class="output">Raw payload voice TTS появится здесь.</pre>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="asterisk">
|
|
||||||
<h2>Мост Asterisk</h2>
|
|
||||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadAsteriskStatusBtn" class="btn">Показать статус</button>
|
|
||||||
<button id="loadAsteriskEventsBtn" class="btn ghost">Показать события</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<select id="asteriskEventStatus">
|
|
||||||
<option value="">Все статусы</option>
|
|
||||||
<option value="received">Получено</option>
|
|
||||||
<option value="forwarded">Переслано</option>
|
|
||||||
<option value="failed">Ошибка</option>
|
|
||||||
</select>
|
|
||||||
<input id="asteriskBridgeEventId" placeholder="ID bridge-события для просмотра или повтора" />
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadAsteriskEventDetailBtn" class="btn ghost">Открыть событие</button>
|
|
||||||
<button id="retryAsteriskEventBtn" class="btn ghost">Повторить событие</button>
|
|
||||||
<button id="reconnectAsteriskBtn" class="btn ghost">Переподключить AMI</button>
|
|
||||||
</div>
|
|
||||||
<pre id="asteriskStatusOutput" class="output">Статус моста Asterisk появится здесь.</pre>
|
|
||||||
<ul id="asteriskEventsList" class="list"></ul>
|
|
||||||
<pre id="asteriskEventOutput" class="output">Детали bridge-события Asterisk появятся здесь.</pre>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal">
|
|
||||||
<h2>Лог действий</h2>
|
|
||||||
<p class="hint">Сверху всегда последнее действие администратора.</p>
|
|
||||||
<pre id="eventLog" class="output tall"></pre>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/admin/assets/app.js?v=track35-admin-ru-polish"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-7927
File diff suppressed because it is too large
Load Diff
@@ -1,454 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Аналитика</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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=track45-voice-name-flow-v1" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">K</div>
|
|
||||||
<div>
|
|
||||||
<div class="brand-title">KonturCC</div>
|
|
||||||
<div class="brand-sub">Центр аналитики</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav-menu">
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Рабочие зоны</div>
|
|
||||||
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
|
||||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
|
||||||
<a class="nav-link active" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
|
||||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator,analyst" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
|
||||||
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<strong>Рабочее место аналитика</strong>
|
|
||||||
<p>Историческая картина по обращениям, каналам, очередям и качеству сценариев без перехода в операционный экран.</p>
|
|
||||||
<p class="hint" id="sessionInfo">Сессия проверяется...</p>
|
|
||||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="workspace">
|
|
||||||
<header class="workspace-topbar reveal">
|
|
||||||
<div class="analytics-topbar-intro">
|
|
||||||
<strong>Историческая аналитика сервиса</strong>
|
|
||||||
<p>Периоды, каналы, очереди, AI и агентские срезы в одном окне.</p>
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<div class="status-pill" id="gatewayStatus">Данные: проверка...</div>
|
|
||||||
<button id="refreshBtn" class="btn ghost" type="button">Обновить данные</button>
|
|
||||||
<div class="profile-chip">
|
|
||||||
<div class="avatar-badge">AN</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<div class="profile-name" id="profileName">Центр аналитики</div>
|
|
||||||
<div class="profile-role" id="profileRole">Историческая аналитика</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
|
||||||
<section class="page-hero reveal">
|
|
||||||
<p class="eyebrow">История сервиса</p>
|
|
||||||
<h1>Аналитика контакт-центра</h1>
|
|
||||||
<p class="hint">Исторические KPI, тренды и структура потока по каналам и очередям в одной витрине.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel reveal analytics-dashboard-panel" id="analyticsPanel">
|
|
||||||
<div class="analytics-head">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Сводка периода</p>
|
|
||||||
<h2>Ключевые метрики и тренды</h2>
|
|
||||||
<p class="hint">Сравнивайте выбранный период с предыдущим окном и быстро переключайтесь между очередями и каналами.</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="analyticsRefreshBtn" class="btn" type="button">Обновить данные</button>
|
|
||||||
<button id="analyticsResetBtn" class="btn ghost" type="button">Сбросить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="analytics-filter-bar">
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsPreset">Период</label>
|
|
||||||
<select id="analyticsPreset">
|
|
||||||
<option value="today">Сегодня</option>
|
|
||||||
<option value="7d" selected>7 дней</option>
|
|
||||||
<option value="30d">30 дней</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsFrom">С</label>
|
|
||||||
<input id="analyticsFrom" type="datetime-local" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsTo">По</label>
|
|
||||||
<input id="analyticsTo" type="datetime-local" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsQueue">Очередь</label>
|
|
||||||
<select id="analyticsQueue">
|
|
||||||
<option value="all">Все очереди</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsChannel">Канал</label>
|
|
||||||
<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>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="analytics-toolkit">
|
|
||||||
<div class="analytics-toolkit-group">
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsCompareMode">Сравнение</label>
|
|
||||||
<select id="analyticsCompareMode">
|
|
||||||
<option value="previous" selected>С предыдущим окном</option>
|
|
||||||
<option value="off">Только текущий срез</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p id="analyticsSavedViewMeta" class="hint analytics-inline-meta">Сохранённых видов пока нет.</p>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-toolkit-group analytics-toolkit-group-wide">
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsSavedViewSelect">Сохранённый вид</label>
|
|
||||||
<select id="analyticsSavedViewSelect">
|
|
||||||
<option value="">Без сохранённого вида</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsSavedViewName">Название вида</label>
|
|
||||||
<input id="analyticsSavedViewName" type="text" placeholder="Например, Telegram за 30 дней" />
|
|
||||||
</div>
|
|
||||||
<div class="actions analytics-toolkit-actions">
|
|
||||||
<button id="analyticsSaveViewBtn" class="btn ghost" type="button">Сохранить вид</button>
|
|
||||||
<button id="analyticsDeleteViewBtn" class="btn ghost" type="button">Удалить</button>
|
|
||||||
<button id="analyticsExportBtn" class="btn" type="button">Экспорт CSV</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="hint analytics-filter-note" id="analyticsRangeHint">Выберите период и при необходимости уточните канал или очередь.</p>
|
|
||||||
<div id="analyticsEmptyState" class="analytics-empty-state" hidden></div>
|
|
||||||
|
|
||||||
<div id="analyticsNarrative" class="analytics-narrative"></div>
|
|
||||||
<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>
|
|
||||||
<p class="eyebrow">AI-аналитика</p>
|
|
||||||
<h3>AI-сессии в цифровых каналах</h3>
|
|
||||||
<p class="hint">Отдельный блок по Telegram и WhatsApp: закрытие AI, передача оператору, задержка ответа и закрытия без оператора без доступа к текстам сообщений.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row analytics-trend-selector">
|
|
||||||
<label for="aiAnalyticsTrendMetric">AI-метрика</label>
|
|
||||||
<select id="aiAnalyticsTrendMetric">
|
|
||||||
<option value="containment_rate">Закрыто AI</option>
|
|
||||||
<option value="handoff_rate">Передано оператору</option>
|
|
||||||
<option value="ai_latency_avg_ms">Задержка AI</option>
|
|
||||||
<option value="closed_without_operator_rate">Закрыто без оператора</option>
|
|
||||||
<option value="human_touched_rate">С участием оператора</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="aiAnalyticsEmptyState" class="analytics-empty-state" hidden></div>
|
|
||||||
<div id="aiAnalyticsOverview" class="summary-grid analytics-overview-grid ai-analytics-overview"></div>
|
|
||||||
|
|
||||||
<div class="analytics-sections">
|
|
||||||
<article class="analytics-subpanel analytics-subpanel-wide">
|
|
||||||
<h3>AI-тренд по периоду</h3>
|
|
||||||
<p class="hint">Динамика AI по выбранной метрике строится по тем же фильтрам периода, канала и очереди, что и основная витрина.</p>
|
|
||||||
<div id="aiAnalyticsTrendChart" class="analytics-chart-shell"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="analytics-subpanel">
|
|
||||||
<h3>Покрытие AI-данных</h3>
|
|
||||||
<p class="hint">Проверяем, насколько AI-сессии связаны с обращениями, очередями и данными по задержке ответа для корректной интерпретации отчёта.</p>
|
|
||||||
<div id="aiAnalyticsCoverage" class="analytics-coverage"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="analytics-subpanel">
|
|
||||||
<h3>AI и оператор по каналам</h3>
|
|
||||||
<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>
|
|
||||||
<p class="hint">Клик по исходу открывает полноэкранную детализацию по AI-сессиям только на уровне метаданных.</p>
|
|
||||||
<div id="aiAnalyticsOutcomeTable" class="analytics-table-shell"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="analytics-subpanel">
|
|
||||||
<h3>Причины передачи оператору</h3>
|
|
||||||
<p class="hint">Нажмите на причину, чтобы открыть срез по передачам оператору с той же группой причин.</p>
|
|
||||||
<div id="aiAnalyticsReasonTable" class="analytics-table-shell"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="analytics-agent-panel" id="agentAnalyticsPanel">
|
|
||||||
<div class="analytics-subhead">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Агенты</p>
|
|
||||||
<h3>Агентская аналитика</h3>
|
|
||||||
<p class="hint">Нагрузка, обработка и текущие состояния агентов по фактам reporting и данным supervisor без локальных заглушек.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
<div>
|
|
||||||
<h3>Тренд по агентам</h3>
|
|
||||||
<p class="hint">Динамика по агентам помогает оценить нагрузку, стабильность обработки и изменение FCR по выбранному окну.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row analytics-trend-selector">
|
|
||||||
<label for="agentAnalyticsTrendMetric">Метрика</label>
|
|
||||||
<select id="agentAnalyticsTrendMetric">
|
|
||||||
<option value="interactions_per_agent">Нагрузка на агента</option>
|
|
||||||
<option value="agents_with_activity">Агенты с активностью</option>
|
|
||||||
<option value="avg_handle_seconds">AHT</option>
|
|
||||||
<option value="fcr_rate">FCR</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="agentAnalyticsTrendChart" class="analytics-chart-shell"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="analytics-subpanel">
|
|
||||||
<h3>Команды и очереди</h3>
|
|
||||||
<p class="hint">Сводка по командам и очередям, где сейчас сосредоточена агентская нагрузка.</p>
|
|
||||||
<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>
|
|
||||||
<p class="hint">Сравнение ночной, дневной и вечерней смен по обращениям, обработке и FCR.</p>
|
|
||||||
<div id="agentAnalyticsShiftTable" class="analytics-table-shell"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="analytics-sections">
|
|
||||||
<article class="analytics-subpanel analytics-subpanel-wide" id="analyticsTrendBlock">
|
|
||||||
<div class="analytics-subhead">
|
|
||||||
<div>
|
|
||||||
<h3>Тренд по периоду</h3>
|
|
||||||
<p class="hint">Динамика по выбранной метрике помогает быстро увидеть изменение нагрузки и качества.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row analytics-trend-selector">
|
|
||||||
<label for="analyticsTrendMetric">Метрика</label>
|
|
||||||
<select id="analyticsTrendMetric">
|
|
||||||
<option value="volume">Обращения</option>
|
|
||||||
<option value="SL">SL</option>
|
|
||||||
<option value="ASA">Ожидание</option>
|
|
||||||
<option value="Abandon">Потери</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsTrendChart" class="analytics-chart-shell"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="analytics-subpanel">
|
|
||||||
<h3>Каналы</h3>
|
|
||||||
<p class="hint">Сравнение каналов по объёму, обработке и доле ответа за выбранный период.</p>
|
|
||||||
<p class="hint analytics-drilldown-note">Нажмите на строку, чтобы открыть точную детализацию по обращениям этого канала.</p>
|
|
||||||
<div id="analyticsChannelTable" class="analytics-table-shell"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="analytics-sections">
|
|
||||||
<article class="analytics-subpanel analytics-subpanel-wide" id="analyticsQueuesBlock">
|
|
||||||
<h3>Очереди</h3>
|
|
||||||
<p class="hint">Очереди отсортированы по объёму. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.</p>
|
|
||||||
<p class="hint analytics-drilldown-note">Клик по строке открывает точный срез по очереди и не меняет фильтры витрины.</p>
|
|
||||||
<div id="analyticsQueueTable" class="analytics-table-shell"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="analyticsDrilldownBackdrop" class="analytics-drawer-backdrop" hidden></div>
|
|
||||||
<aside
|
|
||||||
id="analyticsDrilldownDrawer"
|
|
||||||
class="analytics-drawer"
|
|
||||||
hidden
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="analyticsDrilldownTitle"
|
|
||||||
aria-describedby="analyticsDrilldownMeta"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<div class="analytics-drawer-head">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Детализация обращений</p>
|
|
||||||
<h2 id="analyticsDrilldownTitle">Детализация обращений</h2>
|
|
||||||
<p id="analyticsDrilldownMeta" class="hint">Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.</p>
|
|
||||||
</div>
|
|
||||||
<button id="analyticsDrilldownCloseBtn" class="btn ghost" type="button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="analyticsDrilldownFilters" class="analytics-filter-chips analytics-drawer-filters"></div>
|
|
||||||
|
|
||||||
<div class="analytics-drawer-toolbar">
|
|
||||||
<div class="row analytics-drawer-toolbar-search">
|
|
||||||
<label for="analyticsDrilldownSearch">Поиск</label>
|
|
||||||
<input id="analyticsDrilldownSearch" type="search" placeholder="ID, тема или назначенный" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsDrilldownStatus">Статус</label>
|
|
||||||
<select id="analyticsDrilldownStatus">
|
|
||||||
<option value="all" selected>Все статусы</option>
|
|
||||||
<option value="new">Новая</option>
|
|
||||||
<option value="in_progress">В работе</option>
|
|
||||||
<option value="escalated">Эскалация</option>
|
|
||||||
<option value="closed">Закрыта</option>
|
|
||||||
<option value="abandoned">Потеряна</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="analyticsDrilldownSort">Сортировка</label>
|
|
||||||
<select id="analyticsDrilldownSort">
|
|
||||||
<option value="created_at:desc" selected>Создано: новые сверху</option>
|
|
||||||
<option value="created_at:asc">Создано: старые сверху</option>
|
|
||||||
<option value="updated_at:desc">Обновлено: новые сверху</option>
|
|
||||||
<option value="updated_at:asc">Обновлено: старые сверху</option>
|
|
||||||
<option value="subject:asc">Тема: А-Я</option>
|
|
||||||
<option value="subject:desc">Тема: Я-А</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions analytics-drawer-toolbar-actions">
|
|
||||||
<button id="analyticsDrilldownClearBtn" class="btn ghost" type="button">Сбросить</button>
|
|
||||||
<button id="analyticsDrilldownExportBtn" class="btn" type="button">Экспорт CSV</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="analytics-drawer-body">
|
|
||||||
<section class="analytics-drawer-list-shell">
|
|
||||||
<div class="analytics-drawer-list-head">
|
|
||||||
<strong>Список обращений</strong>
|
|
||||||
<span id="analyticsDrilldownPageMeta" class="hint">0 из 0</span>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsDrilldownList" class="analytics-drawer-list"></div>
|
|
||||||
<div class="analytics-drawer-pager">
|
|
||||||
<button id="analyticsDrilldownPrevBtn" class="btn ghost" type="button">Назад</button>
|
|
||||||
<button id="analyticsDrilldownNextBtn" class="btn ghost" type="button">Дальше</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="analytics-drawer-detail-shell">
|
|
||||||
<div class="analytics-drawer-list-head">
|
|
||||||
<strong>Карточка обращения</strong>
|
|
||||||
<span class="hint">Лента событий и базовые поля без записей, транскриптов и канального контента.</span>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsDrilldownDetail" class="analytics-drawer-detail"></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<input id="sessionUser" type="hidden" value="analyst" />
|
|
||||||
<input id="sessionRole" type="hidden" value="analyst" />
|
|
||||||
|
|
||||||
<script src="/analyst/assets/app.js?v=track45-voice-name-flow-v1"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
{
|
|
||||||
"queue_options": [
|
|
||||||
{ "queue_id": "sales_kz", "name": "Продажи KZ" },
|
|
||||||
{ "queue_id": "support_kz", "name": "Поддержка KZ" },
|
|
||||||
{ "queue_id": "vip_kz", "name": "VIP-линия" },
|
|
||||||
{ "queue_id": "retention_kz", "name": "Удержание" }
|
|
||||||
],
|
|
||||||
"agents": [
|
|
||||||
"agent.aigerim",
|
|
||||||
"agent.dias",
|
|
||||||
"agent.nurdaulet",
|
|
||||||
"agent.madina",
|
|
||||||
"agent.ruslan",
|
|
||||||
"agent.tomiris"
|
|
||||||
],
|
|
||||||
"queue_factors": {
|
|
||||||
"sales_kz": 1.08,
|
|
||||||
"support_kz": 0.96,
|
|
||||||
"vip_kz": 0.58,
|
|
||||||
"retention_kz": 0.72
|
|
||||||
},
|
|
||||||
"channel_base": {
|
|
||||||
"voice": { "total": 620, "answered": 584, "abandoned": 36 },
|
|
||||||
"telegram": { "total": 214, "answered": 205, "abandoned": 9 },
|
|
||||||
"whatsapp": { "total": 182, "answered": 171, "abandoned": 11 },
|
|
||||||
"webchat": { "total": 141, "answered": 132, "abandoned": 9 },
|
|
||||||
"email": { "total": 108, "answered": 94, "abandoned": 14 }
|
|
||||||
},
|
|
||||||
"ai_reason_labels": {
|
|
||||||
"requested_human": "Запрос оператора",
|
|
||||||
"knowledge_or_tool_gap": "Не хватило знаний или инструмента",
|
|
||||||
"policy_or_sensitive": "Чувствительный сценарий",
|
|
||||||
"delivery_or_runtime_error": "Ошибка доставки или runtime",
|
|
||||||
"manual_claim": "Ручной takeover",
|
|
||||||
"other": "Другая причина"
|
|
||||||
},
|
|
||||||
"interaction_statuses": [
|
|
||||||
"closed",
|
|
||||||
"in_progress",
|
|
||||||
"closed",
|
|
||||||
"new",
|
|
||||||
"closed",
|
|
||||||
"escalated"
|
|
||||||
],
|
|
||||||
"agent_state_cycle": [
|
|
||||||
"READY",
|
|
||||||
"BUSY",
|
|
||||||
"READY",
|
|
||||||
"BREAK",
|
|
||||||
"BUSY",
|
|
||||||
"OFFLINE"
|
|
||||||
],
|
|
||||||
"coverage_note": "Показаны демонстрационные данные для временного наполнения витрины.",
|
|
||||||
"dashboard_coverage": {
|
|
||||||
"implemented_metrics": ["SL", "ASA", "AHT", "Abandon", "FCR", "DigitalShare"],
|
|
||||||
"dimensions": ["queue", "channel", "agent", "shift"],
|
|
||||||
"supported_filters": ["preset", "from_ts", "to_ts", "queue_id", "channel"]
|
|
||||||
},
|
|
||||||
"ai_channel_rows": [
|
|
||||||
{
|
|
||||||
"channel": "telegram",
|
|
||||||
"sessions_started": 176,
|
|
||||||
"ai_only_sessions": 106,
|
|
||||||
"human_touched_sessions": 58,
|
|
||||||
"handoff_rate": 24.4,
|
|
||||||
"ai_latency_avg_ms": 1380,
|
|
||||||
"containment_rate": 60.2,
|
|
||||||
"closed_without_operator_rate": 52.1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "whatsapp",
|
|
||||||
"sessions_started": 142,
|
|
||||||
"ai_only_sessions": 80,
|
|
||||||
"human_touched_sessions": 54,
|
|
||||||
"handoff_rate": 28.9,
|
|
||||||
"ai_latency_avg_ms": 1524,
|
|
||||||
"containment_rate": 56.4,
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"queues": [
|
|
||||||
{
|
|
||||||
"queue_id": "sales_kz",
|
|
||||||
"share": 0.31,
|
|
||||||
"start_capture_rate": 68.4,
|
|
||||||
"downstream_rescue_rate": 49.6,
|
|
||||||
"handoff_unconfirmed_rate": 24.1,
|
|
||||||
"manual_correction_rate": 11.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"queue_id": "support_kz",
|
|
||||||
"share": 0.39,
|
|
||||||
"start_capture_rate": 61.2,
|
|
||||||
"downstream_rescue_rate": 46.9,
|
|
||||||
"handoff_unconfirmed_rate": 31.8,
|
|
||||||
"manual_correction_rate": 15.4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"queue_id": "vip_kz",
|
|
||||||
"share": 0.11,
|
|
||||||
"start_capture_rate": 73.5,
|
|
||||||
"downstream_rescue_rate": 52.8,
|
|
||||||
"handoff_unconfirmed_rate": 18.6,
|
|
||||||
"manual_correction_rate": 8.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"queue_id": "retention_kz",
|
|
||||||
"share": 0.19,
|
|
||||||
"start_capture_rate": 55.1,
|
|
||||||
"downstream_rescue_rate": 39.4,
|
|
||||||
"handoff_unconfirmed_rate": 37.9,
|
|
||||||
"manual_correction_rate": 19.7
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"coverage": {
|
|
||||||
"sessions_with_start_decision": 192,
|
|
||||||
"sessions_with_final_ai_state": 184,
|
|
||||||
"sessions_with_manual_overlay": 12,
|
|
||||||
"note": "Мок-данные по voice name-flow показывают стартовый захват имени, добор AI и ручное исправление оператором."
|
|
||||||
},
|
|
||||||
"trend_profiles": {
|
|
||||||
"scenario_calls": { "base": 24, "step": 2.4, "cycle": 4 },
|
|
||||||
"start_capture_rate": { "base": 60.5, "step": 2.2, "cycle": 4 },
|
|
||||||
"downstream_rescue_rate": { "base": 40.2, "step": 1.9, "cycle": 5 },
|
|
||||||
"handoff_unconfirmed_rate": { "base": 33.1, "step": 2.4, "cycle": 4 },
|
|
||||||
"manual_correction_rate": { "base": 14.4, "step": 1.5, "cycle": 3 }
|
|
||||||
},
|
|
||||||
"empty_note": "За выбранный период нет звонков, прошедших через сценарий сбора имени."
|
|
||||||
},
|
|
||||||
"voice_name_labels": {
|
|
||||||
"scenario_calls": "Звонки в сценарии",
|
|
||||||
"start_obtained": "Имя взято сразу",
|
|
||||||
"needed_downstream": "Потребовался AI после старта",
|
|
||||||
"downstream_ai_obtained": "Имя добрал AI после follow-up",
|
|
||||||
"handoff_confirmed_name": "Передача с подтверждённым именем",
|
|
||||||
"handoff_unconfirmed_name": "Передача без подтверждённого имени",
|
|
||||||
"confirmed_name": "Передача с подтверждённым именем",
|
|
||||||
"unconfirmed_name": "Передача без подтверждённого имени"
|
|
||||||
},
|
|
||||||
"agent_shift_rows": [
|
|
||||||
{
|
|
||||||
"shift_key": "night",
|
|
||||||
"label": "Ночная смена",
|
|
||||||
"agents_with_activity": 2,
|
|
||||||
"interactions_total": 18,
|
|
||||||
"answered_total": 17,
|
|
||||||
"avg_handle_seconds": 356,
|
|
||||||
"fcr_rate": 69.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"shift_key": "day",
|
|
||||||
"label": "Дневная смена",
|
|
||||||
"agents_with_activity": 5,
|
|
||||||
"interactions_total": 52,
|
|
||||||
"answered_total": 53,
|
|
||||||
"avg_handle_seconds": 318,
|
|
||||||
"fcr_rate": 76.8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"shift_key": "evening",
|
|
||||||
"label": "Вечерняя смена",
|
|
||||||
"agents_with_activity": 3,
|
|
||||||
"interactions_total": 30,
|
|
||||||
"answered_total": 30,
|
|
||||||
"avg_handle_seconds": 334,
|
|
||||||
"fcr_rate": 73.4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-193
@@ -1,193 +0,0 @@
|
|||||||
const SESSION_STORAGE_KEY = 'cc_session';
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
user: 'admin',
|
|
||||||
role: 'admin',
|
|
||||||
token: null,
|
|
||||||
authSource: 'local',
|
|
||||||
oidc: {
|
|
||||||
enabled: false,
|
|
||||||
loginPath: '/auth/oidc/start?return_mode=popup',
|
|
||||||
providerLabel: 'Keycloak',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
function destinationForRole(role) {
|
|
||||||
if (role === 'admin') {
|
|
||||||
return '/admin';
|
|
||||||
}
|
|
||||||
if (role === 'supervisor') {
|
|
||||||
return '/supervisor';
|
|
||||||
}
|
|
||||||
if (role === 'analyst') {
|
|
||||||
return '/analyst';
|
|
||||||
}
|
|
||||||
return '/operator';
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSessionFromInputs() {
|
|
||||||
state.user = $('sessionUser').value.trim() || 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeLog(message, payload) {
|
|
||||||
const suffix = payload ? ` ${JSON.stringify(payload, null, 2)}` : '';
|
|
||||||
$('loginLog').textContent = `${message}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistSession(data) {
|
|
||||||
const payload = {
|
|
||||||
access_token: data.access_token,
|
|
||||||
user: data.username || state.user,
|
|
||||||
role: data.role,
|
|
||||||
auth_source: data.auth_source || 'local',
|
|
||||||
provider: data.provider || null,
|
|
||||||
full_name: data.full_name || null,
|
|
||||||
};
|
|
||||||
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
$('sessionUser').value = state.user;
|
|
||||||
updateSessionInfo(data.provider ? `provider: ${data.provider}` : '');
|
|
||||||
persistSession(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(service, path, options = {}) {
|
|
||||||
syncSessionFromInputs();
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(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: 'Non-JSON response' };
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
writeLog('Корпоративный вход недоступен.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
|
|
||||||
if (!popup) {
|
|
||||||
writeLog('Не удалось открыть окно корпоративного входа.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeLog('Открыт корпоративный вход.', { provider: 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);
|
|
||||||
writeLog('Корпоративный вход выполнен.', { user: state.user, role: state.role });
|
|
||||||
window.location.href = destinationForRole(state.role);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.data.type === 'oidc-error') {
|
|
||||||
writeLog('Корпоративный вход не выполнен.', { error: event.data.message || 'unknown' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
writeLog('Локальный вход выполнен.', { user: state.user, role: data.role });
|
|
||||||
window.location.href = destinationForRole(data.role);
|
|
||||||
} catch (err) {
|
|
||||||
$('sessionInfo').textContent = `Ошибка входа: ${err.message}`;
|
|
||||||
writeLog('Вход не выполнен.', { error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wire() {
|
|
||||||
$('loginBtn').addEventListener('click', login);
|
|
||||||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
|
||||||
$('refreshBtn').addEventListener('click', loadOidcConfig);
|
|
||||||
window.addEventListener('message', handleOidcMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
wire();
|
|
||||||
updateSessionInfo();
|
|
||||||
await loadOidcConfig();
|
|
||||||
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(raw);
|
|
||||||
if (payload?.access_token) {
|
|
||||||
state.token = payload.access_token;
|
|
||||||
state.user = payload.user || state.user;
|
|
||||||
state.role = payload.role || state.role;
|
|
||||||
state.authSource = payload.auth_source || 'local';
|
|
||||||
$('sessionUser').value = state.user;
|
|
||||||
updateSessionInfo(payload.provider ? `provider: ${payload.provider}` : '');
|
|
||||||
writeLog('Найдена активная сессия.', { user: state.user, role: state.role });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Login</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="login-shell">
|
|
||||||
<section class="panel login-layout login-card reveal">
|
|
||||||
<div class="login-brand">
|
|
||||||
<div class="brand-mark">K</div>
|
|
||||||
<div class="login-brand-copy">
|
|
||||||
<strong>KonturCC</strong>
|
|
||||||
<span>Единая точка входа в operator / supervisor / admin shell</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="page-hero">
|
|
||||||
<p class="eyebrow">Session</p>
|
|
||||||
<h1>Авторизация</h1>
|
|
||||||
<p class="hint">После входа система автоматически переведёт вас в нужный рабочий экран по роли.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="login-grid">
|
|
||||||
<div class="row">
|
|
||||||
<label>Пользователь</label>
|
|
||||||
<input id="sessionUser" value="admin" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Роль</label>
|
|
||||||
<select id="sessionRole">
|
|
||||||
<option value="admin" selected>admin</option>
|
|
||||||
<option value="supervisor">supervisor</option>
|
|
||||||
<option value="operator">operator</option>
|
|
||||||
<option value="analyst">analyst</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пароль</label>
|
|
||||||
<input id="sessionPassword" type="password" value="admin123" />
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn" class="btn">Войти</button>
|
|
||||||
<button id="corporateLoginBtn" class="btn ghost" type="button">Корпоративный вход</button>
|
|
||||||
<button id="refreshBtn" class="btn ghost" type="button">Обновить всё</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
|
||||||
<pre id="loginLog" class="output" style="margin-top: 16px;">Ожидание входа.</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/login/assets/app.js?v=track19-analyst-shell-v1"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
(() => {
|
|
||||||
const SESSION_STORAGE_KEY = 'cc_session';
|
|
||||||
const SUPERVISOR_REALTIME_PATH = '/proxy/supervisor/supervisor/realtime';
|
|
||||||
|
|
||||||
function currentRole() {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const payload = JSON.parse(raw);
|
|
||||||
return String(payload?.role || '').toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleFromRequest(input, init) {
|
|
||||||
try {
|
|
||||||
const headers = new Headers((init && init.headers) || (input && input.headers) || undefined);
|
|
||||||
return String(headers.get('X-Role') || '').toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlFromRequest(input) {
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
if (input && typeof input.url === 'string') {
|
|
||||||
return input.url;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRestrictedSupervisorRealtime(input, init) {
|
|
||||||
const url = urlFromRequest(input);
|
|
||||||
if (!url.includes(SUPERVISOR_REALTIME_PATH)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const role = roleFromRequest(input, init) || currentRole();
|
|
||||||
return role === 'operator';
|
|
||||||
}
|
|
||||||
|
|
||||||
function restrictedSupervisorResponse() {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
agents: { total: 0, by_state: {}, items: [] },
|
|
||||||
queues: [],
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
restricted: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFetch = window.fetch.bind(window);
|
|
||||||
window.fetch = function patchedFetch(input, init) {
|
|
||||||
if (isRestrictedSupervisorRealtime(input, init)) {
|
|
||||||
return Promise.resolve(restrictedSupervisorResponse());
|
|
||||||
}
|
|
||||||
return originalFetch(input, init);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
-7456
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<rect width="64" height="64" rx="14" fill="#0a2340"/>
|
|
||||||
<path d="M18 44V20h7l7 11 7-11h7v24h-6V30l-8 12-8-12v14z" fill="#22d3c5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 201 B |
@@ -1,762 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Оператор</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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=track25-messenger-polish1" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">K</div>
|
|
||||||
<div>
|
|
||||||
<div class="brand-title">KonturCC</div>
|
|
||||||
<div class="brand-sub">Омниканальный контур</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav-menu">
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Рабочие зоны</div>
|
|
||||||
<a class="nav-link" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
|
||||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
|
||||||
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
|
||||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator,analyst" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Страницы</div>
|
|
||||||
<a class="nav-link active" data-operator-view-link="workspace" href="#workspace"><span class="nav-bullet"></span>Рабочий стол</a>
|
|
||||||
<a class="nav-link" data-operator-view-link="messages" href="#messages"><span class="nav-bullet"></span>Сообщения</a>
|
|
||||||
<a class="nav-link" data-operator-view-link="customers" href="#customers-page"><span class="nav-bullet"></span>Клиенты</a>
|
|
||||||
<a class="nav-link" data-debug-channel="telegram" href="#telegram-page" hidden><span class="nav-bullet"></span>Telegram</a>
|
|
||||||
<a class="nav-link" data-debug-channel="whatsapp" href="#whatsapp-page" hidden><span class="nav-bullet"></span>WhatsApp</a>
|
|
||||||
<a class="nav-link" data-operator-view-link="calls" data-roles="admin,operator" href="#calls"><span class="nav-bullet"></span>Звонки</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<strong>Операторский экран</strong>
|
|
||||||
<p>Показывает единый контур: клиент, обращение, каналы, база знаний и voice lifecycle.</p>
|
|
||||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="workspace">
|
|
||||||
<header class="workspace-topbar reveal">
|
|
||||||
<div class="search-box">
|
|
||||||
<input class="app-search" placeholder="Найти клиента, обращение или канал..." />
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
|
|
||||||
<div class="phone-entrypoint">
|
|
||||||
<button id="browserPhoneStatusBtn" class="phone-chip" type="button" aria-label="Состояние браузерного телефона" aria-expanded="false" data-state="offline">
|
|
||||||
<span class="phone-chip-dot" id="browserPhoneStatusDot"></span>
|
|
||||||
<span class="phone-chip-copy">
|
|
||||||
<span class="phone-chip-title">Телефон</span>
|
|
||||||
<span class="phone-chip-status" id="browserPhoneStatusLabel">Не подключен</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div id="browserPhoneSettingsPanel" class="phone-settings hidden">
|
|
||||||
<div class="softphone-summary compact">
|
|
||||||
<div class="softphone-status-block">
|
|
||||||
<div class="softphone-status-label">Регистрация</div>
|
|
||||||
<div class="softphone-status-value" id="browserPhoneRegistrationState">Не настроен</div>
|
|
||||||
</div>
|
|
||||||
<div class="softphone-status-block">
|
|
||||||
<div class="softphone-status-label">Внутренний номер оператора</div>
|
|
||||||
<div class="softphone-status-value" id="browserPhoneOperatorExtension">—</div>
|
|
||||||
</div>
|
|
||||||
<div class="softphone-status-block">
|
|
||||||
<div class="softphone-status-label">WSS</div>
|
|
||||||
<div class="softphone-status-value" id="browserPhoneWsUrl">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact phone-settings-actions">
|
|
||||||
<button id="browserPhoneConnectBtn" class="btn" type="button">Подключить телефон</button>
|
|
||||||
<button id="browserPhoneDisconnectBtn" class="btn ghost" type="button">Отключить</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<div class="row stacked">
|
|
||||||
<label for="browserPhoneMicSelect">Микрофон</label>
|
|
||||||
<select id="browserPhoneMicSelect"></select>
|
|
||||||
</div>
|
|
||||||
<div class="row stacked">
|
|
||||||
<label for="browserPhoneSpeakerSelect">Динамик</label>
|
|
||||||
<select id="browserPhoneSpeakerSelect"></select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="browserPhoneHint">Браузерный телефон выключен или не настроен.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="icon-button" type="button" aria-label="Уведомления">•</button>
|
|
||||||
<div class="profile-chip">
|
|
||||||
<div class="avatar-badge">OP</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<div class="profile-name" id="profileName">Экран оператора</div>
|
|
||||||
<div class="profile-role" id="profileRole">Основной сценарий</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
|
||||||
<section id="workspaceView" class="operator-view" data-operator-view="workspace">
|
|
||||||
<section class="page-hero reveal">
|
|
||||||
<h1>Операторский пульт v2</h1>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel auth reveal" id="authPanel">
|
|
||||||
<h2>Сессия</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пользователь</label>
|
|
||||||
<input id="sessionUser" value="admin" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Роль</label>
|
|
||||||
<select id="sessionRole">
|
|
||||||
<option value="admin" selected>Администратор</option>
|
|
||||||
<option value="supervisor">Супервизор</option>
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пароль</label>
|
|
||||||
<input id="sessionPassword" type="password" value="admin123" />
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn" class="btn">Войти</button>
|
|
||||||
<button id="corporateLoginBtn" class="btn ghost" type="button">Корпоративный вход</button>
|
|
||||||
<button id="refreshBtn" class="btn ghost">Обновить всё</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid">
|
|
||||||
<article class="panel reveal panel-span-full unified-inbox-panel" id="unifiedInbox">
|
|
||||||
<div class="unified-inbox-head">
|
|
||||||
<div>
|
|
||||||
<h2>Единая очередь</h2>
|
|
||||||
<p class="hint">Новые задачи, мои диалоги, AI handoff, эскалации и живые звонки в одном месте.</p>
|
|
||||||
</div>
|
|
||||||
<button id="refreshUnifiedInboxBtn" class="btn ghost" type="button">Обновить очередь</button>
|
|
||||||
</div>
|
|
||||||
<div id="unifiedInboxSummary" class="summary-grid"></div>
|
|
||||||
<div id="unifiedInboxBoard" class="unified-inbox-scroll"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal panel-span-full" id="interactions">
|
|
||||||
<h2>Обращения</h2>
|
|
||||||
<div class="inline-form">
|
|
||||||
<select id="interactionChannel">
|
|
||||||
<option value="voice">Голос</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="whatsapp" data-feature-option="whatsapp" hidden disabled>WhatsApp</option>
|
|
||||||
<option value="webchat">Веб-чат</option>
|
|
||||||
<option value="email">Электронная почта</option>
|
|
||||||
</select>
|
|
||||||
<input id="interactionSubject" placeholder="Тема обращения" />
|
|
||||||
<input id="interactionCustomerId" placeholder="ID клиента" />
|
|
||||||
<button id="createInteractionBtn" class="btn">Создать</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="defaultAssignee" value="operator_a" placeholder="Исполнитель по умолчанию" />
|
|
||||||
<input id="defaultEscalationQueue" value="line2" placeholder="Очередь 2 линии" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="loadInteractionsBtn" class="btn ghost">Загрузить список</button>
|
|
||||||
</div>
|
|
||||||
<div id="interactionTable" class="table"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="messagesView" class="operator-view hidden" data-operator-view="messages">
|
|
||||||
<article class="panel reveal panel-span-full messenger-panel" id="messengerWorkspace">
|
|
||||||
<div class="messenger-layout">
|
|
||||||
<aside class="messenger-list-pane">
|
|
||||||
<div class="messenger-pane-head">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Unified messenger</p>
|
|
||||||
<h2>Сообщения</h2>
|
|
||||||
<p class="hint">Единое окно для Telegram сейчас и WhatsApp позже.</p>
|
|
||||||
</div>
|
|
||||||
<button id="messengerRefreshBtn" class="btn ghost" type="button">Повторить</button>
|
|
||||||
</div>
|
|
||||||
<div class="messenger-filters" aria-label="Фильтры сообщений">
|
|
||||||
<button class="messenger-filter active" data-messenger-filter="all" type="button">Все</button>
|
|
||||||
<button class="messenger-filter" data-messenger-filter="new" type="button">Новые</button>
|
|
||||||
<button class="messenger-filter" data-messenger-filter="mine" type="button">Мои</button>
|
|
||||||
<button class="messenger-filter" data-messenger-filter="ai" type="button">AI handoff</button>
|
|
||||||
</div>
|
|
||||||
<div id="messengerConversationList" class="messenger-conversation-list"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="messenger-chat-pane">
|
|
||||||
<header class="messenger-chat-header">
|
|
||||||
<div class="messenger-chat-identity">
|
|
||||||
<div id="messengerConversationAvatar" class="telegram-thread-avatar">TG</div>
|
|
||||||
<div class="messenger-chat-copy">
|
|
||||||
<div id="messengerConversationTitle" class="messenger-chat-title">Выберите диалог</div>
|
|
||||||
<div id="messengerConversationMeta" class="messenger-chat-meta">Выберите диалог слева.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="messenger-chat-actions">
|
|
||||||
<span id="messengerChannelBadge" class="messenger-channel-badge">Telegram</span>
|
|
||||||
<button id="messengerClaimBtn" class="btn ghost" type="button">Взять в работу</button>
|
|
||||||
<button id="messengerCloseBtn" class="btn ghost" type="button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messengerAiSummaryCard" class="telegram-ai-summary messenger-ai-summary hidden">
|
|
||||||
<div class="telegram-ai-summary-head">
|
|
||||||
<div>
|
|
||||||
<div class="telegram-ai-summary-title">AI summary</div>
|
|
||||||
<div class="telegram-ai-summary-caption">Короткий контекст перед ручным ответом.</div>
|
|
||||||
</div>
|
|
||||||
<div class="telegram-ai-summary-side">
|
|
||||||
<div id="messengerAiSummaryStatus"></div>
|
|
||||||
<div id="messengerAiSummaryMeta" class="telegram-ai-summary-meta"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="messengerAiSummaryContent" class="telegram-ai-summary-grid"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div id="messengerBackendError" class="messenger-error hidden">
|
|
||||||
<span>Backend сообщений недоступен.</span>
|
|
||||||
<button id="messengerRetryBtn" class="btn ghost" type="button">Повторить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="messenger-chat-surface">
|
|
||||||
<div id="messengerMessagesTimeline" class="telegram-message-timeline messenger-message-timeline"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="messenger-composer-wrap">
|
|
||||||
<div class="telegram-composer-shell messenger-composer-shell">
|
|
||||||
<textarea id="messengerReplyText" placeholder="Написать ответ клиенту..."></textarea>
|
|
||||||
<button id="messengerSendReplyBtn" class="telegram-send-btn" type="button" aria-label="Отправить">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M21 3 3.9 10.09a1 1 0 0 0 .06 1.87l6.5 2.27 2.27 6.5a1 1 0 0 0 1.87.06L21 3Z" />
|
|
||||||
<path d="M10.5 14.5 21 3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="messengerComposerHint" class="telegram-composer-hint">Ответ доступен после выбора и принятия диалога в работу.</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="messenger-context-pane">
|
|
||||||
<div class="messenger-context-card">
|
|
||||||
<p class="eyebrow">Клиент</p>
|
|
||||||
<h3 id="messengerContextName">Диалог не выбран</h3>
|
|
||||||
<dl class="messenger-context-list">
|
|
||||||
<div>
|
|
||||||
<dt>Канал</dt>
|
|
||||||
<dd id="messengerContextChannel">Telegram</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Статус</dt>
|
|
||||||
<dd id="messengerContextStatus">—</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Клиент</dt>
|
|
||||||
<dd id="messengerContextCustomer">—</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Обращение</dt>
|
|
||||||
<dd id="messengerContextInteraction">—</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<button id="messengerOpenProfileBtn" class="btn" type="button">Открыть профиль</button>
|
|
||||||
</div>
|
|
||||||
<div class="messenger-context-card compact">
|
|
||||||
<p class="eyebrow">Последнее сообщение</p>
|
|
||||||
<p id="messengerContextPreview" class="messenger-context-preview">Выберите диалог слева.</p>
|
|
||||||
</div>
|
|
||||||
<div class="messenger-context-card compact">
|
|
||||||
<p class="eyebrow">AI summary</p>
|
|
||||||
<p id="messengerContextAiSummary" class="messenger-context-preview">Сводка появится после AI handoff.</p>
|
|
||||||
</div>
|
|
||||||
<div class="messenger-context-card compact">
|
|
||||||
<p class="eyebrow">Быстрые действия</p>
|
|
||||||
<button id="messengerContextOpenProfileBtn" class="btn ghost" type="button">Профиль клиента</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="customersView" class="operator-view hidden" data-operator-view="customers">
|
|
||||||
<section class="page-hero reveal customers-page-hero">
|
|
||||||
<h1>Список лидов</h1>
|
|
||||||
<p class="hint">База всех входящих и активных лидов.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<article class="panel reveal panel-span-full customers-panel" id="customers">
|
|
||||||
<div class="customers-toolbar">
|
|
||||||
<div class="customers-toolbar-left">
|
|
||||||
<div class="customers-search">
|
|
||||||
<span class="customers-search-icon">⌕</span>
|
|
||||||
<input id="customerQuery" placeholder="Поиск по имени или телефону..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="customers-toolbar-right">
|
|
||||||
<button id="exportCustomersBtn" class="btn ghost" type="button">⇩ Экспорт CSV</button>
|
|
||||||
<button id="toggleCustomerLeadFormBtn" class="btn" type="button">+ Добавить лид</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="customerLeadForm" class="customer-lead-form hidden">
|
|
||||||
<div class="inline-form">
|
|
||||||
<input id="customerName" placeholder="Имя контакта" />
|
|
||||||
<input id="customerPhone" placeholder="Телефон" />
|
|
||||||
<button id="createCustomerBtn" class="btn" type="button">Сохранить лид</button>
|
|
||||||
<button id="customerCancelBtn" class="btn ghost" type="button">Отмена</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="customerList" class="lead-table-shell"></div>
|
|
||||||
|
|
||||||
<div class="customers-footer">
|
|
||||||
<div id="customerPageSummary" class="customers-summary">Пока нет лидов.</div>
|
|
||||||
<div class="customers-pager">
|
|
||||||
<button id="customerPrevPageBtn" class="icon-button" type="button" aria-label="Предыдущая страница">‹</button>
|
|
||||||
<button id="customerNextPageBtn" class="icon-button" type="button" aria-label="Следующая страница">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="customerProfileView" class="operator-view hidden" data-operator-view="customer-profile">
|
|
||||||
<section class="page-hero reveal customers-page-hero customer-profile-page-hero">
|
|
||||||
<div class="page-hero-actions">
|
|
||||||
<button id="customerProfileBackBtn" class="btn ghost" type="button">← К списку клиентов</button>
|
|
||||||
</div>
|
|
||||||
<h1 id="customerProfileTitle">Профиль клиента</h1>
|
|
||||||
<p id="customerProfileHint" class="hint">Единый профиль клиента и вся история его контактов в одном месте.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<article class="panel reveal panel-span-full customer-profile-page-panel">
|
|
||||||
<section id="customerProfileContent" class="customer-spotlight-shell customer-spotlight-page">
|
|
||||||
<div class="customer-spotlight-empty">
|
|
||||||
<strong>Выберите клиента</strong>
|
|
||||||
<p>Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="telegramView" class="operator-view hidden" data-operator-view="telegram">
|
|
||||||
<article class="panel reveal panel-span-full telegram-panel" id="telegramWorkspace">
|
|
||||||
<div class="telegram-layout">
|
|
||||||
<aside class="telegram-threads-pane">
|
|
||||||
<div class="telegram-pane-head">
|
|
||||||
<button class="telegram-pane-icon-btn" type="button" aria-label="Меню" disabled>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M4 7.25h16M4 12h16M4 16.75h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="telegram-thread-search">
|
|
||||||
<span class="telegram-thread-search-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4.5 4.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input id="telegramThreadSearch" placeholder="Поиск" />
|
|
||||||
</div>
|
|
||||||
<button id="loadTelegramThreadsBtn" class="telegram-pane-icon-btn" type="button" aria-label="Обновить">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v6h-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="telegramThreadsList" class="telegram-thread-list"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="telegram-chat-pane">
|
|
||||||
<header class="telegram-chat-header">
|
|
||||||
<div class="telegram-thread-identity">
|
|
||||||
<div id="telegramThreadAvatar" class="telegram-thread-avatar">TG</div>
|
|
||||||
<div class="telegram-thread-copy">
|
|
||||||
<div id="telegramThreadDisplayName" class="telegram-thread-title">Выберите диалог</div>
|
|
||||||
<div class="telegram-thread-meta-row">
|
|
||||||
<div id="telegramThreadMeta" class="telegram-thread-meta">Выберите диалог слева.</div>
|
|
||||||
<span id="telegramThreadAiChip" class="telegram-thread-ai-chip hidden">AI активен</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="telegram-chat-actions">
|
|
||||||
<button id="telegramHeaderSearchBtn" class="telegram-chat-icon-btn" type="button" aria-label="Поиск">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4.5 4.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="telegramHeaderCallBtn" class="telegram-chat-icon-btn" type="button" aria-label="Звонок">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M5.5 4.5h3l1.4 3.6-1.7 1.7a15.4 15.4 0 0 0 6 6l1.7-1.7 3.6 1.4v3a1.5 1.5 0 0 1-1.5 1.5A15.5 15.5 0 0 1 4 6A1.5 1.5 0 0 1 5.5 4.5Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="telegramHeaderMenuBtn" class="telegram-chat-icon-btn" type="button" aria-label="Действия">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="5" r="1.5" />
|
|
||||||
<circle cx="12" cy="12" r="1.5" />
|
|
||||||
<circle cx="12" cy="19" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="telegramMessageSearchBar" class="telegram-message-search hidden">
|
|
||||||
<div class="telegram-message-search-field">
|
|
||||||
<span class="telegram-thread-search-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4.5 4.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input id="telegramMessageSearch" placeholder="Поиск по чату" />
|
|
||||||
</div>
|
|
||||||
<div id="telegramMessageSearchMeta" class="telegram-message-search-meta">Поиск по текущему чату</div>
|
|
||||||
<button id="telegramMessageSearchClearBtn" class="telegram-message-search-clear" type="button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="telegramOperatorTray" class="telegram-operator-tray hidden">
|
|
||||||
<div class="telegram-operator-meta">
|
|
||||||
<span id="telegramThreadPresenceChip" class="telegram-operator-chip free">свободен</span>
|
|
||||||
<span id="telegramThreadOwnerChip" class="telegram-operator-chip subtle">владелец —</span>
|
|
||||||
</div>
|
|
||||||
<div class="telegram-operator-controls">
|
|
||||||
<button id="telegramClaimThreadBtn" class="telegram-operator-action" type="button">В работу</button>
|
|
||||||
<button id="telegramReturnToAiBtn" class="telegram-operator-action subtle" type="button">Вернуть AI</button>
|
|
||||||
<button id="telegramCloseThreadBtn" class="telegram-operator-action subtle" type="button">Закрыть</button>
|
|
||||||
<input id="telegramEscalationQueue" class="telegram-operator-input" placeholder="line2" />
|
|
||||||
<button id="telegramEscalateThreadBtn" class="telegram-operator-action subtle" type="button">Эскалация</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section id="telegramAiSummaryCard" class="telegram-ai-summary hidden">
|
|
||||||
<div class="telegram-ai-summary-head">
|
|
||||||
<div>
|
|
||||||
<div class="telegram-ai-summary-title">Сводка AI</div>
|
|
||||||
<div class="telegram-ai-summary-caption">Краткая сводка для оператора перед ручным ответом.</div>
|
|
||||||
</div>
|
|
||||||
<div class="telegram-ai-summary-side">
|
|
||||||
<div id="telegramAiSummaryStatus"></div>
|
|
||||||
<div id="telegramAiSummaryMeta" class="telegram-ai-summary-meta"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="telegramAiSummaryContent" class="telegram-ai-summary-grid"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="telegram-chat-surface">
|
|
||||||
<div id="telegramMessagesTimeline" class="telegram-message-timeline"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="telegram-composer-wrap">
|
|
||||||
<div class="telegram-composer-shell">
|
|
||||||
<button class="telegram-composer-icon-btn" type="button" aria-label="Вложение" disabled>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="m9.5 12.5 5.94-5.94a3 3 0 1 1 4.24 4.24l-8.48 8.48a5 5 0 0 1-7.07-7.07l8.13-8.13" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<textarea id="telegramReplyText" placeholder="Написать сообщение..."></textarea>
|
|
||||||
<button class="telegram-composer-icon-btn" type="button" aria-label="Эмодзи">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M8.5 14.5c.8 1.2 2 2 3.5 2s2.7-.8 3.5-2" />
|
|
||||||
<circle cx="9" cy="10" r="1" />
|
|
||||||
<circle cx="15" cy="10" r="1" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="telegramSendReplyBtn" class="telegram-send-btn" type="button" aria-label="Отправить">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M21 3 3.9 10.09a1 1 0 0 0 .06 1.87l6.5 2.27 2.27 6.5a1 1 0 0 0 1.87.06L21 3Z" />
|
|
||||||
<path d="M10.5 14.5 21 3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="telegramComposerHint" class="telegram-composer-hint">Ответ доступен после принятия в работу или для admin/supervisor.</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="whatsappView" class="operator-view hidden" data-operator-view="whatsapp" data-feature="whatsapp" hidden>
|
|
||||||
<article class="panel reveal panel-span-full whatsapp-panel" id="whatsappWorkspace">
|
|
||||||
<div class="whatsapp-shell">
|
|
||||||
<aside class="whatsapp-nav-rail" aria-label="WhatsApp navigation">
|
|
||||||
<div class="whatsapp-rail-group">
|
|
||||||
<button class="whatsapp-rail-btn active" type="button" aria-label="Чаты" data-whatsapp-ui-action="chats">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M5.5 6.5h13a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H10l-4.5 3v-3H5.5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-rail-btn" type="button" aria-label="Статус" data-whatsapp-ui-action="status">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="8.25" />
|
|
||||||
<path d="M12 7.75v4.5l3 1.75" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-rail-btn" type="button" aria-label="Channels" data-whatsapp-ui-action="channels">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M4.5 8.5a7.5 7.5 0 0 1 15 0" />
|
|
||||||
<path d="M7 12a5 5 0 0 1 10 0" />
|
|
||||||
<path d="M9.5 15.5a2.5 2.5 0 0 1 5 0" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="whatsapp-rail-footer">
|
|
||||||
<button class="whatsapp-rail-btn" type="button" aria-label="Настройки" data-whatsapp-ui-action="settings">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 8.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z" />
|
|
||||||
<path d="M19.4 13.5a1 1 0 0 0 .2 1.1l.1.1a1 1 0 0 1 0 1.4l-1 1a1 1 0 0 1-1.4 0l-.1-.1a1 1 0 0 0-1.1-.2 1 1 0 0 0-.6.9V19a1 1 0 0 1-1 1h-1.4a1 1 0 0 1-1-1v-.1a1 1 0 0 0-.7-.9 1 1 0 0 0-1 .2l-.1.1a1 1 0 0 1-1.4 0l-1-1a1 1 0 0 1 0-1.4l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.9-.6H5a1 1 0 0 1-1-1v-1.4a1 1 0 0 1 1-1h.1a1 1 0 0 0 .9-.7 1 1 0 0 0-.2-1l-.1-.1a1 1 0 0 1 0-1.4l1-1a1 1 0 0 1 1.4 0l.1.1a1 1 0 0 0 1 .2 1 1 0 0 0 .6-.9V5a1 1 0 0 1 1-1h1.4a1 1 0 0 1 1 1v.1a1 1 0 0 0 .7.9 1 1 0 0 0 1.1-.2l.1-.1a1 1 0 0 1 1.4 0l1 1a1 1 0 0 1 0 1.4l-.1.1a1 1 0 0 0-.2 1 1 1 0 0 0 .9.6H19a1 1 0 0 1 1 1v1.4a1 1 0 0 1-1 1h-.1a1 1 0 0 0-.9.7Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="whatsapp-rail-avatar" aria-hidden="true">OP</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<aside class="whatsapp-list-pane">
|
|
||||||
<header class="whatsapp-list-head">
|
|
||||||
<div class="whatsapp-pane-title-wrap">
|
|
||||||
<div class="whatsapp-pane-title">WhatsApp</div>
|
|
||||||
<div id="whatsappPaneCaption" class="whatsapp-pane-caption">Держите телефон подключённым</div>
|
|
||||||
</div>
|
|
||||||
<div class="whatsapp-list-actions">
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Новый чат" data-whatsapp-ui-action="new-chat">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Меню" data-whatsapp-ui-action="menu">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="5" r="1.5" />
|
|
||||||
<circle cx="12" cy="12" r="1.5" />
|
|
||||||
<circle cx="12" cy="19" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="whatsapp-search-wrap">
|
|
||||||
<div class="whatsapp-search-field">
|
|
||||||
<span class="whatsapp-search-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24">
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4.5 4.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input id="whatsappSearchInput" type="search" placeholder="Поиск или новый чат" />
|
|
||||||
</div>
|
|
||||||
<div id="whatsappFilterBar" class="whatsapp-filter-bar" role="tablist" aria-label="Фильтры чатов">
|
|
||||||
<button class="whatsapp-filter-chip active" type="button" data-whatsapp-filter="all">Все</button>
|
|
||||||
<button class="whatsapp-filter-chip" type="button" data-whatsapp-filter="unread">Непрочитанные</button>
|
|
||||||
<button class="whatsapp-filter-chip" type="button" data-whatsapp-filter="groups">Группы</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="whatsappChatList" class="whatsapp-chat-list"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="whatsapp-chat-pane">
|
|
||||||
<header class="whatsapp-chat-head">
|
|
||||||
<div class="whatsapp-chat-identity">
|
|
||||||
<div id="whatsappChatAvatar" class="whatsapp-chat-avatar">WA</div>
|
|
||||||
<div class="whatsapp-chat-copy">
|
|
||||||
<div id="whatsappChatTitle" class="whatsapp-chat-title">WhatsApp</div>
|
|
||||||
<div id="whatsappChatMeta" class="whatsapp-chat-meta">Выберите чат для начала переписки.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="whatsapp-chat-actions">
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Голосовой звонок" data-whatsapp-ui-action="voice-call">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M5.5 4.5h3l1.4 3.6-1.7 1.7a15.4 15.4 0 0 0 6 6l1.7-1.7 3.6 1.4v3a1.5 1.5 0 0 1-1.5 1.5A15.5 15.5 0 0 1 4 6 1.5 1.5 0 0 1 5.5 4.5Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Видеозвонок" data-whatsapp-ui-action="video-call">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<rect x="3.75" y="6.5" width="10.5" height="11" rx="2.5" />
|
|
||||||
<path d="m14.75 10.5 5.5-3v9l-5.5-3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Поиск по чату" data-whatsapp-ui-action="search-chat">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="11" cy="11" r="6.5" />
|
|
||||||
<path d="m16 16 4.5 4.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="whatsapp-icon-btn" type="button" aria-label="Меню чата" data-whatsapp-ui-action="chat-menu">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="5" r="1.5" />
|
|
||||||
<circle cx="12" cy="12" r="1.5" />
|
|
||||||
<circle cx="12" cy="19" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="whatsappContextPanel" class="whatsapp-context-panel hidden" aria-live="polite">
|
|
||||||
<div class="whatsapp-context-bar">
|
|
||||||
<div class="whatsapp-context-pills">
|
|
||||||
<span id="whatsappAiStateChip" class="whatsapp-state-chip hidden"></span>
|
|
||||||
<span id="whatsappOwnerChip" class="whatsapp-state-chip subtle hidden"></span>
|
|
||||||
</div>
|
|
||||||
<div class="whatsapp-context-actions">
|
|
||||||
<button id="whatsappClaimBtn" class="whatsapp-context-btn" type="button">Взять в работу</button>
|
|
||||||
<button id="whatsappReturnToAiBtn" class="whatsapp-context-btn secondary" type="button">Вернуть AI</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<article id="whatsappAiSummaryCard" class="whatsapp-ai-summary hidden">
|
|
||||||
<div class="whatsapp-ai-summary-head">
|
|
||||||
<div>
|
|
||||||
<div class="whatsapp-ai-summary-kicker">Передача от AI</div>
|
|
||||||
<h3>Контекст для оператора</h3>
|
|
||||||
</div>
|
|
||||||
<div class="whatsapp-ai-summary-side">
|
|
||||||
<div id="whatsappAiSummaryStatus"></div>
|
|
||||||
<div id="whatsappAiSummaryMeta" class="whatsapp-ai-summary-meta"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="whatsappAiSummaryContent" class="whatsapp-ai-summary-grid"></div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="whatsapp-chat-surface">
|
|
||||||
<div id="whatsappMessageTimeline" class="whatsapp-message-timeline"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="whatsapp-composer-wrap">
|
|
||||||
<div class="whatsapp-composer-shell">
|
|
||||||
<button class="whatsapp-composer-btn" type="button" aria-label="Вложение" data-whatsapp-ui-action="attach">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="m9.5 12.5 5.94-5.94a3 3 0 1 1 4.24 4.24l-8.48 8.48a5 5 0 0 1-7.07-7.07l8.13-8.13" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<textarea id="whatsappComposerInput" placeholder="Написать сообщение"></textarea>
|
|
||||||
<button class="whatsapp-composer-btn" type="button" aria-label="Эмодзи" data-whatsapp-ui-action="emoji">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M8.5 14.5c.8 1.2 2 2 3.5 2s2.7-.8 3.5-2" />
|
|
||||||
<circle cx="9" cy="10" r="1" />
|
|
||||||
<circle cx="15" cy="10" r="1" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="whatsappSendBtn" class="whatsapp-send-btn" type="button" aria-label="Отправить">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M21 3 3.9 10.09a1 1 0 0 0 .06 1.87l6.5 2.27 2.27 6.5a1 1 0 0 0 1.87.06L21 3Z" />
|
|
||||||
<path d="M10.5 14.5 21 3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="callsView" class="operator-view hidden" data-operator-view="calls">
|
|
||||||
<section class="page-hero reveal customers-page-hero">
|
|
||||||
<h1>Звонки</h1>
|
|
||||||
<p class="hint">Живые и недавние звонки, AI-handoff и журнал действий в одном месте. Основной take over остаётся во всплывающем окне браузерного телефона.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<article class="panel reveal panel-span-full technical-panel" id="liveCalls">
|
|
||||||
<h2>Мониторинг звонков</h2>
|
|
||||||
<p class="hint">Здесь видны текущий статус, недавние вызовы и трассировка действий. Ответ, перевод и завершение звонка остаются во всплывающем окне.</p>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
|
|
||||||
<button id="loadLiveCallActionsBtn" class="btn ghost">Действия по звонку</button>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<details class="details-block">
|
|
||||||
<summary>Лог действий по звонку</summary>
|
|
||||||
<pre id="liveCallActionsOutput" class="output"></pre>
|
|
||||||
</details>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="browserPhoneCallOverlay" class="call-window-overlay hidden" aria-live="polite">
|
|
||||||
<div class="call-window">
|
|
||||||
<div class="call-window-head">
|
|
||||||
<div>
|
|
||||||
<div id="browserPhoneCallState" class="call-window-status">Входящий звонок</div>
|
|
||||||
<h2 id="browserPhoneCallTitle" class="call-window-title">Неизвестный абонент</h2>
|
|
||||||
<p id="browserPhoneIncomingText" class="call-window-meta">SIP-приглашение ещё не поступало.</p>
|
|
||||||
<p id="browserPhoneCallMeta" class="call-window-meta subtle">Ждём входящий звонок в браузере.</p>
|
|
||||||
</div>
|
|
||||||
<button id="browserPhoneCallSettingsBtn" class="icon-button" type="button" aria-label="Настройки телефона">⋯</button>
|
|
||||||
</div>
|
|
||||||
<div class="call-window-body">
|
|
||||||
<div class="call-window-avatar">☎</div>
|
|
||||||
<div id="browserPhoneCallTimer" class="call-window-timer">00:00</div>
|
|
||||||
<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>
|
|
||||||
<option value="queue_code">код очереди</option>
|
|
||||||
</select>
|
|
||||||
<input id="browserPhoneTransferTargetValue" placeholder="2002 или voice_lab" />
|
|
||||||
</div>
|
|
||||||
<div class="call-window-actions">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<audio id="browserPhoneRemoteAudio" autoplay playsinline></audio>
|
|
||||||
|
|
||||||
<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=track44-messenger-polish1"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Vendored
-2
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Vendored
-2
File diff suppressed because one or more lines are too long
-927
@@ -1,927 +0,0 @@
|
|||||||
const state = {
|
|
||||||
user: 'admin',
|
|
||||||
role: 'admin',
|
|
||||||
token: null,
|
|
||||||
authSource: 'local',
|
|
||||||
fullName: null,
|
|
||||||
dashboard: null,
|
|
||||||
leads: [],
|
|
||||||
deals: [],
|
|
||||||
selectedDealId: '',
|
|
||||||
workspaceById: {},
|
|
||||||
logLines: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SESSION_STORAGE_KEY = 'cc_session';
|
|
||||||
const STAGE_OPTIONS = [
|
|
||||||
'new_qualified_lead',
|
|
||||||
'warm_lead',
|
|
||||||
'hot_lead',
|
|
||||||
'enrichment_required',
|
|
||||||
'active_text_communication',
|
|
||||||
'active_voice_communication',
|
|
||||||
'waiting_customer_reply',
|
|
||||||
'offer_preparing',
|
|
||||||
'offer_sent',
|
|
||||||
'conditions_negotiation',
|
|
||||||
'counterparty_data_requested',
|
|
||||||
'counterparty_data_received',
|
|
||||||
'document_preparing',
|
|
||||||
'document_sent',
|
|
||||||
'document_confirmed',
|
|
||||||
'invoice_preparing',
|
|
||||||
'invoice_sent',
|
|
||||||
'payment_expected',
|
|
||||||
'partially_paid',
|
|
||||||
'paid',
|
|
||||||
'payment_overdue',
|
|
||||||
'won',
|
|
||||||
'lost',
|
|
||||||
'postponed',
|
|
||||||
'follow_up_scheduled',
|
|
||||||
'transferred_to_execution',
|
|
||||||
'transferred_to_support',
|
|
||||||
];
|
|
||||||
|
|
||||||
const BOARD_GROUPS = [
|
|
||||||
{ key: 'inbound', title: 'Вход', note: 'Квалифицированный lead intake и дообогащение.', stages: ['new_qualified_lead', 'warm_lead', 'hot_lead', 'enrichment_required'] },
|
|
||||||
{ key: 'communication', title: 'Коммуникация', note: 'Text/voice сессии, вопросы и follow-up.', stages: ['active_text_communication', 'active_voice_communication', 'waiting_customer_reply'] },
|
|
||||||
{ key: 'commercial', title: 'Коммерция', note: 'Оффер, согласование условий, next step.', stages: ['offer_preparing', 'offer_sent', 'conditions_negotiation'] },
|
|
||||||
{ key: 'paperwork', title: 'Оформление', note: 'Реквизиты, документы, подтверждение.', stages: ['counterparty_data_requested', 'counterparty_data_received', 'document_preparing', 'document_sent', 'document_confirmed'] },
|
|
||||||
{ key: 'finance', title: 'Финансы', note: 'Счет, оплата, просрочка.', stages: ['invoice_preparing', 'invoice_sent', 'payment_expected', 'partially_paid', 'paid', 'payment_overdue'] },
|
|
||||||
{ key: 'outcome', title: 'Закрытие', note: 'Win/loss/post-sale handoff.', stages: ['won', 'lost', 'postponed', 'follow_up_scheduled', 'transferred_to_execution', 'transferred_to_support'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STAGE_LABELS = {
|
|
||||||
new_qualified_lead: 'Новый квалифицированный лид',
|
|
||||||
warm_lead: 'Теплый лид',
|
|
||||||
hot_lead: 'Горячий лид',
|
|
||||||
enrichment_required: 'Нужно дообогащение',
|
|
||||||
active_text_communication: 'Текстовая коммуникация',
|
|
||||||
active_voice_communication: 'Голосовая коммуникация',
|
|
||||||
waiting_customer_reply: 'Ждем ответ клиента',
|
|
||||||
offer_preparing: 'Готовим оффер',
|
|
||||||
offer_sent: 'Оффер отправлен',
|
|
||||||
conditions_negotiation: 'Согласуем условия',
|
|
||||||
counterparty_data_requested: 'Запрос реквизитов',
|
|
||||||
counterparty_data_received: 'Реквизиты получены',
|
|
||||||
document_preparing: 'Готовим документ',
|
|
||||||
document_sent: 'Документ отправлен',
|
|
||||||
document_confirmed: 'Документ подтвержден',
|
|
||||||
invoice_preparing: 'Готовим счет',
|
|
||||||
invoice_sent: 'Счет отправлен',
|
|
||||||
payment_expected: 'Ждем оплату',
|
|
||||||
partially_paid: 'Частичная оплата',
|
|
||||||
paid: 'Оплачено',
|
|
||||||
payment_overdue: 'Просрочка оплаты',
|
|
||||||
won: 'Успешно',
|
|
||||||
lost: 'Потеряно',
|
|
||||||
postponed: 'Отложено',
|
|
||||||
follow_up_scheduled: 'Follow-up',
|
|
||||||
transferred_to_execution: 'Передано в исполнение',
|
|
||||||
transferred_to_support: 'Передано человеку',
|
|
||||||
};
|
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
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, 14);
|
|
||||||
$('salesLog').textContent = state.logLines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSessionFromInputs() {
|
|
||||||
state.user = $('sessionUser').value.trim() || 'admin';
|
|
||||||
state.role = $('sessionRole').value || 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
$('authPanel').style.display = 'none';
|
|
||||||
updateSessionInfo();
|
|
||||||
updateProfile();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
clearStoredSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSessionInfo(extra = '') {
|
|
||||||
$('sessionInfo').textContent = state.token
|
|
||||||
? `Токен активен | роль: ${state.role}${extra ? ` | ${extra}` : ''}`
|
|
||||||
: 'Токен: отсутствует';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateProfile() {
|
|
||||||
$('profileName').textContent = state.fullName || state.user || 'Экран продаж';
|
|
||||||
$('profileRole').textContent = `Роль: ${state.role || 'unknown'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(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/sales/${path}`, { ...options, headers });
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await response.json();
|
|
||||||
} catch {
|
|
||||||
data = { error: 'Сервис вернул не JSON' };
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
syncSessionFromInputs();
|
|
||||||
try {
|
|
||||||
const data = await fetch('/proxy/auth/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User': state.user,
|
|
||||||
'X-Role': state.role,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: state.user,
|
|
||||||
password: $('sessionPassword').value,
|
|
||||||
}),
|
|
||||||
}).then((response) => response.json());
|
|
||||||
state.token = data.access_token;
|
|
||||||
state.role = data.role;
|
|
||||||
state.authSource = data.auth_source || 'local';
|
|
||||||
state.fullName = data.full_name || null;
|
|
||||||
$('authPanel').style.display = 'none';
|
|
||||||
updateSessionInfo();
|
|
||||||
updateProfile();
|
|
||||||
persistSession();
|
|
||||||
log('Логин выполнен', { user: state.user, role: state.role });
|
|
||||||
await refreshAll();
|
|
||||||
} catch (error) {
|
|
||||||
updateSessionInfo(`ошибка: ${error.message}`);
|
|
||||||
log('Ошибка входа', { error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
clearStoredSession();
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkGateway() {
|
|
||||||
try {
|
|
||||||
const data = await fetch('/health').then((response) => response.json());
|
|
||||||
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
|
|
||||||
} catch {
|
|
||||||
$('gatewayStatus').textContent = 'Шлюз: недоступен';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageLabel(stageId) {
|
|
||||||
return STAGE_LABELS[stageId] || stageId || 'Без этапа';
|
|
||||||
}
|
|
||||||
|
|
||||||
function boardGroupsWithItems() {
|
|
||||||
const query = ($('salesSearch').value || '').trim().toLowerCase();
|
|
||||||
return BOARD_GROUPS.map((group) => {
|
|
||||||
const items = state.deals.filter((deal) => {
|
|
||||||
const inGroup = group.stages.includes(deal.stage_id);
|
|
||||||
if (!inGroup) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
deal.title,
|
|
||||||
deal.need_summary,
|
|
||||||
deal.current_channel,
|
|
||||||
deal.customer_id,
|
|
||||||
].some((value) => String(value || '').toLowerCase().includes(query));
|
|
||||||
});
|
|
||||||
return { ...group, items };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function salesCard(deal) {
|
|
||||||
const activeClass = deal.deal_id === state.selectedDealId ? 'active' : '';
|
|
||||||
const amount = Number(deal.final_amount || deal.estimated_amount || 0);
|
|
||||||
return `
|
|
||||||
<button class="sales-card ${activeClass}" type="button" data-deal-id="${deal.deal_id}">
|
|
||||||
<div class="sales-card-top">
|
|
||||||
<div class="sales-card-title">${deal.title}</div>
|
|
||||||
<div class="sales-card-priority">P${deal.priority}</div>
|
|
||||||
</div>
|
|
||||||
<div class="sales-card-body">${deal.need_summary || 'Потребность еще не уточнена.'}</div>
|
|
||||||
<div class="sales-card-meta">
|
|
||||||
<span class="sales-chip">${stageLabel(deal.stage_id)}</span>
|
|
||||||
<span class="sales-chip">${deal.current_channel || 'канал не задан'}</span>
|
|
||||||
<span class="sales-chip">${amount.toLocaleString('ru-RU')} ${deal.currency || 'KZT'}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBoard() {
|
|
||||||
$('salesBoard').innerHTML = boardGroupsWithItems()
|
|
||||||
.map(
|
|
||||||
(group) => `
|
|
||||||
<section class="sales-column">
|
|
||||||
<div class="sales-column-head">
|
|
||||||
<div class="sales-column-title">${group.title}</div>
|
|
||||||
<div class="sales-column-count">${group.items.length}</div>
|
|
||||||
</div>
|
|
||||||
<div class="sales-column-note">${group.note}</div>
|
|
||||||
<div class="sales-card-list">
|
|
||||||
${group.items.length ? group.items.map((deal) => salesCard(deal)).join('') : '<div class="sales-workspace-empty">Пусто.</div>'}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function kpiCard(label, value, note) {
|
|
||||||
return `
|
|
||||||
<article class="sales-kpi-card">
|
|
||||||
<div class="sales-kpi-label">${label}</div>
|
|
||||||
<div class="sales-kpi-value">${value}</div>
|
|
||||||
<div class="sales-kpi-note">${note}</div>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDashboard() {
|
|
||||||
const data = state.dashboard;
|
|
||||||
if (!data) {
|
|
||||||
$('salesDashboard').innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('salesDashboard').innerHTML = [
|
|
||||||
kpiCard('Лиды', Number(data.leads_total || 0), 'Сколько квалифицированных лидов уже в CRM'),
|
|
||||||
kpiCard('Активные сделки', Number(data.deals_active || 0), 'Открытые кейсы в работе'),
|
|
||||||
kpiCard('Выиграно', Number(data.deals_won || 0), 'Закрыто с оплатой'),
|
|
||||||
kpiCard('Просрочка', Number(data.overdue_invoices || 0), 'Счета, которые зависли'),
|
|
||||||
kpiCard('Ожидаем оплату', `${Number(data.payment_expected || 0).toLocaleString('ru-RU')} KZT`, 'Потенциальный cash-in'),
|
|
||||||
kpiCard('Получено', `${Number(data.payment_received || 0).toLocaleString('ru-RU')} KZT`, 'Фактические оплаты'),
|
|
||||||
kpiCard('Voice сессии', Number(data.voice_sessions || 0), 'Голосовой контур продаж'),
|
|
||||||
kpiCard('Text сессии', Number(data.text_sessions || 0), 'Текстовый контур продаж'),
|
|
||||||
kpiCard('Switch channel', Number(data.channel_switches || 0), 'Переходы между voice/text'),
|
|
||||||
kpiCard('Эскалации', Number(data.human_escalations || 0), 'Передачи живому менеджеру'),
|
|
||||||
].join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStageOptions() {
|
|
||||||
$('dealStageSelect').innerHTML = STAGE_OPTIONS.map((value) => `<option value="${value}">${stageLabel(value)}</option>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceMetric(label, value) {
|
|
||||||
return `
|
|
||||||
<div class="sales-metric">
|
|
||||||
<div class="sales-metric-label">${label}</div>
|
|
||||||
<div class="sales-metric-value">${value}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function kvItem(label, value) {
|
|
||||||
return `
|
|
||||||
<div class="sales-kv-item">
|
|
||||||
<div class="sales-kv-label">${label}</div>
|
|
||||||
<div class="sales-kv-value">${value || '—'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listRows(items, titleKey, bodyKey) {
|
|
||||||
if (!items?.length) {
|
|
||||||
return '<div class="sales-workspace-empty">Пока пусто.</div>';
|
|
||||||
}
|
|
||||||
return `<div class="sales-list">${items
|
|
||||||
.map(
|
|
||||||
(item) => `
|
|
||||||
<div class="sales-list-row">
|
|
||||||
<div>
|
|
||||||
<strong>${item[titleKey]}</strong>
|
|
||||||
<span>${item[bodyKey] || ''}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>${item.updated_at || item.created_at || item.started_at || ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWorkspace() {
|
|
||||||
if (!state.selectedDealId) {
|
|
||||||
$('dealWorkspace').innerHTML = 'Выберите карточку на воронке, чтобы увидеть подробности.';
|
|
||||||
$('dealMetrics').innerHTML = '';
|
|
||||||
$('dealTitle').textContent = 'Выберите сделку';
|
|
||||||
$('dealSubtitle').textContent = 'Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.';
|
|
||||||
$('dealStageBadge').textContent = 'Нет данных';
|
|
||||||
$('telegramThreadId').value = '';
|
|
||||||
$('telegramChatId').value = '';
|
|
||||||
$('voiceSessionId').value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = state.workspaceById[state.selectedDealId];
|
|
||||||
if (!workspace) {
|
|
||||||
$('dealWorkspace').innerHTML = 'Загружаю карточку сделки...';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { deal, lead, communications, offers, documents, invoices, payments, timeline, counterparty, escalations, tasks } = workspace;
|
|
||||||
const bindings = workspaceBindings(workspace);
|
|
||||||
$('dealTitle').textContent = deal.title;
|
|
||||||
$('dealSubtitle').textContent = `${stageLabel(deal.stage_id)} • ${deal.current_channel || 'канал не задан'} • ${deal.status}`;
|
|
||||||
$('dealStageBadge').textContent = stageLabel(deal.stage_id);
|
|
||||||
$('dealStageSelect').value = deal.stage_id;
|
|
||||||
$('telegramThreadId').value = bindings.telegramThreadId || '';
|
|
||||||
$('telegramChatId').value = bindings.telegramChatId || '';
|
|
||||||
$('voiceSessionId').value = bindings.voiceSessionId || '';
|
|
||||||
|
|
||||||
$('dealMetrics').innerHTML = [
|
|
||||||
workspaceMetric('Канал', deal.current_channel || '—'),
|
|
||||||
workspaceMetric('Следующий шаг', deal.next_action_type || 'не задан'),
|
|
||||||
workspaceMetric('Сумма', `${Number(deal.final_amount || deal.estimated_amount || 0).toLocaleString('ru-RU')} ${deal.currency || 'KZT'}`),
|
|
||||||
workspaceMetric('Customer link', deal.customer_id || 'не привязан'),
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
$('dealWorkspace').innerHTML = `
|
|
||||||
<div class="sales-workspace-grid">
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Lead / Deal core</h3>
|
|
||||||
<div class="sales-kv">
|
|
||||||
${kvItem('Lead', lead?.lead_id || '—')}
|
|
||||||
${kvItem('Имя', lead?.full_name || '—')}
|
|
||||||
${kvItem('Телефон', lead?.phone || '—')}
|
|
||||||
${kvItem('Предпочтительный канал', deal.preferred_channel || '—')}
|
|
||||||
${kvItem('Score', lead?.lead_score || '—')}
|
|
||||||
${kvItem('Потребность', deal.need_summary || lead?.initial_need_summary || '—')}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Counterparty</h3>
|
|
||||||
<div class="sales-kv">
|
|
||||||
${kvItem('Компания', counterparty?.company_name || '—')}
|
|
||||||
${kvItem('Контакт', counterparty?.full_name || '—')}
|
|
||||||
${kvItem('BIN/IIN', counterparty?.bin_iin || '—')}
|
|
||||||
${kvItem('Email для документов', counterparty?.email_for_docs || '—')}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Коммуникации</h3>
|
|
||||||
${listRows(
|
|
||||||
communications.map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: `${item.channel_type === 'voice' ? 'Voice' : 'Text'} • ${item.direction}`,
|
|
||||||
detail: item.summary || item.subject || item.result_code || 'Без summary',
|
|
||||||
})),
|
|
||||||
'headline',
|
|
||||||
'detail',
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Коммерческий пакет</h3>
|
|
||||||
${listRows(
|
|
||||||
offers.map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: `${item.title} • ${item.status}`,
|
|
||||||
detail: `${Number(item.total_amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
|
|
||||||
})),
|
|
||||||
'headline',
|
|
||||||
'detail',
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Документы и счета</h3>
|
|
||||||
${listRows(
|
|
||||||
[...documents, ...invoices].map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: item.document_id
|
|
||||||
? `${item.document_type} • ${item.status}`
|
|
||||||
: `${item.invoice_number} • ${item.status}`,
|
|
||||||
detail: item.document_id
|
|
||||||
? (item.file_url || 'Файл еще не прикреплен')
|
|
||||||
: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
|
|
||||||
})),
|
|
||||||
'headline',
|
|
||||||
'detail',
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Оплаты, задачи, эскалации</h3>
|
|
||||||
${listRows(
|
|
||||||
[
|
|
||||||
...payments.map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: `Оплата • ${item.status}`,
|
|
||||||
detail: `${Number(item.amount || 0).toLocaleString('ru-RU')} ${item.currency}`,
|
|
||||||
})),
|
|
||||||
...tasks.map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: `Task • ${item.task_type}`,
|
|
||||||
detail: `${item.status} • ${item.run_at}`,
|
|
||||||
})),
|
|
||||||
...escalations.map((item) => ({
|
|
||||||
...item,
|
|
||||||
headline: `Эскалация • ${item.severity}`,
|
|
||||||
detail: item.reason,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
'headline',
|
|
||||||
'detail',
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-section-card">
|
|
||||||
<h3>Timeline</h3>
|
|
||||||
<div class="sales-stream">
|
|
||||||
${timeline.length ? timeline
|
|
||||||
.map(
|
|
||||||
(item) => `
|
|
||||||
<div class="sales-stream-item">
|
|
||||||
<div class="sales-stream-title">${item.title}</div>
|
|
||||||
<div class="sales-stream-time">${item.ts}</div>
|
|
||||||
<div class="sales-stream-body">${item.body}</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join('') : '<div class="sales-workspace-empty">Пока нет timeline событий.</div>'}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestCommunication(workspace, channelType = '') {
|
|
||||||
const items = workspace?.communications || [];
|
|
||||||
if (!channelType) {
|
|
||||||
return items[0] || null;
|
|
||||||
}
|
|
||||||
return items.find((item) => item.channel_type === channelType) || items[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceBindings(workspace) {
|
|
||||||
const bindings = {
|
|
||||||
telegramThreadId: '',
|
|
||||||
telegramChatId: '',
|
|
||||||
voiceSessionId: '',
|
|
||||||
};
|
|
||||||
for (const item of workspace?.communications || []) {
|
|
||||||
const metadata = item.metadata || {};
|
|
||||||
if (!bindings.telegramThreadId && metadata.telegram_thread_id) {
|
|
||||||
bindings.telegramThreadId = metadata.telegram_thread_id;
|
|
||||||
}
|
|
||||||
if (!bindings.telegramChatId && metadata.telegram_chat_id) {
|
|
||||||
bindings.telegramChatId = metadata.telegram_chat_id;
|
|
||||||
}
|
|
||||||
if (!bindings.voiceSessionId && metadata.voice_session_id) {
|
|
||||||
bindings.voiceSessionId = metadata.voice_session_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard() {
|
|
||||||
state.dashboard = await api('api/v1/dashboard');
|
|
||||||
renderDashboard();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDeals() {
|
|
||||||
const query = ($('salesSearch').value || '').trim();
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query) {
|
|
||||||
params.set('query', query);
|
|
||||||
}
|
|
||||||
const path = params.toString() ? `api/v1/deals?${params.toString()}` : 'api/v1/deals';
|
|
||||||
state.deals = await api(path);
|
|
||||||
renderBoard();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLeads() {
|
|
||||||
state.leads = await api('api/v1/leads');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkspace(dealId) {
|
|
||||||
state.workspaceById[dealId] = await api(`api/v1/deals/${encodeURIComponent(dealId)}/workspace`);
|
|
||||||
renderWorkspace();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAll() {
|
|
||||||
await Promise.all([loadDashboard(), loadDeals(), loadLeads()]);
|
|
||||||
if (state.selectedDealId) {
|
|
||||||
await loadWorkspace(state.selectedDealId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectDeal(dealId) {
|
|
||||||
state.selectedDealId = dealId;
|
|
||||||
renderBoard();
|
|
||||||
renderWorkspace();
|
|
||||||
await loadWorkspace(dealId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedWorkspace() {
|
|
||||||
return state.workspaceById[state.selectedDealId] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createLead() {
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
source_type: 'crm',
|
|
||||||
source_channel: $('leadSourceChannel').value,
|
|
||||||
full_name: $('leadName').value.trim(),
|
|
||||||
company_name: $('leadCompany').value.trim() || null,
|
|
||||||
phone: $('leadPhone').value.trim() || null,
|
|
||||||
email: $('leadEmail').value.trim() || null,
|
|
||||||
lead_temperature: $('leadTemperature').value,
|
|
||||||
lead_score: Number($('leadScore').value || 68),
|
|
||||||
initial_need_summary: $('leadNeedSummary').value.trim() || null,
|
|
||||||
preferred_channel: $('leadPreferredChannel').value,
|
|
||||||
assigned_agent_type: $('leadPreferredChannel').value === 'voice' ? 'voice_ai' : 'text_ai',
|
|
||||||
status: 'new_qualified_lead',
|
|
||||||
priority: 3,
|
|
||||||
title: $('leadName').value.trim() ? `Лид: ${$('leadName').value.trim()}` : null,
|
|
||||||
};
|
|
||||||
await api('api/v1/leads', { method: 'POST', body: JSON.stringify(payload) });
|
|
||||||
log('Лид создан', payload);
|
|
||||||
await refreshAll();
|
|
||||||
} catch (error) {
|
|
||||||
log('Ошибка создания лида', { error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedDemo() {
|
|
||||||
$('leadName').value = 'Demo Buyer';
|
|
||||||
$('leadCompany').value = 'KazTrade Labs';
|
|
||||||
$('leadPhone').value = '+77015551234';
|
|
||||||
$('leadEmail').value = 'buyer@kaztrade.test';
|
|
||||||
$('leadNeedSummary').value = 'Нужен расчет и оффер на пакет услуг с возможностью быстро перейти из чата в звонок.';
|
|
||||||
$('leadPreferredChannel').value = 'telegram';
|
|
||||||
$('leadTemperature').value = 'hot';
|
|
||||||
$('leadScore').value = '81';
|
|
||||||
await createLead();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postQuickAction(handler, successLabel) {
|
|
||||||
if (!state.selectedDealId) {
|
|
||||||
log('Сначала выберите сделку.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await handler();
|
|
||||||
log(successLabel, { deal_id: state.selectedDealId });
|
|
||||||
await refreshAll();
|
|
||||||
} catch (error) {
|
|
||||||
log('Ошибка действия', { error: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTextSession() {
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/communications/text`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
channel_type: 'text',
|
|
||||||
direction: 'outbound',
|
|
||||||
agent_type: 'text_ai',
|
|
||||||
subject: 'Текстовый follow-up по сделке',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Текстовая сессия создана',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startVoiceSession() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const phone = workspace?.lead?.phone || workspace?.counterparty?.phone_for_docs || '+77010000000';
|
|
||||||
return postQuickAction(
|
|
||||||
() => api('api/v1/calls/outbound', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
deal_id: state.selectedDealId,
|
|
||||||
phone_number: phone,
|
|
||||||
provider: 'voice_assistant',
|
|
||||||
subject: 'Голосовой follow-up по сделке',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Голосовая сессия создана',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchToText() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const communication = workspace?.communications?.[0];
|
|
||||||
if (!communication) {
|
|
||||||
log('Нет коммуникации для channel switch.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
to_channel: 'telegram',
|
|
||||||
reason_for_channel_switch: 'После звонка нужно зафиксировать условия письменно',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Сделка переведена в текст',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchToVoice() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const communication = workspace?.communications?.[0];
|
|
||||||
if (!communication) {
|
|
||||||
log('Нет коммуникации для channel switch.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/switch-channel`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
to_channel: 'voice',
|
|
||||||
reason_for_channel_switch: 'Сложный кейс, удобнее перейти в звонок',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Сделка переведена в voice',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOffer() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const amount = Number(workspace?.deal?.estimated_amount || workspace?.deal?.final_amount || 150000);
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/offers`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
offer_type: 'offer',
|
|
||||||
title: `Коммерческое предложение • ${workspace?.deal?.title || 'Сделка'}`,
|
|
||||||
description: 'Автогенерированный оффер для быстрого запуска продажного цикла.',
|
|
||||||
line_items: [{ name: 'Основной пакет', quantity: 1, price: amount }],
|
|
||||||
pricing: { subtotal: amount, vat: 0, total: amount },
|
|
||||||
total_amount: amount,
|
|
||||||
currency: workspace?.deal?.currency || 'KZT',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Оффер создан',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvoice() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const amount = Number(workspace?.deal?.final_amount || workspace?.deal?.estimated_amount || 150000);
|
|
||||||
const firstDocument = workspace?.documents?.[0];
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/invoices`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
basis_document_id: firstDocument?.document_id || null,
|
|
||||||
amount,
|
|
||||||
currency: workspace?.deal?.currency || 'KZT',
|
|
||||||
due_date: $('nextActionAt').value.trim() || null,
|
|
||||||
line_items: [{ title: workspace?.deal?.title || 'Сделка', amount }],
|
|
||||||
metadata: { generated_from: 'sales_ui' },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Счет создан',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markPaid() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const invoice = workspace?.invoices?.[0];
|
|
||||||
if (!invoice) {
|
|
||||||
log('Сначала создайте счет.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api('api/v1/payments/webhook', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
deal_id: state.selectedDealId,
|
|
||||||
invoice_id: invoice.invoice_id,
|
|
||||||
payment_provider: 'manual',
|
|
||||||
amount: invoice.amount,
|
|
||||||
currency: invoice.currency,
|
|
||||||
status: 'success',
|
|
||||||
payment_method: 'bank_transfer',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Оплата зафиксирована',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveStage() {
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/change-stage`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
stage_id: $('dealStageSelect').value,
|
|
||||||
reason: 'Ручное обновление стадии из sales workspace',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Этап обновлен',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveNextAction() {
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/deals/${encodeURIComponent(state.selectedDealId)}/schedule-next-action`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
next_action_type: $('nextActionType').value.trim() || 'follow_up',
|
|
||||||
next_action_at: $('nextActionAt').value.trim() || null,
|
|
||||||
payload: { source: 'sales_ui' },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Next action сохранен',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bindTelegramThread() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const communication = latestCommunication(workspace, 'text');
|
|
||||||
const threadId = $('telegramThreadId').value.trim();
|
|
||||||
if (!communication) {
|
|
||||||
log('Сначала откройте text-сессию по сделке.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!threadId) {
|
|
||||||
log('Укажите telegram thread id.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
channel_provider: 'telegram',
|
|
||||||
telegram_thread_id: threadId,
|
|
||||||
telegram_chat_id: $('telegramChatId').value.trim() || null,
|
|
||||||
metadata: { source: 'sales_ui_binding' },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Telegram thread привязан к сделке',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bindVoiceSession() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const communication = latestCommunication(workspace, 'voice');
|
|
||||||
const voiceSessionId = $('voiceSessionId').value.trim();
|
|
||||||
if (!communication) {
|
|
||||||
log('Сначала откройте voice-сессию по сделке.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!voiceSessionId) {
|
|
||||||
log('Укажите voice session id.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api(`api/v1/communications/${encodeURIComponent(communication.communication_id)}/bind-external`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
channel_provider: 'voice',
|
|
||||||
voice_session_id: voiceSessionId,
|
|
||||||
metadata: { source: 'sales_ui_binding' },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Voice session привязана к сделке',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTelegramReply() {
|
|
||||||
const workspace = selectedWorkspace();
|
|
||||||
const communication = latestCommunication(workspace, 'text');
|
|
||||||
const threadId = $('telegramThreadId').value.trim();
|
|
||||||
const body = $('telegramReplyText').value.trim();
|
|
||||||
if (!threadId) {
|
|
||||||
log('Сначала привяжите Telegram thread.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!body) {
|
|
||||||
log('Введите текст сообщения.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return postQuickAction(
|
|
||||||
() => api('api/v1/messages/outbound', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
deal_id: state.selectedDealId,
|
|
||||||
communication_id: communication?.communication_id || null,
|
|
||||||
channel_provider: 'telegram',
|
|
||||||
sender_type: 'human',
|
|
||||||
sender_id: state.user,
|
|
||||||
body,
|
|
||||||
metadata: {
|
|
||||||
telegram_thread_id: threadId,
|
|
||||||
telegram_chat_id: $('telegramChatId').value.trim() || null,
|
|
||||||
source: 'sales_ui_temp_telegram',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'Сообщение поставлено в Telegram bridge',
|
|
||||||
).then(() => {
|
|
||||||
$('telegramReplyText').value = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBoardClick(event) {
|
|
||||||
const button = event.target.closest('[data-deal-id]');
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectDeal(button.dataset.dealId).catch((error) => {
|
|
||||||
log('Ошибка загрузки сделки', { error: error.message });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
$('loginBtn').addEventListener('click', login);
|
|
||||||
$('logoutBtn').addEventListener('click', logout);
|
|
||||||
$('refreshSalesBtn').addEventListener('click', () => refreshAll().catch((error) => log('Ошибка обновления', { error: error.message })));
|
|
||||||
$('salesSearch').addEventListener('input', renderBoard);
|
|
||||||
$('salesBoard').addEventListener('click', handleBoardClick);
|
|
||||||
$('createLeadBtn').addEventListener('click', createLead);
|
|
||||||
$('seedDemoBtn').addEventListener('click', seedDemo);
|
|
||||||
$('startTextBtn').addEventListener('click', startTextSession);
|
|
||||||
$('startVoiceBtn').addEventListener('click', startVoiceSession);
|
|
||||||
$('switchToTextBtn').addEventListener('click', switchToText);
|
|
||||||
$('switchToVoiceBtn').addEventListener('click', switchToVoice);
|
|
||||||
$('createOfferBtn').addEventListener('click', createOffer);
|
|
||||||
$('createInvoiceBtn').addEventListener('click', createInvoice);
|
|
||||||
$('markPaidBtn').addEventListener('click', markPaid);
|
|
||||||
$('saveStageBtn').addEventListener('click', saveStage);
|
|
||||||
$('saveNextActionBtn').addEventListener('click', saveNextAction);
|
|
||||||
$('bindTelegramBtn').addEventListener('click', bindTelegramThread);
|
|
||||||
$('bindVoiceSessionBtn').addEventListener('click', bindVoiceSession);
|
|
||||||
$('sendTelegramReplyBtn').addEventListener('click', sendTelegramReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
renderStageOptions();
|
|
||||||
bindEvents();
|
|
||||||
updateProfile();
|
|
||||||
await checkGateway();
|
|
||||||
if (restoreStoredSession()) {
|
|
||||||
log('Сессия восстановлена', { user: state.user, role: state.role });
|
|
||||||
await refreshAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init().catch((error) => {
|
|
||||||
log('Ошибка инициализации', { error: error.message });
|
|
||||||
});
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Продажи</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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" />
|
|
||||||
<link rel="stylesheet" href="/sales/assets/styles.css?v=sales-v1" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">S</div>
|
|
||||||
<div>
|
|
||||||
<div class="brand-title">KonturCC</div>
|
|
||||||
<div class="brand-sub">Sales orchestration</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav-menu">
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Рабочие зоны</div>
|
|
||||||
<a class="nav-link" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
|
||||||
<a class="nav-link" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
|
||||||
<a class="nav-link" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
|
||||||
<a class="nav-link" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
|
||||||
<a class="nav-link active" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Секции</div>
|
|
||||||
<a class="nav-link active" href="#overview"><span class="nav-bullet"></span>Воронка</a>
|
|
||||||
<a class="nav-link" href="#dealPanel"><span class="nav-bullet"></span>Карточка сделки</a>
|
|
||||||
<a class="nav-link" href="#createLeadPanel"><span class="nav-bullet"></span>Новый лид</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<strong>Модуль продаж</strong>
|
|
||||||
<p>Одна сделка живет через text и voice. Здесь же этапы, офферы, счета, оплаты, эскалации и handoff между каналами.</p>
|
|
||||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="workspace">
|
|
||||||
<header class="workspace-topbar reveal">
|
|
||||||
<div class="search-box">
|
|
||||||
<input id="salesSearch" class="app-search" placeholder="Найти по сделке, клиенту, задаче или каналу..." />
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
|
|
||||||
<button id="refreshSalesBtn" class="btn ghost" type="button">Обновить</button>
|
|
||||||
<div class="profile-chip">
|
|
||||||
<div class="avatar-badge">SL</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<div class="profile-name" id="profileName">Экран продаж</div>
|
|
||||||
<div class="profile-role" id="profileRole">Omnichannel pipeline</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout sales-layout">
|
|
||||||
<section class="page-hero reveal" id="overview">
|
|
||||||
<p class="eyebrow">Sales Control Room</p>
|
|
||||||
<h1>Омниканальная воронка продаж</h1>
|
|
||||||
<p class="hint">Теплый лид попадает в CRM, дальше система ведет его через коммуникации, оффер, документы, счет, оплату и post-sale handoff без потери контекста.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel auth reveal" id="authPanel">
|
|
||||||
<h2>Сессия</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пользователь</label>
|
|
||||||
<input id="sessionUser" value="admin" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Роль</label>
|
|
||||||
<select id="sessionRole">
|
|
||||||
<option value="admin" selected>Администратор</option>
|
|
||||||
<option value="supervisor">Супервизор</option>
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пароль</label>
|
|
||||||
<input id="sessionPassword" type="password" value="admin123" />
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn" class="btn" type="button">Войти</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sales-dashboard-grid reveal" id="salesDashboard"></section>
|
|
||||||
|
|
||||||
<section class="sales-main-grid">
|
|
||||||
<article class="panel reveal sales-board-panel">
|
|
||||||
<div class="sales-board-head">
|
|
||||||
<div>
|
|
||||||
<h2>Воронка</h2>
|
|
||||||
<p class="hint">Колонки собраны по крупным бизнес-блокам из ТЗ: вход, коммуникация, коммерция, оформление, финансы и закрытие.</p>
|
|
||||||
</div>
|
|
||||||
<div class="sales-toolbar">
|
|
||||||
<button id="openCreateLeadBtn" class="btn" type="button">Добавить лид</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="salesBoard" class="sales-board"></div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="sales-detail-stack">
|
|
||||||
<article class="panel reveal sales-detail-panel" id="dealPanel">
|
|
||||||
<div class="sales-detail-head">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Карточка сделки</p>
|
|
||||||
<h2 id="dealTitle">Выберите сделку</h2>
|
|
||||||
<p id="dealSubtitle" class="hint">Справа будет единый контекст: text + voice, офферы, счета, оплата, timeline.</p>
|
|
||||||
</div>
|
|
||||||
<div id="dealStageBadge" class="status-pill">Нет данных</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sales-quick-actions">
|
|
||||||
<button id="startTextBtn" class="btn ghost" type="button">Текстовая сессия</button>
|
|
||||||
<button id="startVoiceBtn" class="btn ghost" type="button">Голосовая сессия</button>
|
|
||||||
<button id="switchToTextBtn" class="btn ghost" type="button">Voice -> Text</button>
|
|
||||||
<button id="switchToVoiceBtn" class="btn ghost" type="button">Text -> Voice</button>
|
|
||||||
<button id="createOfferBtn" class="btn ghost" type="button">Создать оффер</button>
|
|
||||||
<button id="createInvoiceBtn" class="btn ghost" type="button">Выставить счет</button>
|
|
||||||
<button id="markPaidBtn" class="btn ghost" type="button">Отметить оплату</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<select id="dealStageSelect"></select>
|
|
||||||
<button id="saveStageBtn" class="btn" type="button">Сменить этап</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="nextActionType" placeholder="Следующий шаг, например follow_up_call" />
|
|
||||||
<input id="nextActionAt" placeholder="2026-05-10T12:00:00+05:00" />
|
|
||||||
<button id="saveNextActionBtn" class="btn ghost" type="button">Сохранить next action</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sales-bind-grid">
|
|
||||||
<input id="telegramThreadId" placeholder="Telegram thread id" />
|
|
||||||
<input id="telegramChatId" placeholder="Telegram chat id" />
|
|
||||||
<button id="bindTelegramBtn" class="btn ghost" type="button">Привязать Telegram</button>
|
|
||||||
<input id="voiceSessionId" placeholder="Voice session id" />
|
|
||||||
<button id="bindVoiceSessionBtn" class="btn ghost" type="button">Привязать voice session</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sales-inline-message">
|
|
||||||
<textarea id="telegramReplyText" rows="3" class="json-textarea" placeholder="Временная текстовая отправка через Telegram bot в рамках этой сделки."></textarea>
|
|
||||||
<button id="sendTelegramReplyBtn" class="btn" type="button">Отправить в Telegram</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sales-detail-metrics" id="dealMetrics"></div>
|
|
||||||
<div id="dealWorkspace" class="sales-workspace-empty">Выберите карточку на воронке, чтобы увидеть подробности.</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="createLeadPanel">
|
|
||||||
<h2>Новый лид</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="leadName" placeholder="Имя клиента" />
|
|
||||||
<input id="leadCompany" placeholder="Компания" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="leadPhone" placeholder="Телефон" />
|
|
||||||
<input id="leadEmail" placeholder="Email" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<select id="leadSourceChannel">
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="whatsapp">WhatsApp</option>
|
|
||||||
<option value="webchat">Webchat</option>
|
|
||||||
<option value="email">Email</option>
|
|
||||||
<option value="voice">Voice</option>
|
|
||||||
</select>
|
|
||||||
<select id="leadPreferredChannel">
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
<option value="whatsapp">WhatsApp</option>
|
|
||||||
<option value="webchat">Webchat</option>
|
|
||||||
<option value="email">Email</option>
|
|
||||||
<option value="voice">Voice</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<select id="leadTemperature">
|
|
||||||
<option value="warm">Warm</option>
|
|
||||||
<option value="hot">Hot</option>
|
|
||||||
</select>
|
|
||||||
<input id="leadScore" type="number" min="0" max="100" value="68" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="leadNeedSummary">Краткая потребность</label>
|
|
||||||
<textarea id="leadNeedSummary" rows="4" class="json-textarea" placeholder="Например: клиенту нужен расчет, коммерческое предложение и созвон сегодня."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="createLeadBtn" class="btn" type="button">Создать лид</button>
|
|
||||||
<button id="seedDemoBtn" class="btn ghost" type="button">Создать demo-сделку</button>
|
|
||||||
</div>
|
|
||||||
<pre id="salesLog" class="output">Здесь появятся ответы сервиса и состояние быстрых действий.</pre>
|
|
||||||
</article>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/sales/assets/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
.sales-layout {
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-dashboard-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kpi-card {
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top right, rgba(51, 102, 232, 0.12), transparent 34%),
|
|
||||||
linear-gradient(180deg, #ffffff, #f8fbff);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kpi-label {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kpi-value {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-family: "Manrope", sans-serif;
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kpi-note {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-main-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.95fr);
|
|
||||||
gap: 18px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-detail-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-board-panel,
|
|
||||||
.sales-detail-panel {
|
|
||||||
min-height: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-board-head,
|
|
||||||
.sales-detail-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-board-head h2,
|
|
||||||
.sales-detail-head h2 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-board {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(220px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-column {
|
|
||||||
min-height: 420px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 255, 0.92));
|
|
||||||
padding: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-column-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-column-title {
|
|
||||||
font-family: "Manrope", sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-column-count {
|
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: var(--primary-soft);
|
|
||||||
border: 1px solid var(--primary-line);
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-column-note {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 14px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: var(--primary-line);
|
|
||||||
box-shadow: 0 12px 24px rgba(16, 33, 61, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 16px 30px rgba(51, 102, 232, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-top {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-title {
|
|
||||||
font-family: "Manrope", sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-priority {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #9a4f00;
|
|
||||||
background: #fff2d8;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-body {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-card-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #f4f7fd;
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-workspace-empty {
|
|
||||||
margin-top: 18px;
|
|
||||||
border: 1px dashed var(--line-strong);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 20px;
|
|
||||||
color: var(--text-soft);
|
|
||||||
background: #fbfcff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-quick-actions {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-detail-metrics {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-bind-grid {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-inline-message {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-metric {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #fbfdff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-metric-label {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-metric-value {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-family: "Manrope", sans-serif;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-workspace-grid {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-section-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-section-card h3 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kv {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kv-item {
|
|
||||||
border: 1px solid #edf1f7;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #fbfcff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kv-label {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-kv-value {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-stream {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-stream-item {
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
padding: 0 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-stream-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-stream-time {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-stream-body {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-list-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #f8fbff;
|
|
||||||
border: 1px solid #edf1f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-list-row strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-list-row span {
|
|
||||||
color: var(--text-soft);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1440px) {
|
|
||||||
.sales-board {
|
|
||||||
grid-template-columns: repeat(6, minmax(260px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.sales-main-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-bind-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.sales-detail-metrics,
|
|
||||||
.sales-kv {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sales-bind-grid,
|
|
||||||
.sales-inline-message {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,273 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>KonturCC Супервизор</title>
|
|
||||||
<link rel="icon" href="/operator/assets/favicon.svg" type="image/svg+xml" />
|
|
||||||
<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" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark">K</div>
|
|
||||||
<div>
|
|
||||||
<div class="brand-title">KonturCC</div>
|
|
||||||
<div class="brand-sub">Операционный контроль</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav-menu">
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Рабочие зоны</div>
|
|
||||||
<a class="nav-link" data-shell="operator" data-roles="admin,supervisor,operator,analyst" href="/operator"><span class="nav-bullet"></span>Оператор</a>
|
|
||||||
<a class="nav-link active" data-shell="supervisor" data-roles="admin,supervisor" href="/supervisor"><span class="nav-bullet"></span>Супервизор</a>
|
|
||||||
<a class="nav-link" data-shell="analyst" data-roles="admin,supervisor,analyst" href="/analyst"><span class="nav-bullet"></span>Аналитика</a>
|
|
||||||
<a class="nav-link" data-shell="sales" data-roles="admin,supervisor,operator,analyst" href="/sales"><span class="nav-bullet"></span>Продажи</a>
|
|
||||||
<a class="nav-link" data-shell="admin" data-roles="admin" href="/admin"><span class="nav-bullet"></span>Администрирование</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Блоки</div>
|
|
||||||
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
|
|
||||||
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
|
|
||||||
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
|
|
||||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
|
||||||
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-label">Записи</div>
|
|
||||||
<a class="nav-link" href="#recordings"><span class="nav-bullet"></span>Записи</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<strong>Экран супервизора</strong>
|
|
||||||
<p>Сводка по агентам, очередям и KPI без захода в операторский экран.</p>
|
|
||||||
<button id="logoutBtn" class="btn ghost" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="workspace">
|
|
||||||
<header class="workspace-topbar reveal">
|
|
||||||
<div class="search-box">
|
|
||||||
<input class="app-search" placeholder="Найти агента, очередь или KPI..." />
|
|
||||||
</div>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<div class="status-pill" id="gatewayStatus">Шлюз: проверка...</div>
|
|
||||||
<button class="icon-button" type="button" aria-label="Уведомления">•</button>
|
|
||||||
<div class="profile-chip">
|
|
||||||
<div class="avatar-badge">SV</div>
|
|
||||||
<div class="profile-meta">
|
|
||||||
<div class="profile-name" id="profileName">Экран супервизора</div>
|
|
||||||
<div class="profile-role" id="profileRole">Операционный контроль</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="layout">
|
|
||||||
<section class="page-hero reveal">
|
|
||||||
<p class="eyebrow">Операционный контур</p>
|
|
||||||
<h1>Пульт супервизора</h1>
|
|
||||||
<p class="hint">Операционный обзор: состояние команды, очередей и ключевых KPI.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel auth reveal" id="authPanel">
|
|
||||||
<h2>Сессия</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пользователь</label>
|
|
||||||
<input id="sessionUser" value="supervisor" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Роль</label>
|
|
||||||
<select id="sessionRole">
|
|
||||||
<option value="supervisor" selected>Супервизор</option>
|
|
||||||
<option value="admin">Администратор</option>
|
|
||||||
<option value="operator">Оператор</option>
|
|
||||||
<option value="analyst">Аналитик</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Пароль</label>
|
|
||||||
<input id="sessionPassword" type="password" value="sup12345" />
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn" class="btn">Войти</button>
|
|
||||||
<button id="corporateLoginBtn" class="btn ghost" type="button">Корпоративный вход</button>
|
|
||||||
<button id="refreshBtn" class="btn ghost">Обновить все</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="sessionInfo">Токен: отсутствует</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid">
|
|
||||||
<article class="panel reveal" id="realtime">
|
|
||||||
<h2>Оперативный срез</h2>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadRealtimeBtn" class="btn ghost">Обновить срез</button>
|
|
||||||
<button id="loadKpiBtn" class="btn ghost">Обновить KPI</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Этот блок нужен для оперативной проверки и технической диагностики на экране супервизора.</p>
|
|
||||||
<div id="realtimeSummary" class="summary-grid"></div>
|
|
||||||
<details class="details-block">
|
|
||||||
<summary>Технический ответ оперативного среза</summary>
|
|
||||||
<pre id="realtimeOutput" class="output"></pre>
|
|
||||||
</details>
|
|
||||||
<div id="kpiSummary" class="summary-grid"></div>
|
|
||||||
<details class="details-block">
|
|
||||||
<summary>Технический ответ KPI</summary>
|
|
||||||
<pre id="kpiOutput" class="output"></pre>
|
|
||||||
</details>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="liveCalls">
|
|
||||||
<h2>Живые звонки Asterisk</h2>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="loadLiveCallsBtn" class="btn ghost">Обновить звонки</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Показываются последние звонки, пришедшие от Asterisk.</p>
|
|
||||||
<ul id="liveCallsList" class="list"></ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="agents">
|
|
||||||
<h2>Агенты</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="agentId" placeholder="ID агента" value="supervisor_demo_agent" />
|
|
||||||
<select id="agentState">
|
|
||||||
<option value="READY">Готовы</option>
|
|
||||||
<option value="BUSY">Заняты</option>
|
|
||||||
<option value="BREAK">BREAK</option>
|
|
||||||
<option value="OFFLINE">OFFLINE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="agentQueueId" placeholder="Очередь" value="line2" />
|
|
||||||
<button id="setAgentBtn" class="btn">Обновить статус</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="loadAgentsBtn" class="btn ghost">Показать агентов</button>
|
|
||||||
</div>
|
|
||||||
<ul id="agentsList" class="list"></ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="queues">
|
|
||||||
<h2>Очередь</h2>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="queueMetricId" placeholder="ID очереди" value="line2" />
|
|
||||||
<input id="queueInQueue" placeholder="В очереди" type="number" value="2" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="queueAvgWait" placeholder="Среднее ожидание (сек)" type="number" value="18" />
|
|
||||||
<button id="saveQueueMetricsBtn" class="btn">Обновить очередь</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Используйте это как быстрый операционный контроль для демонстрационного среза.</p>
|
|
||||||
<pre id="queueMetricsOutput" class="output"></pre>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal panel-span-full" id="knowledge">
|
|
||||||
<h2>База знаний</h2>
|
|
||||||
<p class="hint">Супервизор может вручную создать категорию, добавить статью и сразу обновить базу знаний для Voice AI.</p>
|
|
||||||
<div id="kbSummary" class="summary-grid"></div>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="kbCategoryName" placeholder="Новая категория" />
|
|
||||||
<input id="kbCategoryDescription" placeholder="Описание категории" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<button id="createKbCategoryBtn" class="btn">Добавить категорию</button>
|
|
||||||
<button id="loadKbBtn" class="btn ghost" type="button">Обновить базу знаний</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<select id="kbArticleCategory"></select>
|
|
||||||
<select id="kbArticleLanguage">
|
|
||||||
<option value="ru">RU</option>
|
|
||||||
<option value="kz">KZ</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="kbArticleGroupId" placeholder="ID группы статей (связка RU/KZ)" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="kbArticleTitle" placeholder="Заголовок статьи" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="kbArticleTags" placeholder="Теги через запятую" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="kbArticleBody">Текст статьи</label>
|
|
||||||
<textarea id="kbArticleBody" rows="6" placeholder="Например: график работы, адреса, частые вопросы, правила маршрутизации..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="actions compact-actions">
|
|
||||||
<button id="saveKbArticleBtn" class="btn">Сохранить статью</button>
|
|
||||||
<button id="resetKbArticleBtn" class="btn ghost" type="button">Новая статья</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint" id="kbFormHint">Создайте новую статью или выберите существующую для редактирования.</p>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="kbSearchQuery" placeholder="Поиск по базе знаний" />
|
|
||||||
<button id="searchKbBtn" class="btn ghost" type="button">Искать</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<div>
|
|
||||||
<h3>Категории</h3>
|
|
||||||
<ul id="kbCategoriesList" class="list"></ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Статьи</h3>
|
|
||||||
<ul id="kbArticlesList" class="list"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details class="details-block">
|
|
||||||
<summary>Предпросмотр выбранной статьи</summary>
|
|
||||||
<pre id="kbArticlePreview" class="output"></pre>
|
|
||||||
</details>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal">
|
|
||||||
<h2>Лог действий</h2>
|
|
||||||
<p class="hint">Сверху всегда последнее действие супервизора.</p>
|
|
||||||
<pre id="eventLog" class="output tall"></pre>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel reveal" id="recordings">
|
|
||||||
<h2>Записи</h2>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<input id="recordingEventId" placeholder="ID voice_event / обращения / звонка" />
|
|
||||||
<button id="importRecordingBtn" class="btn">Импорт / поиск</button>
|
|
||||||
</div>
|
|
||||||
<div class="inline-form split-two">
|
|
||||||
<input id="recordingCallId" placeholder="Фильтр по ID звонка" />
|
|
||||||
<input id="recordingInteractionId" placeholder="Фильтр по ID обращения" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-form compact">
|
|
||||||
<select id="recordingStatus">
|
|
||||||
<option value="">Все</option>
|
|
||||||
<option value="ready">Готова</option>
|
|
||||||
<option value="archived">В архиве</option>
|
|
||||||
<option value="missing">Отсутствует</option>
|
|
||||||
</select>
|
|
||||||
<button id="loadRecordingsBtn" class="btn ghost">Показать записи</button>
|
|
||||||
<button id="archiveRecordingBtn" class="btn ghost" type="button">Архивировать</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Для Asterisk запись обычно импортируется автоматически после завершения звонка.</p>
|
|
||||||
<ul id="recordingsList" class="list"></ul>
|
|
||||||
<div class="recording-detail">
|
|
||||||
<div class="hint" id="recordingSelectionHint">Выберите запись для предпросмотра.</div>
|
|
||||||
<pre id="recordingMetaOutput" class="output"></pre>
|
|
||||||
<audio id="recordingPlayer" controls preload="none"></audio>
|
|
||||||
<button id="downloadRecordingBtn" class="btn ghost" type="button">Скачать</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/supervisor/assets/app.js?v=track34-supervisor-ru-polish"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user