This commit is contained in:
Magzhan Zhumabayev
2026-05-10 20:55:55 +05:00
parent 8577d97356
commit 8e51290f94
19 changed files with 0 additions and 27867 deletions
-1418
View File
File diff suppressed because it is too large Load Diff
-506
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-454
View File
@@ -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>
-207
View File
@@ -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
View File
@@ -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();
-62
View File
@@ -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>
-70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -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

-762
View File
@@ -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">&larr; К списку клиентов</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>
-2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-927
View File
@@ -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 });
});
-215
View File
@@ -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>
-373
View File
@@ -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;
}
}
-1027
View File
File diff suppressed because it is too large Load Diff
-273
View File
@@ -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>