chatRAG / app /admin /templates /admin.html
joedown11's picture
fix: revert to bare number format, add Baileys session connection pre-check to test endpoint
ee3fec5
Raw
History Blame Contribute Delete
239 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Martechsol β€” Admin Dashboard</title>
<link rel="icon" href="/martech_sol_logo.jpg" type="image/jpeg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f8fafc;
--surface: #ffffff;
--surface-hover: #f1f5f9;
--border: #e2e8f0;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent2: #a855f7;
--accent-light: #ede9fe;
--green: #10b981;
--red: #ef4444;
--yellow: #f59e0b;
--text: #0f172a;
--text2: #475569;
--text3: #94a3b8;
--radius: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
html,
body {
height: 100%;
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ── Utilities ── */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 8px;
}
.gap-4 {
gap: 16px;
}
.hidden {
display: none !important;
}
/* ── Login Screen ── */
#login-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
z-index: 9999;
}
.login-card {
width: 380px;
background: var(--surface);
border-radius: 24px;
padding: 48px 40px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
animation: fadeUp .5s ease;
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo svg {
width: 56px;
height: 56px;
margin: 0 auto;
display: block;
}
.login-logo h1 {
font-size: 24px;
font-weight: 700;
margin-top: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-logo p {
font-size: 14px;
color: var(--text2);
margin-top: 4px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-size: 15px;
outline: none;
transition: all .2s;
}
.form-group input:focus {
border-color: var(--accent);
background: #fff;
box-shadow: 0 0 0 3px var(--accent-light);
}
.btn-primary {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: all .2s;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.btn-primary:hover {
opacity: .9;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
background: var(--surface);
color: var(--text2);
transition: all .2s;
}
.btn-secondary:hover {
background: var(--surface-hover);
color: var(--text);
}
.login-error {
display: none;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 12px;
font-size: 14px;
color: #b91c1c;
margin-top: 16px;
text-align: center;
}
/* ── App Layout ── */
#app {
display: none;
height: 100vh;
width: 100vw;
flex-direction: row;
}
/* ── Resizer ── */
.resizer {
width: 6px;
background: var(--border);
cursor: col-resize;
transition: background 0.2s;
z-index: 20;
}
.resizer:hover,
.resizer.dragging {
background: var(--accent);
}
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
transition: background .2s;
position: relative;
}
.btn-icon:hover {
background: var(--surface-hover);
color: var(--text);
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: var(--red);
border-radius: 50%;
border: 2px solid var(--surface);
display: none;
}
.notification-dropdown {
position: absolute;
top: 60px;
right: 32px;
width: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg);
display: none;
flex-direction: column;
z-index: 100;
overflow: hidden;
animation: fadeUp .2s ease;
}
.notification-dropdown.show {
display: flex;
}
.notif-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: var(--text);
font-size: 14px;
}
.notif-list {
max-height: 300px;
overflow-y: auto;
}
.notif-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
transition: background .2s;
}
.notif-item:hover {
background: var(--surface-hover);
}
.notif-item-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.notif-item-desc {
font-size: 12px;
color: var(--text2);
}
.notif-item-time {
font-size: 10px;
color: var(--text3);
align-self: flex-end;
}
.notif-empty {
padding: 32px 16px;
text-align: center;
color: var(--text3);
font-size: 13px;
}
/* ── Sidebar ── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 10;
flex: 0 0 280px;
min-width: 200px;
max-width: 600px;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 24px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.sidebar-header svg {
width: 32px;
height: 32px;
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.nav-menu {
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
color: var(--text2);
font-weight: 500;
font-size: 15px;
cursor: pointer;
transition: all .2s;
border: 1px solid transparent;
}
.nav-item svg {
width: 20px;
height: 20px;
opacity: 0.7;
}
.nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--accent-light);
color: var(--accent);
border-color: rgba(139, 92, 246, 0.2);
}
.nav-item.active svg {
opacity: 1;
}
.nav-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text3);
margin: 20px 0 8px 16px;
}
.quick-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-radius: 8px;
color: var(--text2);
font-size: 13px;
text-decoration: none;
transition: background .2s;
margin-bottom: 4px;
}
.quick-link:hover {
background: var(--surface-hover);
color: var(--accent);
}
.quick-link svg {
width: 14px;
height: 14px;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border);
}
/* ── Main Area ── */
.main-area {
display: flex;
flex-direction: column;
background: var(--bg);
overflow: hidden;
}
.topbar {
height: 70px;
padding: 0 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.topbar-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.view-container {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 32px;
display: none;
}
.view-container.active {
display: flex;
flex-direction: column;
animation: fadeUp .4s ease;
}
/* ── Dashboard View ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: var(--surface);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-card-title {
font-size: 14px;
font-weight: 500;
color: var(--text2);
}
.stat-card-value {
font-size: 36px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-card-value.accent {
color: var(--accent);
}
.chart-container {
background: var(--surface);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
height: 400px;
width: 100%;
}
/* ── Sessions View ── */
#view-sessions {
padding: 0;
flex-direction: row;
}
.sessions-sidebar {
width: 320px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sessions-header {
padding: 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
}
.search-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
font-size: 14px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
}
.session-actions {
display: flex;
gap: 8px;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.session-item {
padding: 14px;
border-radius: 12px;
cursor: pointer;
margin-bottom: 8px;
border: 1px solid transparent;
transition: all .2s;
}
.session-item:hover {
background: var(--bg);
border-color: var(--border);
}
.session-item.active {
background: var(--accent-light);
border-color: rgba(139, 92, 246, 0.3);
}
.session-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.session-item-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item.active .session-item-name {
color: var(--accent);
}
.session-item-time {
font-size: 11px;
color: var(--text3);
}
.session-item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text2);
}
.msg-badge {
background: var(--bg);
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
}
.session-item.active .msg-badge {
background: #fff;
color: var(--accent);
border-color: rgba(139, 92, 246, 0.3);
}
.chat-view {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
}
.chat-header {
padding: 20px 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-header-info h3 {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.chat-header-info p {
font-size: 13px;
color: var(--text2);
}
.chat-header-actions {
display: flex;
gap: 12px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.msg-wrapper {
display: flex;
flex-direction: column;
max-width: 80%;
}
.msg-wrapper.user {
align-self: flex-end;
}
.msg-wrapper.bot {
align-self: flex-start;
}
.msg-meta {
font-size: 11px;
color: var(--text3);
margin-bottom: 6px;
display: flex;
gap: 8px;
align-items: center;
}
.msg-wrapper.user .msg-meta {
justify-content: flex-end;
}
.msg-bubble {
padding: 16px 20px;
border-radius: 18px;
font-size: 15px;
line-height: 1.6;
box-shadow: var(--shadow-sm);
white-space: pre-wrap;
word-wrap: break-word;
}
.msg-wrapper.user .msg-bubble {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-bottom-right-radius: 4px;
}
.msg-wrapper.bot .msg-bubble {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
border-bottom-left-radius: 4px;
border: none;
}
.placeholder-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text3);
}
.placeholder-view svg {
width: 64px;
height: 64px;
opacity: 0.5;
margin-bottom: 16px;
}
/* ── Permission Dialog ── */
#perm-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn .25s ease;
}
#perm-dialog-overlay.hidden {
display: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.perm-card {
background: var(--surface);
border-radius: 24px;
padding: 36px 40px 32px;
width: 440px;
max-width: 95vw;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
animation: slideUp .3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(24px) scale(.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.perm-header {
text-align: center;
margin-bottom: 28px;
}
.perm-header .perm-icon-wrap {
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
box-shadow: 0 8px 20px rgba(139, 92, 246, 0.35);
}
.perm-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
}
.perm-header p {
font-size: 13px;
color: var(--text2);
line-height: 1.5;
}
.perm-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.perm-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 14px;
border: 1.5px solid var(--border);
background: var(--bg);
transition: border-color .2s;
}
.perm-row.granted {
border-color: #10b981;
background: #f0fdf4;
}
.perm-row.denied {
border-color: #ef4444;
background: #fef2f2;
}
.perm-row-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--accent-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.perm-row.granted .perm-row-icon {
background: #dcfce7;
}
.perm-row.denied .perm-row-icon {
background: #fee2e2;
}
.perm-row-info {
flex: 1;
min-width: 0;
}
.perm-row-info strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
margin-bottom: 2px;
}
.perm-row-info span {
font-size: 12px;
color: var(--text2);
}
.perm-status {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
flex-shrink: 0;
}
.perm-status.ok {
background: #dcfce7;
color: #15803d;
}
.perm-status.bad {
background: #fee2e2;
color: #b91c1c;
}
.perm-status.prompt {
background: var(--accent-light);
color: var(--accent);
}
.perm-grant-btn {
padding: 6px 14px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: opacity .2s, transform .2s;
flex-shrink: 0;
}
.perm-grant-btn:hover {
opacity: .88;
transform: translateY(-1px);
}
.perm-grant-btn:disabled {
opacity: .4;
cursor: not-allowed;
transform: none;
}
.perm-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
#btn-perm-enable-all {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: all .2s;
box-shadow: 0 4px 14px rgba(139, 92, 246, .35);
}
#btn-perm-enable-all:hover {
opacity: .9;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(139, 92, 246, .45);
}
#btn-perm-skip {
width: 100%;
padding: 11px;
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
background: var(--surface);
color: var(--text2);
transition: all .2s;
}
#btn-perm-skip:hover {
background: var(--surface-hover);
color: var(--text);
}
/* ── Toasts & Modals ── */
.toast {
position: fixed;
bottom: 32px;
right: 32px;
padding: 16px 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 14px;
font-weight: 500;
color: var(--text);
box-shadow: var(--shadow-lg);
transform: translateY(20px);
opacity: 0;
transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 9998;
display: flex;
align-items: center;
gap: 12px;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
border-left: 4px solid var(--green);
}
.toast.error {
border-left: 4px solid var(--red);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all .2s;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--surface);
border-radius: 20px;
padding: 32px;
width: 420px;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: all .2s;
}
.modal-overlay.open .modal {
transform: scale(1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text3);
padding: 4px;
}
.modal-close:hover {
color: var(--text);
}
.export-option {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all .2s;
}
.export-option:hover {
background: var(--bg);
}
.export-option.selected {
border-color: var(--accent);
background: var(--accent-light);
}
.export-option input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.export-option-text strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.export-option-text span {
display: block;
font-size: 12px;
color: var(--text2);
margin-top: 4px;
}
.format-btn {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
font-weight: 600;
font-size: 13px;
color: var(--text2);
cursor: pointer;
}
.format-btn.active {
background: var(--surface);
border-color: var(--accent);
color: var(--accent);
box-shadow: var(--shadow-sm);
}
/* ── Animations ── */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
margin: auto;
}
/* Reply area: hidden by .hidden class, flex when visible */
.chat-reply-area {
display: flex;
}
.chat-reply-area.hidden {
display: none !important;
}
/* Settings view padding */
#view-settings {
padding: 32px;
}
/* Sessions view no padding */
#view-sessions {
padding: 0;
}
/* ── Queued Users Panel ── */
.queue-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
margin-top: 24px;
}
.queue-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.queue-panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text1);
display: flex;
align-items: center;
gap: 8px;
}
.queue-panel-title .queue-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse-dot 2s infinite;
}
.queue-panel-count {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
padding: 3px 10px;
border-radius: 20px;
}
.queue-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.queue-item:hover {
border-color: var(--accent);
background: var(--surface-hover);
}
.queue-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.queue-item-name {
font-size: 13px;
font-weight: 600;
color: var(--text1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-timer {
font-size: 11px;
color: #f59e0b;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.queue-takeover-btn {
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
flex-shrink: 0;
}
.queue-takeover-btn:hover {
background: var(--accent-hover);
}
.queue-empty {
text-align: center;
padding: 28px 16px;
color: var(--text3);
font-size: 13px;
}
.queue-empty svg {
width: 36px;
height: 36px;
margin-bottom: 8px;
opacity: 0.35;
}
@keyframes queue-flash {
0% {
background: rgba(245, 158, 11, 0.18);
}
100% {
background: var(--bg);
}
}
.queue-item-new {
animation: queue-flash 1.5s ease-out;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(139, 92, 246, 0); }
50% { box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.25); }
}
.session-assigned-to-me {
border-left: 3px solid var(--accent) !important;
animation: glow-pulse 2s ease-in-out infinite;
}
.assignment-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--accent-light);
color: var(--accent);
white-space: nowrap;
}
.queue-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: #f59e0b;
border-radius: 10px;
display: none;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ── Settings Panel ── */
.settings-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: var(--text2);
cursor: pointer;
transition: all .15s;
}
.settings-nav-item svg {
opacity: .7;
flex-shrink: 0;
}
.settings-nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.settings-nav-item.active {
background: var(--accent-light);
color: var(--accent);
}
.settings-nav-item.active svg {
opacity: 1;
}
.settings-section {
display: none;
}
.settings-section.active {
display: block;
}
.settings-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--surface);
}
.settings-row-icon {
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-toggle {
position: relative;
width: 38px;
height: 22px;
flex-shrink: 0;
cursor: pointer;
}
.settings-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.settings-toggle-track {
position: absolute;
inset: 0;
border-radius: 11px;
background: var(--border);
transition: background .2s;
}
.settings-toggle input:checked+.settings-toggle-track {
background: var(--accent);
}
.settings-toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
transition: transform .2s;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.settings-toggle input:checked~.settings-toggle-thumb {
transform: translateX(16px);
}
.settings-badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
flex-shrink: 0;
background: #dcfce7;
color: #15803d;
}
/* Custom Context Menu */
#custom-context-menu {
position: fixed;
z-index: 10000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: 6px;
display: none;
flex-direction: column;
min-width: 160px;
overflow: hidden;
transform-origin: top left;
animation: contextMenuPop 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes contextMenuPop {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.ctx-menu-item {
padding: 10px 14px;
font-size: 13px;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
border-radius: 8px;
transition: background 0.1s;
}
.ctx-menu-item:hover {
background: var(--surface-hover);
}
.ctx-menu-item.danger {
color: var(--red);
}
.ctx-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.ctx-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.session-item-archived {
opacity: 0.65;
}
</style>
</head>
<body>
<!-- LOGIN SCREEN -->
<div id="login-screen">
<div class="login-card">
<div class="login-logo">
<img src="/martech_sol_logo.jpg" alt="Martechsol Logo"
style="width: 56px; height: 56px; margin-bottom: 16px; border-radius: 12px; object-fit: contain;" />
<h1>Welcome Back</h1>
<p>Sign in to manage your AI agents</p>
</div>
<div id="login-stage-1">
<div class="form-group">
<label>Username</label>
<input type="text" id="inp-user" placeholder="admin" autocomplete="username" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="inp-pass" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password" />
</div>
<button class="btn-primary" id="btn-login">Sign In</button>
</div>
<div id="login-stage-2" class="hidden">
<div class="form-group">
<label>Security Code (OTP)</label>
<input type="text" id="inp-otp" placeholder="123456" maxlength="6"
style="text-align:center;letter-spacing:8px;font-size:20px;font-weight:700" />
<p id="otp-hint" style="font-size:12px;color:var(--text2);margin-top:12px;text-align:center">Code sent to your
email.</p>
</div>
<button class="btn-primary" id="btn-verify">Verify Code</button>
<button class="btn-secondary" id="btn-back" style="margin-top:12px">Back</button>
</div>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- APP CONTAINER -->
<div id="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<svg viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#lg)" />
<path d="M10 16h12M16 10v12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
</svg>
<h2>Admin Panel</h2>
</div>
<nav class="nav-menu">
<div class="nav-item active" data-view="dashboard" id="nav-dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Dashboard
</div>
<div class="nav-item" data-view="sla" id="nav-sla">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
SLA Dashboard
</div>
<div class="nav-item" data-view="sessions" id="nav-sessions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
Live Sessions
</div>
<div class="nav-item" data-view="queued" id="nav-queued" style="position:relative;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
Queued Users
<span class="queue-badge" id="queue-badge">0</span>
</div>
<div class="nav-item" data-view="daily" id="nav-daily">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Daily Users
</div>
<div class="nav-item" data-view="blocked" id="nav-blocked">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
Blocked Users
</div>
<div class="nav-item" data-view="archived" id="nav-archived">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
Archived
</div>
<div class="nav-item" data-view="settings" id="nav-settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg>
Settings
</div>
<div class="nav-item" data-view="admin-users" id="nav-admin-users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
Admin Users
</div>
<div class="nav-section-title">Quick Links</div>
<a href="/widget" target="_blank" class="quick-link">
Chat Widget Demo
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</nav>
<div class="sidebar-footer">
<div id="sidebar-profile-chip" style="display:flex; align-items:center; gap:10px; padding:10px 4px 14px; border-bottom:1px solid var(--border); margin-bottom:12px;">
<div id="profile-avatar" style="width:36px; height:36px; border-radius:50%; background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:15px; flex-shrink:0;">A</div>
<div style="flex:1; min-width:0;">
<div id="profile-username" style="font-size:13px; font-weight:600; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">Admin</div>
<div id="profile-role-badge" style="font-size:10px; font-weight:600; display:inline-block; padding:1px 8px; border-radius:20px; background:#dcfce7; color:#15803d; margin-top:2px;">Superadmin</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<button class="btn-secondary" id="btn-my-profile" style="width:100%; border-color:transparent; font-size:13px; padding:8px; display:flex; align-items:center; justify-content:center; gap:6px;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
My Profile <span id="sidebar-duty-dot" style="width:8px; height:8px; border-radius:50%; background:#10b981; display:inline-block;"></span>
</button>
<button class="btn-secondary" id="btn-change-password" style="width:100%; border-color:transparent; font-size:13px; padding:8px; display:flex; align-items:center; justify-content:center; gap:6px;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Change Password
</button>
<button class="btn-secondary" id="btn-logout" style="width:100%; border-color:transparent; font-size:13px; padding:8px; color:var(--red);">Sign Out</button>
</div>
</div>
</aside>
<div class="resizer" id="sidebar-resizer"></div>
<!-- Main Content -->
<main class="main-content" style="flex:1; min-width:0; display:flex; flex-direction:column; height:100vh;">
<!-- Notification Banner -->
<div id="notif-permission-banner"
style="display:none; background:var(--red); color:white; padding:10px 20px; font-size:13px; font-weight:500; text-align:center; align-items:center; justify-content:center; gap:10px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Your browser is blocking notifications. Please click the lock icon in the URL bar, allow notifications, and
refresh the page to receive alerts.
</div>
<header class="topbar">
<div class="topbar-title" id="topbar-title">Dashboard Overview</div>
<div class="flex items-center gap-4">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);cursor:pointer">
<input type="checkbox" id="toggle-refresh" checked style="accent-color:var(--accent)" /> Auto-refresh
</label>
<button class="btn-secondary" id="btn-open-export" style="padding: 8px 16px; font-size: 13px;">
Export Data
</button>
<div style="position: relative;">
<button class="btn-icon" id="btn-notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:20px;height:20px;">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span class="notification-badge" id="notif-badge"></span>
</button>
<div class="notification-dropdown" id="notif-dropdown">
<div class="notif-header">
Notifications
<label
style="font-size:11px; font-weight:normal; display:flex; align-items:center; gap:4px; color:var(--text2); cursor:pointer;">
<input type="checkbox" id="toggle-snooze" style="accent-color:var(--accent);" /> Snooze
</label>
</div>
<div class="notif-list" id="notif-list">
<div class="notif-empty">No new notifications</div>
</div>
</div>
</div>
</div>
</header>
<!-- View: Dashboard -->
<div class="view-container active" id="view-dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Total Sessions</div>
<div class="stat-card-value accent" id="dash-total-sessions">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Today's Sessions</div>
<div class="stat-card-value" id="dash-today-sessions">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Total Messages</div>
<div class="stat-card-value" id="dash-total-messages">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Live Chats</div>
<div class="stat-card-value" id="dash-live" style="color:var(--green)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">In Queue</div>
<div class="stat-card-value" id="dash-queued" style="color:var(--yellow)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Daily Users</div>
<div class="stat-card-value accent" id="dash-daily">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Blocked</div>
<div class="stat-card-value" id="dash-blocked" style="color:var(--red)">-</div>
</div>
</div>
<div class="chart-container">
<canvas id="messagesChart"></canvas>
</div>
<!-- Queued Users Panel -->
<div class="queue-panel" id="queue-panel-dashboard">
<div class="queue-panel-header">
<div class="queue-panel-title">
<span class="queue-dot"></span>
Users in Queue
</div>
<span class="queue-panel-count" id="queue-panel-count">0 waiting</span>
</div>
<div class="queue-items" id="queue-items"></div>
</div>
</div>
<!-- View: SLA Dashboard -->
<div class="view-container" id="view-sla">
<h3 style="margin-bottom:24px; color:var(--text1); font-size:18px;">SLA Metrics Overview</h3>
<p style="color:var(--text2); font-size:13px; margin-bottom:24px;">Monitoring response times, queue delays, and
overall chat resolution speed.</p>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Avg. Queue Wait Time</div>
<div class="stat-card-value" id="sla-queue-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Avg. Agent Reply Time</div>
<div class="stat-card-value" id="sla-agent-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Avg. Resolution Time</div>
<div class="stat-card-value" id="sla-res-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">SLA Breaches (>3 min)</div>
<div class="stat-card-value" id="sla-breaches" style="color:var(--red)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Breach Rate</div>
<div class="stat-card-value" id="sla-breach-rate">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Human Takeover Rate</div>
<div class="stat-card-value accent" id="sla-takeover-rate">-</div>
</div>
</div>
</div>
<!-- View: Settings -->
<div class="view-container" id="view-settings" style="padding:0; flex-direction:row !important ;">
<!-- Settings nav sidebar -->
<div
style="width:220px; flex-shrink:0; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; padding:20px 10px; gap:2px;">
<div
style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:8px 12px 4px;">
General</div>
<div class="settings-nav-item active" data-settings-sec="notifications">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
Notifications
</div>
<div class="settings-nav-item" data-settings-sec="whatsapp">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
WhatsApp
</div>
<div class="settings-nav-item" data-settings-sec="escalation" id="nav-settings-escalation">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Escalation
</div>
<div
style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:16px 12px 4px;">
System</div>
<div class="settings-nav-item" data-settings-sec="config">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Config
</div>
<div class="settings-nav-item" data-settings-sec="security">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Security
</div>
<div class="settings-nav-item" data-settings-sec="version">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Version info
</div>
</div>
<!-- Settings content area -->
<div style="flex:1; overflow-y:auto; padding:32px 40px;">
<!-- NOTIFICATIONS SECTION -->
<div class="settings-section active" id="settings-sec-notifications">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Notifications</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Control exactly which alert paths fire
when a user requests human support.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; margin-bottom:16px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#ede9fe; color:#7c3aed;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">OS popup alerts</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Browser notification shown even when
this tab is minimised</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-os" onchange="saveNotifSetting('os_popup', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#fef3c7; color:#d97706;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Background sound</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">HTMLAudioElement chime β€” plays while
tab is hidden <strong style="color:var(--text2)">(path 1)</strong></div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-bgsound"
onchange="saveNotifSetting('sound_background', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#d1fae5; color:#059669;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Foreground sound</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">AudioContext beep when the tab is
open and active <strong style="color:var(--text2)">(path 2)</strong></div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-fgsound"
onchange="saveNotifSetting('sound_foreground', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row" style="background:#fef2f2;">
<div class="settings-row-icon" style="background:#fee2e2; color:#dc2626;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-3.75" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Re-play on tab focus <span
style="font-size:10px; font-weight:600; background:#fee2e2; color:#b91c1c; padding:2px 7px; border-radius:20px; margin-left:6px; vertical-align:1px;">Double-sound
fix</span></div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Replay sound when you switch back to
this tab β€” disable to prevent double-chime</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-replay"
onchange="saveNotifSetting('sound_onfocus', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#dbeafe; color:#2563eb;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Flash tab title</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Blink "πŸ”” New Alert!" in the browser
tab title bar</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-flash"
onchange="saveNotifSetting('title_flash', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
</div>
<div
style="font-size:12px; color:var(--text2); padding:12px 14px; background:var(--accent-light); border-left:3px solid var(--accent); border-radius:4px; line-height:1.6; margin-top:16px;">
<strong>How the sound paths work:</strong> Background sound (HTMLAudio) already plays successfully in
hidden tabs once audio is unlocked. Enabling "Re-play on tab focus" fires a second time when you switch
back β€” leaving it <strong>off</strong> eliminates the double-chime.
</div>
</div>
<!-- WHATSAPP SECTION -->
<div class="settings-section" id="settings-sec-whatsapp">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">WhatsApp</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Configure who receives WhatsApp
notifications when a user requests HR/supervisor.</p>
<div id="whatsapp-numbers-container" style="display:flex; flex-direction:column; gap:8px;"></div>
<button id="btn-add-whatsapp-number"
style="margin-top:8px; padding:10px 12px; background:var(--surface); border:1px dashed var(--border); border-radius:8px; color:var(--accent); font-size:12px; font-weight:600; cursor:pointer; width:100%; display:flex; align-items:center; justify-content:center; gap:6px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Another Number
</button>
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">Country code + number, no
spaces or + sign. Example: 923XXXXXXXXX</span>
<div style="margin-top:20px; display:flex; flex-direction:column; gap:16px; max-width:480px;">
<div>
<label style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Provider</label>
<select id="inp-whatsapp-provider" style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1); margin-bottom: 16px;">
<option value="meta">Meta Cloud API</option>
<option value="baileys">Baileys API (e.g. Evolution API)</option>
</select>
</div>
<div id="meta-settings-group">
<div>
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Phone
Number ID (Meta)</label>
<input type="text" id="inp-whatsapp-phone-id" placeholder="e.g. 1143551428842521"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
</div>
<div style="margin-top: 16px;">
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Access
Token (Meta)</label>
<input type="password" id="inp-whatsapp-token" placeholder="Meta WhatsApp API token"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">If a token is already
saved, it will show as <b>β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’</b>. Leave blank to keep your current token.</span>
</div>
</div>
<div id="baileys-settings-group" style="display:none;">
<div>
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Baileys API URL</label>
<input type="text" id="inp-baileys-url" placeholder="e.g. http://localhost:8080/message/sendText/instance"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
</div>
<div style="margin-top: 16px;">
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Baileys API Token (Optional)</label>
<input type="password" id="inp-baileys-token" placeholder="Global API Key or Bearer token"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">If a token is already
saved, it will show as <b>β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’</b>. Leave blank to keep your current token.</span>
</div>
<div style="margin-top: 16px; padding: 12px; border: 1px dashed var(--border); border-radius: 12px; background: rgba(0,0,0,0.02); display: flex; flex-direction: column; align-items: center;">
<button class="btn-secondary" id="btn-baileys-qr" style="padding:10px 20px; font-size:13px; display:flex; align-items:center; gap:8px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Fetch QR Code for Login
</button>
<div id="baileys-qr-status" style="margin-top:12px; font-size:13px; color:var(--text2); text-align:center;"></div>
<img id="baileys-qr-img" style="margin-top:12px; display:none; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.1); width: 250px; height: 250px;" />
</div>
</div>
<div
style="padding:12px; background:rgba(255,165,0,0.1); border-left:3px solid orange; border-radius:4px; margin-top:8px;">
<span style="font-size:12px; color:var(--text2);"><b>⚠️ Persistence Note:</b> Settings saved here are
temporary if your Hugging Face Space restarts. For permanent settings, add
<code>META_WHATSAPP_PHONE_ID</code>, <code>META_WHATSAPP_TOKEN</code>, and
<code>META_WHATSAPP_ADMIN_NUMBER</code> to your Space's Secrets.</span>
</div>
<div style="display:flex; gap:12px; margin-top:8px;">
<button class="btn-primary" id="btn-save-whatsapp" style="width:auto; padding:12px 24px;">Save
Settings</button>
<button class="btn-secondary" id="btn-test-whatsapp" style="padding:12px 24px;">Send Test
Message</button>
</div>
<div id="whatsapp-status" style="font-size:13px; color:var(--text2);"></div>
</div>
</div>
<!-- CONFIG SECTION -->
<div class="settings-section" id="settings-sec-config">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Config</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Runtime settings for session handling and
auto-refresh behaviour.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#dbeafe; color:#2563eb;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-3.75" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Auto-refresh sessions</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Poll every 3 s for new messages and
session state changes</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="toggle-refresh-settings" checked
onchange="document.getElementById('toggle-refresh').checked=this.checked; document.getElementById('toggle-refresh').dispatchEvent(new Event('change'));" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
</div>
</div>
<!-- SECURITY SECTION -->
<div class="settings-section" id="settings-sec-security">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Security</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">OTP and session security controls.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#fee2e2; color:#dc2626;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Require OTP on login</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Two-factor code sent to registered
email on every sign-in</div>
</div>
<span
style="font-size:11px; font-weight:600; background:#dcfce7; color:#15803d; padding:3px 10px; border-radius:20px;">Active</span>
</div>
</div>
</div>
<!-- ESCALATION SECTION (superadmin only) -->
<div class="settings-section" id="settings-sec-escalation" style="display:none;">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Escalation Settings</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Configure how queued sessions rotate between admins and manage escalation behavior.</p>
<!-- Timeout -->
<div style="padding:16px; background:var(--bg3); border-radius:12px; margin-bottom:16px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-size:13px; font-weight:600; color:var(--text);">Auto-Escalation Timeout</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Time to wait before rotating a queued session to the next available admin (5–120 seconds)</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<input type="number" id="inp-escalation-timeout" class="search-input"
style="width:80px; text-align:center;" min="5" max="120" value="20" />
<span style="font-size:12px; color:var(--text3);">seconds</span>
<button class="btn-primary" id="btn-save-escalation-timeout" style="padding:6px 14px; font-size:12px;">Save</button>
</div>
</div>
</div>
<!-- Max Cycles -->
<div style="padding:16px; background:var(--bg3); border-radius:12px; margin-bottom:16px;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="font-size:13px; font-weight:600; color:var(--text);">Maximum Rotation Cycles</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">After this many full rotation cycles, session exits queue with formal message</div>
</div>
<span style="font-size:14px; font-weight:700; color:var(--accent);">2 cycles</span>
</div>
</div>
<!-- Eligible Admins -->
<div style="margin-bottom:16px;">
<div style="font-size:13px; font-weight:600; color:var(--text); margin-bottom:8px;">Eligible Admins (Rotation Order)</div>
<div id="escalation-eligible-list" style="display:flex; flex-direction:column; gap:6px;">
<div style="text-align:center; padding:12px; color:var(--text3); font-size:12px;">Loading...</div>
</div>
</div>
<!-- Live Queue Status -->
<div style="margin-bottom:16px;">
<div style="font-size:13px; font-weight:600; color:var(--text); margin-bottom:8px;">Live Queue Status</div>
<div id="escalation-queue-status" style="padding:16px; background:var(--bg3); border-radius:12px;">
<div style="text-align:center; padding:8px; color:var(--text3); font-size:12px;">Loading...</div>
</div>
</div>
<!-- How it works info -->
<div
style="font-size:12px; color:var(--text2); padding:12px 14px; background:var(--accent-light); border-left:3px solid var(--accent); border-radius:4px; line-height:1.6;">
<strong>How escalation works:</strong> When a user requests a human agent, the session enters QUEUED status.
The escalation engine assigns it to the first eligible admin and sends them a WhatsApp notification.
If they don't respond within the timeout, it rotates to the next admin. After 2 full cycles,
the session returns to AI with a formal "no agent available" message.
</div>
</div>
<!-- VERSION SECTION -->
<div class="settings-section" id="settings-sec-version">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Version Info</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Current version details of the bot.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#f3e8ff; color:#9333ea;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Current Version</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">The currently deployed version of the Martechsol Assistant</div>
</div>
<span
style="font-size:11px; font-weight:600; background:#dcfce7; color:#15803d; padding:3px 10px; border-radius:20px;">v1.2</span>
</div>
</div>
</div>
</div>
</div>
<!-- View: Sessions -->
<div class="view-container" id="view-sessions">
<div class="sessions-sidebar">
<div class="sessions-header">
<input type="text" class="search-input" id="search-inp" placeholder="Search sessions..." />
<div class="session-actions">
<button class="btn-secondary" id="btn-refresh-sessions" style="padding: 6px; font-size: 12px; flex:1">↻
Refresh</button>
</div>
</div>
<div class="session-list" id="session-list">
<!-- Populated by JS -->
<div style="padding: 40px; text-align:center; color: var(--text3)">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="chat-view">
<div id="chat-placeholder" class="placeholder-view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<p>Select a session to view the conversation</p>
</div>
<div id="chat-active" style="display:none; flex-direction:column; height:100%;">
<div class="chat-header">
<div class="chat-header-info" style="display:flex; align-items:center; gap:12px;">
<button class="btn-secondary" id="btn-back-to-chats"
style="display:none; width: 32px; height: 32px; border-radius: 50%; padding: 0; align-items: center; justify-content: center; flex-shrink: 0;"
title="Back to chats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:16px;height:16px;margin-right:2px;">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div>
<h3 id="chat-user-name">Guest User</h3>
<p id="chat-meta">Session ID: -</p>
</div>
</div>
<div class="chat-header-actions">
<button class="btn-secondary" id="btn-block-user"
style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Block</button>
<button class="btn-secondary" id="btn-takeover"
style="padding: 6px 12px; font-size:12px; border-color:var(--accent); color:var(--accent)">Take
Over</button>
<button class="btn-secondary" id="btn-transfer"
style="padding: 6px 12px; font-size:12px; border-color:#8b5cf6; color:#8b5cf6; display:none">Transfer</button>
<button class="btn-secondary" id="btn-end-chat"
style="padding: 6px 12px; font-size:12px; border-color:var(--red); color:var(--red)">End Chat
Completely</button>
<button class="btn-secondary" id="btn-export-single"
style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Export</button>
<button class="btn-secondary" id="btn-delete-current-chat"
style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete
Chat</button>
<button class="btn-secondary" id="btn-delete-session"
style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete
Session</button>
</div>
</div>
<div class="chat-messages" id="messages-area">
<!-- Populated by JS -->
</div>
<div id="chat-list-area"
style="display:none; flex-direction:column; gap:16px; padding:32px; overflow-y:auto; flex:1;">
<!-- Populated by JS -->
</div>
<div class="chat-reply-area hidden" id="chat-reply-area"
style="padding: 20px; border-top: 1px solid var(--border); background: var(--surface); gap: 12px;">
<input type="text" id="inp-admin-reply" placeholder="Type a message to the user..."
style="flex: 1; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 14px; outline: none;" />
<button class="btn-primary" id="btn-send-reply" style="width: auto; padding: 12px 24px;">Send</button>
</div>
</div>
</div>
</div>
<!-- View: Admin Users -->
<div class="view-container" id="view-admin-users">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<div>
<h3 style="font-size:18px; font-weight:600; color:var(--text); margin-bottom:4px;">Admin Users</h3>
<p style="font-size:13px; color:var(--text2);">Manage admin accounts, roles, and permissions.</p>
</div>
<button class="btn-primary" id="btn-create-admin" onclick="openCreateAdminModal()" style="width:auto; padding:10px 20px; font-size:13px; display:flex; align-items:center; gap:8px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Admin
</button>
</div>
<div style="background:var(--surface); border:1px solid var(--border); border-radius:16px; overflow:hidden; box-shadow:var(--shadow-sm);">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="background:var(--bg); border-bottom:2px solid var(--border);">
<th style="padding:12px 16px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">User</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Role</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Status</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Email</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Permissions</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Last Login</th>
<th style="padding:12px 8px; text-align:left; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--text3);">Actions</th>
</tr>
</thead>
<tbody id="admin-users-table-body">
<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--text3)"><div class="spinner"></div></td></tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- Change Password Modal -->
<div class="modal-overlay" id="change-password-modal">
<div class="modal" style="width:400px;">
<div class="modal-header">
<h3>Change Password</h3>
<button class="modal-close" onclick="document.getElementById('change-password-modal').classList.remove('open')"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="form-group">
<label>Current Password</label>
<input type="password" id="inp-current-password" placeholder="Enter current password" />
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="inp-new-password" placeholder="Min. 8 characters" />
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" id="inp-confirm-password" placeholder="Re-enter new password" />
</div>
<button class="btn-primary" onclick="doChangePassword()">Change Password</button>
</div>
</div>
<!-- Admin Create/Edit Modal -->
<div class="modal-overlay" id="admin-modal">
<div class="modal" style="width:560px; max-height:90vh; overflow-y:auto;">
<div class="modal-header">
<h3 id="modal-admin-title">Create New Admin</h3>
<button class="modal-close" onclick="document.getElementById('admin-modal').classList.remove('open')"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="modal-admin-username" placeholder="e.g. john.doe" />
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" id="modal-admin-display-name" placeholder="e.g. John Doe" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="modal-admin-email" placeholder="e.g. john@company.com" />
</div>
<div class="form-group" id="modal-admin-password-group">
<label>Password</label>
<input type="password" id="modal-admin-password" placeholder="Min. 8 characters" />
</div>
<div class="form-group">
<label>Role</label>
<select id="modal-admin-role" style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text);" onchange="updatePermGroupVisibility()">
<option value="sub_admin">Sub-admin</option>
<option value="superadmin">Superadmin</option>
</select>
</div>
<div id="superadmin-full-badge" style="display:none; padding:16px; background:#fef3c7; border-radius:12px; text-align:center; margin-bottom:16px; color:#92400e; font-weight:600; font-size:13px;">
πŸ”‘ Superadmins have full unrestricted access to all features.
</div>
<div id="admin-perm-toggles">
<div style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text3); margin-bottom:12px;">Permissions</div>
<!-- manage_sessions -->
<div style="display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--bg); border-radius:10px; margin-bottom:6px;">
<input type="checkbox" data-perm-toggle="manage_sessions" style="accent-color:var(--accent); width:16px; height:16px;" onchange="updatePermGroupVisibility()" />
<div style="flex:1;"><span style="font-size:13px; font-weight:600; color:var(--text);">πŸ’¬ Manage Sessions</span><br><span style="font-size:11px; color:var(--text3);">View and interact with chat sessions</span></div>
</div>
<div id="perm-group-sessions" style="display:none; padding-left:28px; margin-bottom:12px;">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.view" style="accent-color:var(--accent);" /> View</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.reply" style="accent-color:var(--accent);" /> Reply</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.takeover" style="accent-color:var(--accent);" /> Takeover</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.block" style="accent-color:var(--accent);" /> Block</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.archive" style="accent-color:var(--accent);" /> Archive</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.delete" style="accent-color:var(--accent);" /> Delete</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="sessions.end_chat" style="accent-color:var(--accent);" /> End Chat</label>
</div>
</div>
<!-- view_analytics -->
<div style="display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--bg); border-radius:10px; margin-bottom:6px;">
<input type="checkbox" data-perm-toggle="view_analytics" style="accent-color:var(--accent); width:16px; height:16px;" onchange="updatePermGroupVisibility()" />
<div style="flex:1;"><span style="font-size:13px; font-weight:600; color:var(--text);">πŸ“Š View Analytics</span><br><span style="font-size:11px; color:var(--text3);">Access dashboard, charts, and SLA metrics</span></div>
</div>
<div id="perm-group-analytics" style="display:none; padding-left:28px; margin-bottom:12px;">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="analytics.dashboard" style="accent-color:var(--accent);" /> Dashboard</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="analytics.sla" style="accent-color:var(--accent);" /> SLA Metrics</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="analytics.export" style="accent-color:var(--accent);" /> Export</label>
</div>
</div>
<!-- manage_whatsapp -->
<div style="display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--bg); border-radius:10px; margin-bottom:6px;">
<input type="checkbox" data-perm-toggle="manage_whatsapp" style="accent-color:var(--accent); width:16px; height:16px;" onchange="updatePermGroupVisibility()" />
<div style="flex:1;"><span style="font-size:13px; font-weight:600; color:var(--text);">πŸ’¬ Manage WhatsApp</span><br><span style="font-size:11px; color:var(--text3);">Configure WhatsApp notification settings</span></div>
</div>
<div id="perm-group-whatsapp" style="display:none; padding-left:28px; margin-bottom:12px;">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="whatsapp.view" style="accent-color:var(--accent);" /> View</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="whatsapp.edit" style="accent-color:var(--accent);" /> Edit</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="whatsapp.test" style="accent-color:var(--accent);" /> Test</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="whatsapp.qr" style="accent-color:var(--accent);" /> QR Code</label>
</div>
</div>
<!-- manage_admins -->
<div style="display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--bg); border-radius:10px; margin-bottom:6px;">
<input type="checkbox" data-perm-toggle="manage_admins" style="accent-color:var(--accent); width:16px; height:16px;" onchange="updatePermGroupVisibility()" />
<div style="flex:1;"><span style="font-size:13px; font-weight:600; color:var(--text);">πŸ‘₯ Manage Admins</span><br><span style="font-size:11px; color:var(--text3);">Create, edit, delete admin accounts</span></div>
</div>
<div id="perm-group-admins" style="display:none; padding-left:28px; margin-bottom:12px;">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="admins.view" style="accent-color:var(--accent);" /> View</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="admins.create" style="accent-color:var(--accent);" /> Create</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="admins.edit" style="accent-color:var(--accent);" /> Edit</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="admins.delete" style="accent-color:var(--accent);" /> Delete</label>
<label style="display:flex; align-items:center; gap:4px; font-size:11px; color:var(--text2); background:var(--surface); border:1px solid var(--border); padding:4px 10px; border-radius:20px; cursor:pointer;"><input type="checkbox" data-perm-toggle="admins.reset_password" style="accent-color:var(--accent);" /> Reset Password</label>
</div>
</div>
</div>
<div class="form-group">
<label>Notes (Optional)</label>
<textarea id="modal-admin-notes" rows="2" placeholder="Internal notes about this admin..." style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; resize:vertical; font-family:'Inter',sans-serif;"></textarea>
</div>
<button class="btn-primary" onclick="saveAdminModal()">Save Admin</button>
</div>
</div>
<!-- Credential Card Modal -->
<div class="modal-overlay" id="credential-card-modal">
<div class="modal" style="width:440px;">
<div class="modal-header">
<h3>Admin Created βœ“</h3>
<button class="modal-close" id="btn-dismiss-credentials"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<p style="font-size:13px; color:var(--text2); margin-bottom:16px;">Share these credentials securely with the new admin. They will be required to change the password on first login.</p>
<pre id="credential-card-text" style="background:var(--bg); border:1px solid var(--border); border-radius:12px; padding:16px; font-size:13px; font-family:'Courier New',monospace; overflow-x:auto; white-space:pre-wrap; color:var(--text); line-height:1.8;"></pre>
<div style="display:flex; gap:10px; margin-top:16px;">
<button class="btn-primary" id="btn-copy-credentials" style="flex:1;">πŸ“‹ Copy to Clipboard</button>
<button class="btn-secondary" id="btn-dismiss-credentials" onclick="document.getElementById('credential-card-modal').classList.remove('open')" style="flex:1;">Done</button>
</div>
</div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="export-modal">
<div class="modal">
<div class="modal-header">
<h3>Export Chat History</h3>
<button class="modal-close" id="export-modal-close"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg></button>
</div>
<label class="export-option selected" id="opt-all">
<input type="radio" name="export-scope" value="all" checked />
<div class="export-option-text">
<strong>All Sessions Combined</strong>
<span>Export every chat session into a single file.</span>
</div>
</label>
<label class="export-option" id="opt-zip">
<input type="radio" name="export-scope" value="zip" />
<div class="export-option-text">
<strong>Separate Files (ZIP)</strong>
<span>Download an archive containing individual session files.</span>
</div>
</label>
<div
style="margin: 24px 0 12px; font-size: 12px; font-weight: 600; color: var(--text3); text-transform: uppercase;">
Format</div>
<div class="flex gap-2" style="margin-bottom: 24px;">
<button class="format-btn active" data-fmt="excel">Excel (.xlsx)</button>
<button class="format-btn" data-fmt="json">JSON (.json)</button>
</div>
<button class="btn-primary" id="btn-submit-export">Generate Export</button>
</div>
</div>
<!-- Takeover Role Modal -->
<div class="modal-overlay" id="takeover-modal">
<div class="modal">
<div class="modal-header">
<h3>Select Role for Takeover</h3>
<button class="modal-close" id="takeover-modal-close"><svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg></button>
</div>
<p style="font-size:13px; color:var(--text2); margin-bottom:20px;">
Choose your role. An automated formal message will be sent to the user immediately upon taking over.
</p>
<div style="display:flex; flex-direction:column; gap:12px; margin-bottom:24px;">
<label class="export-option selected" id="opt-role-hr">
<input type="radio" name="takeover-role" value="hr" checked />
<div class="export-option-text">
<strong>Human Resources (HR)</strong>
<span>"Hello, I am from HR. How may I assist you?"</span>
</div>
</label>
<label class="export-option" id="opt-role-sa">
<input type="radio" name="takeover-role" value="supervising_authority" />
<div class="export-option-text">
<strong>Supervising Authority</strong>
<span>"Hello, I am the Supervising Authority. What can I do for you?"</span>
</div>
</label>
<label class="export-option" id="opt-role-custom">
<input type="radio" name="takeover-role" value="custom" />
<div class="export-option-text" style="width: 100%;">
<strong>Custom Role</strong>
<span>Type a custom role to introduce yourself.</span>
<input type="text" id="inp-custom-role" class="search-input" placeholder="e.g. Sales Representative"
style="margin-top:8px; display:none; width: 100%; box-sizing:border-box;" />
</div>
</label>
</div>
<button class="btn-primary" id="btn-confirm-takeover">Confirm Takeover</button>
</div>
</div>
<!-- Transfer Session Modal -->
<div class="modal-overlay" id="transfer-modal">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>Transfer Session</h3>
<button class="modal-close" onclick="document.getElementById('transfer-modal').classList.remove('open')"><svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<p style="font-size:13px; color:var(--text2); margin-bottom:16px;">
Select an admin to transfer this session to. They will receive a WhatsApp notification.
</p>
<div id="transfer-admin-list" style="display:flex; flex-direction:column; gap:8px; max-height:300px; overflow-y:auto; margin-bottom:16px;">
<div style="text-align:center; padding:20px; color:var(--text3);">Loading...</div>
</div>
<button class="btn-primary" id="btn-confirm-transfer" disabled>Transfer</button>
</div>
</div>
<!-- Profile Settings Modal -->
<div class="modal-overlay" id="profile-modal">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>My Profile</h3>
<button class="modal-close" onclick="document.getElementById('profile-modal').classList.remove('open')"><svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div style="display:flex; flex-direction:column; gap:16px; margin-top:8px;">
<div>
<label style="font-size:12px; color:var(--text2); margin-bottom:4px; display:block;">Display Name</label>
<input type="text" id="profile-display-name" class="search-input" placeholder="Your display name" />
</div>
<div>
<label style="font-size:12px; color:var(--text2); margin-bottom:4px; display:block;">WhatsApp Number</label>
<input type="text" id="profile-whatsapp" class="search-input" placeholder="923001234567" />
<span style="font-size:11px; color:var(--text3); margin-top:2px; display:block;">International format. You'll receive escalation notifications here.</span>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; padding:12px; background:var(--bg3); border-radius:8px;">
<div>
<div style="font-size:13px; font-weight:600;">Availability</div>
<div style="font-size:11px; color:var(--text3);">Off-duty = skipped in auto-assignment</div>
</div>
<label style="position:relative; display:inline-block; width:44px; height:24px; cursor:pointer;">
<input type="checkbox" id="profile-on-duty" style="opacity:0; width:0; height:0;" checked />
<span style="position:absolute; inset:0; background:var(--border); border-radius:999px; transition:.3s;"></span>
<span id="profile-duty-knob" style="position:absolute; top:3px; left:3px; width:18px; height:18px; background:#fff; border-radius:50%; transition:.3s;"></span>
</label>
</div>
</div>
<div style="display:flex; gap:8px; margin-top:20px;">
<button class="btn-secondary" onclick="document.getElementById('profile-modal').classList.remove('open')" style="flex:1;">Cancel</button>
<button class="btn-primary" id="btn-save-profile" style="flex:1;">Save</button>
</div>
</div>
</div>
<div class="toast" id="toast">Message</div>
<!-- Permission Request Dialog -->
<div id="perm-dialog-overlay" class="hidden">
<div class="perm-card">
<div class="perm-header">
<div class="perm-icon-wrap">πŸ””</div>
<h2>Enable Notifications & Sound</h2>
<p>Allow these permissions so you get alerted immediately when a user requests human support β€” even when this
tab is in the background.</p>
</div>
<div class="perm-list">
<!-- Notifications Row -->
<div class="perm-row" id="perm-row-notif">
<div class="perm-row-icon">πŸ’¬</div>
<div class="perm-row-info">
<strong>Browser Notifications</strong>
<span>OS popup alerts even when tab is minimized</span>
</div>
<span class="perm-status prompt" id="perm-status-notif">Checking...</span>
<button class="perm-grant-btn" id="perm-btn-notif">Grant</button>
</div>
<!-- Audio Row -->
<div class="perm-row" id="perm-row-audio">
<div class="perm-row-icon">πŸ”Š</div>
<div class="perm-row-info">
<strong>Notification Sound</strong>
<span>Audio chime when a new alert arrives</span>
</div>
<span class="perm-status prompt" id="perm-status-audio">Checking...</span>
<button class="perm-grant-btn" id="perm-btn-audio">Enable</button>
</div>
</div>
<div class="perm-actions">
<button id="btn-perm-enable-all">βœ… Enable All Permissions</button>
<button id="btn-perm-skip">Skip for now (notifications won't work in background)</button>
</div>
</div>
</div>
<script>
(function () {
let _creds = null;
let _allSessions = [];
let _activeId = null;
let _activeChatId = null;
let _activeStatus = 'AI';
let _activeBlocked = false;
let _currentFilter = 'sessions';
let _refreshInterval = null;
let _activeSessionData = null;
let _sessionDataCache = {}; // Per-session data cache for instant switching
// ────────────────────────────────────────────────────────────────────
// Background Notification Worker
// Chrome throttles setInterval in background tabs to ~1 min or more,
// which stops loadSessions() from running. A Web Worker runs on a
// separate thread that is NOT throttled, so notifications fire
// reliably even when the tab is minimised or in the background.
// ────────────────────────────────────────────────────────────────────
const _NOTIF_WORKER_SRC = `
let _notifiedQueuedIds = new Set();
let _lastAlertIds = new Map();
let _lastAssignedTo = new Map();
let _timer = null;
let _cfg = null;
async function poll() {
if (!_cfg) return;
try {
const r = await fetch(_cfg.url, { headers: { Authorization: _cfg.auth } });
if (!r.ok) return;
const data = await r.json();
const sessions = data.sessions || [];
const myUsername = (_cfg.myUsername || '').toLowerCase();
sessions.forEach(s => {
if (s.archived === true) return;
const sid = s.session_id || s.id;
const assignedTo = (s.assigned_to || '').toLowerCase();
if (s.status === 'QUEUED') {
const prevAssigned = _lastAssignedTo.get(sid) || '';
_lastAssignedTo.set(sid, assignedTo);
// Notify when:
// 1. New QUEUED session assigned to this admin (first time)
// 2. Rotation just assigned it TO this admin (assignedTo changed)
const isAssignedToMe = myUsername && assignedTo === myUsername;
const wasAssignedToMe = myUsername && prevAssigned === myUsername;
const assignmentChanged = assignedTo !== prevAssigned;
if (isAssignedToMe && (!_notifiedQueuedIds.has(sid) || assignmentChanged)) {
_notifiedQueuedIds.add(sid);
if (!_lastAlertIds.has(sid)) {
_lastAlertIds.set(sid, s.alert_id || 0);
}
self.postMessage({
type: 'notification',
message: 'πŸ”” Session from ' + (s.user_name || 'Guest') + ' has been assigned to you. Please respond.',
sessionId: sid,
soundType: 'default'
});
} else if (!myUsername && !_notifiedQueuedIds.has(sid)) {
// Fallback: if we don't know the admin username, fire for all (legacy behavior)
_notifiedQueuedIds.add(sid);
if (!_lastAlertIds.has(sid)) {
_lastAlertIds.set(sid, s.alert_id || 0);
}
self.postMessage({
type: 'notification',
message: 'User ' + (s.user_name || 'Guest') + ' wants to talk to a human agent.',
sessionId: sid,
soundType: 'default'
});
}
} else {
_notifiedQueuedIds.delete(sid);
_lastAssignedTo.delete(sid);
}
// Alert-based notifications (reminders, exit_queue) β€” fire for all admins
const lastAlert = _lastAlertIds.get(sid) || 0;
if (s.alert_id && s.alert_id > lastAlert) {
_lastAlertIds.set(sid, s.alert_id);
if (s.alert_type === 'reminder') {
self.postMessage({ type: 'notification', message: 'Reminder: User ' + (s.user_name || 'Guest') + ' is still waiting in the queue!', sessionId: sid, soundType: 'default', alertId: s.alert_id });
} else if (s.alert_type === 'exit_queue') {
self.postMessage({ type: 'notification', message: 'User ' + (s.user_name || 'Guest') + ' exited the queue and went back to AI.', sessionId: sid, soundType: 'disconnect', alertId: s.alert_id });
}
}
});
} catch (e) {}
}
self.onmessage = e => {
const d = e.data;
if (d.cmd === 'start') {
_cfg = d.config;
if (d.config.alreadyNotifiedIds) {
d.config.alreadyNotifiedIds.forEach(id => _notifiedQueuedIds.add(id));
}
if (d.config.alreadyAlertIds) {
Object.entries(d.config.alreadyAlertIds).forEach(([sid, alertId]) => {
_lastAlertIds.set(sid, alertId);
});
}
if (_timer) clearInterval(_timer);
poll();
_timer = setInterval(poll, d.config.interval || 3000);
} else if (d.cmd === 'updateAuth') {
if (_cfg) _cfg.auth = d.auth;
} else if (d.cmd === 'stop') {
if (_timer) { clearInterval(_timer); _timer = null; }
}
};
`;
let _notifWorker = null;
function startNotifWorker() {
if (_notifWorker) { _notifWorker.terminate(); _notifWorker = null; }
try {
const blob = new Blob([_NOTIF_WORKER_SRC], { type: 'application/javascript' });
_notifWorker = new Worker(URL.createObjectURL(blob));
_notifWorker.onmessage = e => {
const d = e.data;
if (d.type === 'notification') addNotification(d.message, d.sessionId, d.soundType, d.alertId);
};
_notifWorker.onerror = err => console.warn('Notif worker error:', err);
// Load already-notified IDs from localStorage so we don't re-fire on refresh
let storedIds = [];
let storedAlertIds = {};
try {
storedIds = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
storedAlertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
} catch (e) { }
_notifWorker.postMessage({
cmd: 'start',
config: {
url: window.location.origin + '/api/admin/sessions?limit=10000',
auth: authHeader(),
interval: 3000,
myUsername: (window._adminProfile && window._adminProfile.username) || '',
alreadyNotifiedIds: storedIds,
alreadyAlertIds: storedAlertIds
}
});
} catch (err) {
console.warn('Web Worker not available, using main thread polling:', err);
}
}
function invalidateSessionCache(id) {
delete _sessionDataCache[id];
if (String(_activeId) === String(id)) _activeSessionData = null;
}
// Notifications State
let _notifiedQueuedIds = new Set();
let _notifications = [];
let _snoozed = localStorage.getItem('martech_snoozed') === '1';
// Load persisted notifications on startup
function _loadPersistedNotifications() {
try {
const saved = JSON.parse(localStorage.getItem('martech_notifications') || '[]');
// Only keep last 24 hours
const cutoff = Date.now() - 86400000;
_notifications = saved.filter(n => n.timestamp > cutoff).map(n => ({
...n,
time: new Date(n.timestamp)
}));
} catch (e) { _notifications = []; }
}
function _saveNotifications() {
try {
const toSave = _notifications.slice(0, 50).map(n => ({
...n,
time: undefined,
timestamp: n.timestamp || n.time?.getTime() || Date.now()
}));
localStorage.setItem('martech_notifications', JSON.stringify(toSave));
} catch (e) { }
}
// Notification sound using Web Audio API
let _audioCtx = null;
let _audioKeepalive = null;
let _pendingSoundType = null; // queued sound for when tab comes back to foreground
let _titleFlashInterval = null; // for flashing the tab title
const _ORIG_TITLE = document.title;
function _initAudioCtx() {
if (_audioCtx) return;
try {
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) { console.warn('AudioContext not supported:', e); }
}
// Keep AudioContext alive β€” play a silent buffer every 20s so Chrome never auto-suspends it
function _startAudioKeepalive() {
if (_audioKeepalive) return;
_audioKeepalive = setInterval(() => {
if (!_audioCtx) return;
try {
if (_audioCtx.state === 'suspended') { _audioCtx.resume().catch(() => { }); return; }
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { }
}, 20000);
}
function _scheduleBeeps(beeps) {
if (!_audioCtx) return;
const doPlay = () => {
try {
const base = _audioCtx.currentTime + 0.05;
beeps.forEach(b => {
const osc = _audioCtx.createOscillator();
const gain = _audioCtx.createGain();
osc.connect(gain);
gain.connect(_audioCtx.destination);
osc.type = b.type || 'sine';
if (b.freqEnd !== undefined) {
osc.frequency.setValueAtTime(b.freq, base + b.startOffset);
osc.frequency.linearRampToValueAtTime(b.freqEnd, base + b.stopOffset);
} else {
b.freqs.forEach(([f, t]) => osc.frequency.setValueAtTime(f, base + t));
}
gain.gain.setValueAtTime(0.35, base + b.startOffset);
gain.gain.linearRampToValueAtTime(0, base + b.stopOffset);
osc.start(base + b.startOffset);
osc.stop(base + b.stopOffset + 0.01);
});
} catch (e) { console.warn('Sound playback error:', e); }
};
if (_audioCtx.state === 'suspended') {
_audioCtx.resume().then(doPlay).catch(e => console.warn('AudioContext resume failed:', e));
} else {
doPlay();
}
}
// ── Audio System ──
// We use two approaches in parallel:
// 1. HTMLAudioElement with a WAV blob URL β€” works in background tabs after user gesture
// 2. AudioContext as fallback when tab is visible
//
// The WAV is generated programmatically so we have zero external dependencies.
let _notifAudioUrl = null;
let _disconnectAudioUrl = null;
function _generateWavUrl(samples, sampleRate = 22050) {
// samples: Float32Array of PCM values in range [-1, 1]
const numSamples = samples.length;
const buffer = new ArrayBuffer(44 + numSamples * 2);
const view = new DataView(buffer);
const write = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
write(0, 'RIFF');
view.setUint32(4, 36 + numSamples * 2, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, numSamples * 2, true);
for (let i = 0; i < numSamples; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
}
function _buildNotifSamples() {
const sr = 22050, dur = 0.55;
const n = Math.floor(sr * dur);
const s = new Float32Array(n);
// Three-tone chime: 880 β†’ 1100 β†’ 880 Hz
const freqs = [[880, 0, 0.18], [1100, 0.15, 0.35], [880, 0.32, 0.55]];
for (let i = 0; i < n; i++) {
const t = i / sr;
let v = 0;
for (const [f, tStart, tEnd] of freqs) {
if (t >= tStart && t < tEnd) {
const fadeIn = Math.min((t - tStart) / 0.02, 1);
const fadeOut = Math.min((tEnd - t) / 0.05, 1);
v += 0.35 * fadeIn * fadeOut * Math.sin(2 * Math.PI * f * t);
}
}
s[i] = v;
}
return s;
}
function _buildDisconnectSamples() {
const sr = 22050, dur = 0.35;
const n = Math.floor(sr * dur);
const s = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = i / sr;
const freq = 440 - (330 * t / dur); // sweep 440β†’110
const fade = Math.min(1 - t / dur, (dur - t) / 0.05);
s[i] = 0.35 * Math.max(0, fade) * Math.sin(2 * Math.PI * freq * t);
}
return s;
}
function _initAudioUrls() {
if (_notifAudioUrl) return;
try {
_notifAudioUrl = _generateWavUrl(_buildNotifSamples());
_disconnectAudioUrl = _generateWavUrl(_buildDisconnectSamples());
} catch (e) { console.warn('WAV generation failed:', e); }
}
function _playAudioUrl(url) {
if (!url) return;
try {
const audio = new Audio(url);
audio.volume = 0.8;
const p = audio.play();
if (p) p.catch(err => {
// If blocked (e.g. very strict browser), fall back to AudioContext
console.warn('Audio element play blocked:', err);
});
} catch (e) { console.warn('Audio element error:', e); }
}
function playNotifSound() {
_initAudioUrls();
_playAudioUrl(_notifAudioUrl);
}
function playDisconnectSound() {
_initAudioUrls();
_playAudioUrl(_disconnectAudioUrl);
}
let _chartInstance = null;
const STORAGE_KEY = 'martech_admin_creds';
const $ = id => document.getElementById(id);
const authHeader = () => 'Basic ' + btoa(_creds.user + ':' + _creds.pass);
function showToast(msg, type = 'success') {
const t = $('toast');
t.innerHTML = (type === 'success' ? 'βœ“ ' : '⚠ ') + msg;
t.className = 'toast ' + type + ' show';
setTimeout(() => t.classList.remove('show'), 3000);
}
function fmtDate(iso) {
if (!iso) return 'β€”';
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function shortId(id) {
if (!id) return 'β€”';
return id.length > 12 ? id.slice(0, 6) + '…' + id.slice(-4) : id;
}
// ── Login Flow ──
async function doLogin() {
const u = $('inp-user').value.trim(), p = $('inp-pass').value;
if (!u || !p) return;
$('btn-login').textContent = 'Authenticating...'; $('btn-login').disabled = true; $('login-error').style.display = 'none';
try {
const r = await fetch('/api/admin/request-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
if (r.ok) {
$('login-stage-1').classList.add('hidden');
$('login-stage-2').classList.remove('hidden');
$('inp-otp').focus();
} else {
const err = await r.json();
$('login-error').textContent = err.detail || 'Login failed.';
$('login-error').style.display = 'block';
}
} catch (e) {
$('login-error').textContent = 'Network error.'; $('login-error').style.display = 'block';
} finally {
$('btn-login').textContent = 'Sign In'; $('btn-login').disabled = false;
}
}
async function doVerifyOtp() {
const u = $('inp-user').value.trim(), p = $('inp-pass').value, otp = $('inp-otp').value.trim();
if (!otp) return;
$('btn-verify').textContent = 'Verifying...'; $('btn-verify').disabled = true; $('login-error').style.display = 'none';
try {
const r = await fetch('/api/admin/verify-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p, code: otp })
});
if (r.ok) {
_creds = { user: u, pass: p, otp_verified: true };
localStorage.setItem(STORAGE_KEY, JSON.stringify(_creds));
// Init audio HERE β€” this is inside a real user-gesture (button click)
_initAudioCtx();
if (_audioCtx) _audioCtx.resume().catch(() => { });
$('login-screen').classList.add('hidden');
$('app').style.display = 'flex';
await fetchAdminProfile();
initApp();
showToast('Welcome back, ' + (window._adminProfile?.display_name || window._adminProfile?.username || 'Admin') + '!');
} else {
const err = await r.json();
$('login-error').textContent = err.detail || 'Invalid OTP.'; $('login-error').style.display = 'block';
}
} catch (e) {
$('login-error').textContent = 'Network error.'; $('login-error').style.display = 'block';
} finally {
$('btn-verify').textContent = 'Verify Code'; $('btn-verify').disabled = false;
}
}
$('btn-login').onclick = doLogin;
$('btn-verify').onclick = doVerifyOtp;
$('btn-back').onclick = () => {
$('login-stage-2').classList.add('hidden'); $('login-stage-1').classList.remove('hidden'); $('login-error').style.display = 'none'; $('inp-otp').value = '';
};
$('inp-pass').onkeydown = e => { if (e.key === 'Enter') doLogin() };
$('inp-otp').onkeydown = e => { if (e.key === 'Enter') doVerifyOtp() };
$('btn-logout').onclick = () => {
_creds = null; localStorage.removeItem(STORAGE_KEY);
window.location.reload();
};
// Auto Login check
(async function checkAuth() {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (stored && stored.user && stored.otp_verified) {
_creds = stored;
// Re-validate with /api/admin/me
try {
const meResp = await fetch('/api/admin/me', { headers: { Authorization: authHeader() } });
if (meResp.status === 401 || meResp.status === 403) {
_creds = null;
localStorage.removeItem(STORAGE_KEY);
showToast('Your session has expired. Please log in again.', 'error');
return;
}
if (meResp.ok) {
window._adminProfile = await meResp.json();
window._currentAdmin = window._adminProfile;
// Update duty status dot in sidebar
const dutyDot = document.getElementById('sidebar-duty-dot');
if (dutyDot) {
dutyDot.style.background = window._adminProfile.on_duty !== false ? '#10b981' : '#6b7280';
}
}
} catch (e) {
// Network error β€” allow offline auto-login
console.warn('Could not validate session:', e);
}
$('login-screen').classList.add('hidden');
$('app').style.display = 'flex';
applyPermissions();
initApp();
} else { localStorage.removeItem(STORAGE_KEY); }
} catch (e) { }
})();
// ── App Init & Routing ──
// ── Title flash helpers ──
function _startTitleFlash(msg) {
if (_titleFlashInterval) return; // already flashing
let on = true;
_titleFlashInterval = setInterval(() => {
document.title = on ? 'πŸ”” ' + msg : _ORIG_TITLE;
on = !on;
}, 900);
}
function _stopTitleFlash() {
if (_titleFlashInterval) { clearInterval(_titleFlashInterval); _titleFlashInterval = null; }
document.title = _ORIG_TITLE;
}
// ── Page Visibility: resume audio + flush queued sound when tab becomes active ──
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const ns = loadNotifSettings();
if (_audioCtx && _audioCtx.state === 'suspended') {
_audioCtx.resume().then(() => {
if (_pendingSoundType && ns.sound_onfocus) {
if (_pendingSoundType === 'disconnect') playDisconnectSound();
else playNotifSound();
}
_pendingSoundType = null;
}).catch(() => { });
} else if (_pendingSoundType && ns.sound_onfocus) {
if (_pendingSoundType === 'disconnect') playDisconnectSound();
else playNotifSound();
_pendingSoundType = null;
} else {
_pendingSoundType = null; // clear even if skipped
}
_stopTitleFlash();
}
});
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('SW registered:', reg.scope);
await navigator.serviceWorker.ready;
} catch (err) {
console.warn('SW registration failed:', err);
}
}
async function _showOsNotif(title, body, tag) {
const ns = loadNotifSettings();
if (!ns.os_popup) return; // user turned it off
if (!('Notification' in window) || Notification.permission !== 'granted') return;
// Use Service Worker notification β€” this fires OS sound even when tab is backgrounded
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.showNotification(title, {
body,
tag, // deduplicate per session β€” browser replaces same tag
renotify: true, // renotify ensures subsequent events for same session pop up
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="%238b5cf6"/><text x="50" y="68" font-family="sans-serif" font-size="55" font-weight="bold" fill="white" text-anchor="middle">M</text></svg>',
requireInteraction: true,
silent: false
});
return;
}
}
// Fallback to regular Notification API
const n = new Notification(title, { body, tag, silent: false });
n.onclick = () => { window.focus(); n.close(); };
}
function initApp() {
_loadPersistedNotifications(); // ADD THIS FIRST
$('toggle-snooze').checked = _snoozed;
// Apply RBAC permissions to UI
applyPermissions();
updateProfileChip();
// Ensure AudioContext is initialized (auto-login case: no user gesture available,
// but we still call _initAudioCtx so it's ready the moment user clicks anything)
_initAudioCtx();
// Resume if possible (will succeed if called from a user gesture, silently fail otherwise)
if (_audioCtx) _audioCtx.resume().catch(() => { });
// Start keepalive to prevent Chrome auto-suspend
_startAudioKeepalive();
registerServiceWorker(); // Register Service Worker for reliable background push notifications
// On any click in the app, ensure context is running
document.addEventListener('click', () => {
if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume().catch(() => { });
});
// Navigate to the first allowed view
const firstView = getFirstAllowedView();
switchView(firstView);
setInterval(() => {
loadMetrics();
if ($('toggle-refresh') && $('toggle-refresh').checked) {
if (_currentFilter === 'sessions' || _currentFilter === 'queued') {
loadSessions(true, true);
}
}
}, 3000);
setupAutoRefresh();
startNotifWorker();
checkAndRequestPermissions();
}
document.querySelectorAll('.nav-item').forEach(el => {
el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
switchView(el.dataset.view);
}
});
// ── Change Password Modal ──
if ($('btn-change-password')) {
$('btn-change-password').onclick = () => {
const overlay = $('change-password-modal');
if (overlay) overlay.classList.add('open');
};
// Profile modal
$('btn-my-profile').onclick = () => {
const p = window._adminProfile || {};
$('profile-display-name').value = p.display_name || '';
$('profile-whatsapp').value = p.whatsapp_number || '';
$('profile-on-duty').checked = p.on_duty !== false;
_updateDutyToggleStyle();
$('profile-modal').classList.add('open');
};
$('profile-on-duty').onchange = () => _updateDutyToggleStyle();
function _updateDutyToggleStyle() {
const checked = $('profile-on-duty').checked;
const track = $('profile-on-duty').nextElementSibling;
const knob = $('profile-duty-knob');
if (track) track.style.background = checked ? '#10b981' : 'var(--border)';
if (knob) knob.style.left = checked ? '23px' : '3px';
}
$('btn-save-profile').onclick = async () => {
const body = {
display_name: $('profile-display-name').value.trim() || undefined,
whatsapp_number: $('profile-whatsapp').value.trim(),
on_duty: $('profile-on-duty').checked,
};
$('btn-save-profile').textContent = 'Saving...';
$('btn-save-profile').disabled = true;
try {
const r = await fetch('/api/admin/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify(body),
});
if (r.ok) {
const data = await r.json();
window._adminProfile = { ...window._adminProfile, ...data };
window._currentAdmin = window._adminProfile;
// Update sidebar duty dot
const dutyDot = document.getElementById('sidebar-duty-dot');
if (dutyDot) dutyDot.style.background = data.on_duty !== false ? '#10b981' : '#6b7280';
// Update sidebar name
if (data.display_name) $('profile-username').textContent = data.display_name;
$('profile-modal').classList.remove('open');
showToast('Profile updated');
} else {
const err = await r.json().catch(() => ({}));
showToast(err.detail || 'Update failed', 'error');
}
} catch (e) {
showToast('Update failed', 'error');
} finally {
$('btn-save-profile').textContent = 'Save';
$('btn-save-profile').disabled = false;
}
};
}
function switchView(view) {
document.querySelectorAll('.view-container').forEach(v => {
v.style.display = 'none';
v.classList.remove('active');
});
let targetView = view;
if (view === 'daily' || view === 'blocked' || view === 'queued' || view === 'archived') targetView = 'sessions';
const v = $('view-' + targetView);
v.style.display = 'flex';
v.style.flexDirection = (targetView === 'sessions' || targetView === 'settings') ? 'row' : 'column';
setTimeout(() => v.classList.add('active'), 10);
if (view === 'dashboard') $('topbar-title').textContent = 'Dashboard Overview';
else if (view === 'sla') $('topbar-title').textContent = 'SLA Dashboard';
else if (view === 'sessions') $('topbar-title').textContent = 'All Live Chat Sessions';
else if (view === 'queued') $('topbar-title').textContent = 'Users In Queue';
else if (view === 'archived') $('topbar-title').textContent = 'Archived Chats';
else if (view === 'daily') $('topbar-title').textContent = 'Today\'s Users';
else if (view === 'blocked') $('topbar-title').textContent = 'Blocked Users';
else if (view === 'settings') $('topbar-title').textContent = 'Settings';
else if (view === 'admin-users') $('topbar-title').textContent = 'Admin Users';
if (view === 'dashboard') loadAnalytics();
if (view === 'sla') loadSLAMetrics();
if (['sessions', 'daily', 'blocked', 'queued', 'archived'].includes(view)) {
if (_currentFilter !== view) {
_currentFilter = view;
_activeId = null;
_activeSessionData = null;
if ($('chat-placeholder')) $('chat-placeholder').style.display = 'flex';
if ($('chat-active')) $('chat-active').style.display = 'none';
}
loadSessions();
}
if (view === 'settings') {
loadWhatsAppSettings();
initSettingsPanel();
applyNotifSettingsToUI();
}
if (view === 'admin-users') {
loadAdminUsers();
}
}
async function loadSLAMetrics() {
try {
const r = await fetch('/api/admin/sla-metrics', { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const data = await r.json();
function fmtTime(sec) {
if (!sec) return '0s';
if (sec < 60) return Math.round(sec) + 's';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return m + 'm ' + s + 's';
}
$('sla-queue-time').textContent = fmtTime(data.avg_queue_time_sec);
$('sla-agent-time').textContent = fmtTime(data.avg_agent_reply_time_sec);
$('sla-res-time').textContent = fmtTime(data.avg_resolution_time_sec);
$('sla-breaches').textContent = data.breach_count;
$('sla-breach-rate').textContent = (data.breach_rate_pct || 0).toFixed(1) + '%';
$('sla-takeover-rate').textContent = (data.takeover_rate_pct || 0).toFixed(1) + '%';
} catch (e) { console.error('Failed to load SLA metrics', e); }
}
// ── Permission Request Dialog ──
// Shows a modal after login if browser notifications or audio are not yet enabled.
function checkAndRequestPermissions() {
if ('Notification' in window && Notification.permission === 'denied') {
$('notif-permission-banner').style.display = 'flex';
}
const notifGranted = !('Notification' in window) || Notification.permission === 'granted';
const audioAlreadyEnabled = localStorage.getItem('martech_audio_enabled') === '1';
if (notifGranted && audioAlreadyEnabled) return; // both satisfied, skip dialog entirely
const overlay = document.getElementById('perm-dialog-overlay');
const rowNotif = document.getElementById('perm-row-notif');
const rowAudio = document.getElementById('perm-row-audio');
const stNotif = document.getElementById('perm-status-notif');
const stAudio = document.getElementById('perm-status-audio');
const btnNotif = document.getElementById('perm-btn-notif');
const btnAudio = document.getElementById('perm-btn-audio');
function refreshStatuses() {
const np = 'Notification' in window ? Notification.permission : 'denied';
if (np === 'granted') {
stNotif.textContent = 'βœ“ Allowed'; stNotif.className = 'perm-status ok';
rowNotif.className = 'perm-row granted'; btnNotif.disabled = true; btnNotif.textContent = 'Granted';
} else if (np === 'denied') {
stNotif.textContent = 'βœ— Blocked'; stNotif.className = 'perm-status bad';
rowNotif.className = 'perm-row denied';
btnNotif.textContent = 'Open site settings'; btnNotif.disabled = false;
btnNotif.onclick = () => alert('Please click the πŸ”’ lock icon in your browser address bar β†’ Site settings β†’ Notifications β†’ Allow');
} else {
stNotif.textContent = 'Not set'; stNotif.className = 'perm-status prompt';
rowNotif.className = 'perm-row'; btnNotif.disabled = false; btnNotif.textContent = 'Grant';
}
const audioOk = localStorage.getItem('martech_audio_enabled') === '1';
if (audioOk) {
stAudio.textContent = 'βœ“ Allowed'; stAudio.className = 'perm-status ok';
rowAudio.className = 'perm-row granted'; btnAudio.disabled = true; btnAudio.textContent = 'Enabled';
} else {
stAudio.textContent = 'Click to enable'; stAudio.className = 'perm-status prompt';
rowAudio.className = 'perm-row'; btnAudio.disabled = false; btnAudio.textContent = 'Enable';
}
const allGood = np === 'granted' && audioOk;
if (allGood) setTimeout(() => overlay.classList.add('hidden'), 900);
}
async function grantNotif() {
btnNotif.disabled = true; btnNotif.textContent = 'Requesting...';
await Notification.requestPermission();
refreshStatuses();
}
async function grantAudio() {
btnAudio.disabled = true; btnAudio.textContent = 'Enabling...';
_initAudioCtx();
if (_audioCtx) await _audioCtx.resume().catch(() => { });
// Pre-generate WAV blob URLs inside this user gesture so the browser
// allows future Audio element playback even in background tabs
_initAudioUrls();
localStorage.setItem('martech_audio_enabled', '1');
playNotifSound(); // play test sound to confirm
refreshStatuses();
}
btnNotif.onclick = grantNotif;
btnAudio.onclick = grantAudio;
document.getElementById('btn-perm-enable-all').onclick = async () => {
await grantNotif();
await grantAudio();
};
document.getElementById('btn-perm-skip').onclick = () => overlay.classList.add('hidden');
refreshStatuses();
overlay.classList.remove('hidden');
}
async function loadMetrics() {
try {
const r = await fetch('/api/admin/metrics', { headers: { Authorization: authHeader() } });
const m = await r.json();
$('dash-live').textContent = m.live;
$('dash-queued').textContent = m.queued;
$('dash-daily').textContent = m.daily;
$('dash-blocked').textContent = m.blocked;
_updateQueueBadge(m.queued);
} catch (e) {
console.error('Failed to load metrics', e);
}
}
// ── Dashboard Analytics ──
async function loadAnalytics() {
if (!_creds) return;
try {
loadMetrics();
const r = await fetch('/api/admin/analytics', { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const data = await r.json();
$('dash-total-sessions').textContent = data.total_sessions;
$('dash-today-sessions').textContent = data.today_sessions;
$('dash-total-messages').textContent = data.total_messages;
renderChart(data.trends);
} catch (e) { console.error('Analytics error:', e); }
}
function renderChart(trends) {
const ctx = $('messagesChart').getContext('2d');
const labels = trends.map(t => {
const d = new Date(t.date); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
const msgData = trends.map(t => t.messages);
if (_chartInstance) _chartInstance.destroy();
_chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Messages',
data: msgData,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderWidth: 3,
tension: 0.4,
fill: true,
pointBackgroundColor: '#fff',
pointBorderColor: '#8b5cf6',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { borderDash: [4, 4], color: '#e2e8f0' }, border: { display: false } },
x: { grid: { display: false }, border: { display: false } }
},
interaction: { intersect: false, mode: 'index' }
}
});
}
// Notifications Logic
$('btn-notifications').onclick = (e) => {
e.stopPropagation();
$('notif-dropdown').classList.toggle('show');
if ($('notif-dropdown').classList.contains('show')) {
// Mark all as read when opened
_notifications.forEach(n => n.read = true);
_saveNotifications();
_updateBadge();
renderNotifications();
}
};
document.addEventListener('click', (e) => {
if ($('btn-notifications') && !$('btn-notifications').contains(e.target) && $('notif-dropdown') && !$('notif-dropdown').contains(e.target)) {
$('notif-dropdown').classList.remove('show');
}
});
$('toggle-snooze').onchange = (e) => {
_snoozed = e.target.checked;
localStorage.setItem('martech_snoozed', _snoozed ? '1' : '0');
};
function addNotification(message, sessionId, soundType = 'default', alertId = null) {
// Persist so refresh doesn't re-fire
if (sessionId) {
try {
const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
if (!ids.includes(sessionId)) {
ids.push(sessionId);
// Keep only last 50 to avoid unbounded growth
localStorage.setItem('martech_notified_ids', JSON.stringify(ids.slice(-50)));
}
// Persist alert IDs for reminders
if (alertId) {
const alertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
alertIds[sessionId] = alertId;
localStorage.setItem('martech_alert_ids', JSON.stringify(alertIds));
}
} catch (e) { }
}
_notifications.unshift({ message, sessionId, soundType, time: new Date(), timestamp: Date.now(), read: false });
if (_notifications.length > 50) _notifications = _notifications.slice(0, 50);
renderNotifications();
_saveNotifications();
_updateBadge();
if (!_snoozed) {
const ns = loadNotifSettings();
if (ns.sound_background) {
// Path 1: HTMLAudioElement β€” plays in background tabs
if (soundType === 'disconnect') playDisconnectSound();
else playNotifSound();
}
if (document.visibilityState !== 'visible') {
// Only queue the focus-replay if the user has explicitly enabled it
// Default is OFF β€” this prevents the double-chime
if (ns.sound_onfocus) {
_pendingSoundType = soundType;
}
if (ns.title_flash) {
_startTitleFlash('New Alert!');
}
}
}
// ── OS Browser Notification (works even when tab is in background) ──
const notifTitle = soundType === 'disconnect' ? 'πŸ”• User Left Queue' : 'πŸ”” Human Handoff Request';
_showOsNotif(
notifTitle,
message,
'admin-notif-' + (sessionId || Date.now())
);
}
function renderNotifications() {
if (_notifications.length === 0) {
$('notif-list').innerHTML = '<div class="notif-empty">No new notifications</div>';
return;
}
$('notif-list').innerHTML = _notifications.map((n, idx) => `
<div class="notif-item notif-item-link ${n.read ? 'notif-read' : ''}" data-id="${esc(n.sessionId)}" data-idx="${idx}">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div class="notif-item-title" style="color:${n.read ? 'var(--text3)' : 'var(--text)'}">
${n.soundType === 'disconnect' ? 'πŸ”• User Left Queue' : 'πŸ”” Human Request'}
</div>
<button class="notif-dismiss" data-idx="${idx}" style="background:none;border:none;cursor:pointer;color:var(--text3);font-size:14px;padding:0 0 0 8px;line-height:1;" title="Dismiss">βœ•</button>
</div>
<div class="notif-item-desc">${esc(n.message)}</div>
<div class="notif-item-time">${n.time.toLocaleTimeString()}${n.read ? ' Β· Read' : ''}</div>
</div>
`).join('');
// Click notification to open session
document.querySelectorAll('.notif-item-link').forEach(el => {
el.onclick = (e) => {
if (e.target.classList.contains('notif-dismiss')) return;
const idx = parseInt(el.dataset.idx);
_notifications[idx].read = true;
_saveNotifications();
renderNotifications();
_updateBadge();
e.stopPropagation();
$('notif-dropdown').classList.remove('show');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(el.dataset.id);
fetch('/api/admin/sessions/' + encodeURIComponent(el.dataset.id), { headers: { Authorization: authHeader() } })
.then(r => r.json())
.then(data => {
_sessionDataCache[el.dataset.id] = data;
openSession(el.dataset.id, data.current_chat_id || 1);
}).catch(() => openSession(el.dataset.id));
};
});
// Dismiss button
document.querySelectorAll('.notif-dismiss').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
_notifications.splice(idx, 1);
_saveNotifications();
renderNotifications();
_updateBadge();
};
});
}
function _updateBadge() {
const unread = _notifications.filter(n => !n.read).length;
$('notif-badge').style.display = unread > 0 ? 'block' : 'none';
}
// Sessions Management
let _lastAlertIds = new Map();
let _sessionSkip = 0;
const _sessionLimit = 50;
let _hasMoreSessions = true;
let _isLoadingSessions = false;
let _searchQuery = '';
async function loadSessions(reset = true, isPolling = false) {
if (_isLoadingSessions) return;
if (reset && !isPolling) {
_sessionSkip = 0;
_hasMoreSessions = true;
_allSessions = [];
$('session-list').innerHTML = `<div style="text-align:center;padding:20px;color:var(--text3);">Loading...</div>`;
}
if (!_hasMoreSessions && !isPolling) return;
_isLoadingSessions = true;
try {
const fetchSkip = isPolling ? 0 : _sessionSkip;
const fetchLimit = isPolling ? Math.max(_sessionSkip, _sessionLimit) : _sessionLimit;
const url = `/api/admin/sessions?search=${encodeURIComponent(_searchQuery)}&filter=${encodeURIComponent(_currentFilter)}&skip=${fetchSkip}&limit=${fetchLimit}`;
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Failed to fetch');
const data = await r.json();
if (!isPolling) {
if (data.sessions.length < _sessionLimit) {
_hasMoreSessions = false;
}
if (reset) {
_allSessions = data.sessions;
renderSessionList(_allSessions);
} else {
_allSessions = _allSessions.concat(data.sessions);
renderSessionList(data.sessions, true);
}
_sessionSkip += data.sessions.length;
} else {
_allSessions = data.sessions;
renderSessionList(_allSessions);
}
// If we are in the queued view, update the queued panel too
if (_currentFilter === 'queued') {
renderQueuePanel();
}
} catch (e) {
if (reset) $('session-list').innerHTML = `<div style="text-align:center;color:var(--red);padding:20px">Error loading sessions</div>`;
} finally {
_isLoadingSessions = false;
}
}
// ── Queued Users Panel ──
let _prevQueuedIds = new Set();
let _queueTimerInterval = null;
function renderQueuePanel() {
const queued = (_allSessions || []).filter(s => s.status === 'QUEUED' && s.archived !== true);
const container = $('queue-items');
const countEl = $('queue-panel-count');
const badgeEl = $('queue-badge');
// Update count label
if (countEl) countEl.textContent = queued.length + (queued.length === 1 ? ' waiting' : ' waiting');
// Update nav badge
_updateQueueBadge(queued.length);
if (!container) return;
if (queued.length === 0) {
container.innerHTML = `
<div class="queue-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<div>No users in queue</div>
</div>`;
_stopQueueTimers();
_prevQueuedIds = new Set();
return;
}
// Determine which IDs are new (for flash animation)
const currentIds = new Set(queued.map(s => s.session_id || s.id));
const newIds = new Set();
currentIds.forEach(id => { if (!_prevQueuedIds.has(id)) newIds.add(id); });
container.innerHTML = queued.map(s => {
const sid = s.session_id || s.id;
const isDefault = !s.user_name || s.user_name === 'default name' || s.user_name === 'default_name';
const name = isDefault ? shortId(sid) : s.user_name;
const isNew = newIds.has(sid);
const queuedAt = s.queued_at || s.last_active || new Date().toISOString();
return `
<div class="queue-item ${isNew ? 'queue-item-new' : ''}" data-sid="${esc(sid)}" data-queued="${esc(queuedAt)}">
<div class="queue-item-info">
<div class="queue-item-name" title="${esc(name)}">${esc(name)}</div>
<div class="queue-item-timer" data-queued="${esc(queuedAt)}">Waiting...</div>
</div>
<button class="queue-takeover-btn" data-sid="${esc(sid)}">Take Over</button>
</div>`;
}).join('');
// Bind click handlers β€” click item to view, click button to takeover
container.querySelectorAll('.queue-item').forEach(el => {
el.onclick = (e) => {
if (e.target.classList.contains('queue-takeover-btn')) return;
const sid = el.dataset.sid;
// Navigate to sessions view and open this session
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(sid);
openSession(sid);
};
});
container.querySelectorAll('.queue-takeover-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
_queueTakeover(btn.dataset.sid);
};
});
_prevQueuedIds = currentIds;
_startQueueTimers();
}
function _startQueueTimers() {
_stopQueueTimers();
_queueTimerTick(); // immediate first tick
_queueTimerInterval = setInterval(_queueTimerTick, 1000);
}
function _stopQueueTimers() {
if (_queueTimerInterval) { clearInterval(_queueTimerInterval); _queueTimerInterval = null; }
}
function _queueTimerTick() {
document.querySelectorAll('.queue-item-timer[data-queued]').forEach(el => {
const queued = new Date(el.dataset.queued);
const diff = Math.max(0, Math.floor((Date.now() - queued.getTime()) / 1000));
const m = Math.floor(diff / 60);
const s = diff % 60;
el.textContent = m > 0
? `Waiting ${m}m ${String(s).padStart(2, '0')}s`
: `Waiting ${s}s`;
});
}
async function _queueTakeover(sessionId) {
if (!sessionId || sessionId === 'undefined') return;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(sessionId) + '/takeover', {
method: 'POST',
headers: { Authorization: authHeader() }
});
if (!r.ok) throw new Error('Takeover failed');
showToast('You are now chatting with this user');
// Navigate to sessions view and open the session
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(sessionId);
// Fetch session data so we can open the specific chat thread directly
const r2 = await fetch('/api/admin/sessions/' + encodeURIComponent(sessionId), { headers: { Authorization: authHeader() } });
const data = await r2.json();
_sessionDataCache[sessionId] = data;
openSession(sessionId, data.current_chat_id || 1);
loadSessions(); // refresh to update queue panel
} catch (e) {
showToast('Takeover failed', 'error');
}
}
function _updateQueueBadge(count) {
const badge = $('queue-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function renderSessionList(sessions, append = false) {
const html = sessions.map(s => {
const isDefault = !s.user_name || s.user_name === 'default name' || s.user_name === 'default_name';
const name = isDefault ? shortId(s.session_id || s.id) : s.user_name;
const isAssignedToMe = s.assigned_to && s.assigned_to === (_currentAdmin?.username || '');
const assignBadge = s.assigned_to
? (isAssignedToMe
? '<span class="assignment-badge" style="background:rgba(16,185,129,0.15); color:#10b981;">⚑ Your Turn</span>'
: '<span class="assignment-badge">πŸ”’ ' + (s.assigned_to) + '</span>')
: '';
const pulseClass = isAssignedToMe && s.status === 'QUEUED' ? 'session-assigned-to-me' : '';
return `
<div class="session-item ${pulseClass} ${(s.session_id || s.id) === _activeId ? 'active' : ''} ${s.archived ? 'session-item-archived' : ''}" data-id="${s.session_id || s.id}">
<div class="session-item-header">
<span class="session-item-name" title="${name}">${name}</span>
<span class="session-item-time">${fmtDate(s.last_active).split(' ')[1] || ''}</span>
</div>
<div class="session-item-meta">
<span>${fmtDate(s.last_active).split(' ')[0] || ''}</span>
<span class="msg-badge">${s.message_count} msg</span>
${assignBadge}
</div>
</div>
`;
}).join('');
if (append) {
$('session-list').insertAdjacentHTML('beforeend', html);
} else {
$('session-list').innerHTML = html;
}
document.querySelectorAll('.session-item').forEach(el => {
el.onclick = () => openSession(el.dataset.id);
el.oncontextmenu = (e) => {
const sid = el.dataset.id;
const s = _allSessions.find(x => String(x.session_id || x.id) === String(sid));
window.showContextMenu(e, {
type: 'session',
id: sid,
isArchived: s && s.archived === true
});
};
});
}
// Infinite Scroll
$('session-list').addEventListener('scroll', () => {
const el = $('session-list');
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
loadSessions(false);
}
});
// Search debounce
let _searchTimeout;
$('search-inp').oninput = () => {
clearTimeout(_searchTimeout);
_searchTimeout = setTimeout(() => {
_searchQuery = $('search-inp').value;
loadSessions(true);
}, 300);
};
$('btn-refresh-sessions').onclick = () => { _sessionDataCache = {}; _activeSessionData = null; loadSessions(true); if (_activeId) openSession(_activeId, _activeChatId); };
$('btn-back-to-chats').onclick = () => openSession(_activeId);
async function openSession(id, chatId = null) {
if (!id || id === 'undefined') return;
// ALWAYS set _activeId so action buttons (takeover, block, etc.) work
_activeId = String(id);
if (!chatId) {
_activeChatId = null;
renderSessionList(_allSessions);
} else {
_activeChatId = chatId;
}
$('chat-placeholder').style.display = 'none';
$('chat-active').style.display = 'flex';
try {
// Use per-session cache for instant switching
let data = _sessionDataCache[id];
if (!data) {
$('messages-area').innerHTML = '<div class="spinner"></div>';
$('chat-list-area').style.display = 'none';
$('messages-area').style.display = 'flex';
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(id), { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Fetch failed');
data = await r.json();
_sessionDataCache[id] = data;
}
_activeSessionData = data;
const currentChatId = data.current_chat_id || 1;
// Only show chat-list if multiple chats exist AND no specific chat is requested
if (!chatId && currentChatId > 1) {
renderChatList(data);
return;
}
const targetChatId = chatId || currentChatId;
_activeChatId = targetChatId;
renderChatMessages(data, targetChatId);
} catch (e) {
console.error(e);
$('messages-area').innerHTML = '<div style="color:red; text-align:center; margin-top:20px;">Failed to load.</div>';
}
}
function renderChatList(data) {
$('messages-area').style.display = 'none';
$('chat-reply-area').classList.add('hidden');
$('btn-back-to-chats').style.display = 'none';
$('chat-list-area').style.display = 'flex';
$('btn-delete-current-chat').style.display = 'none';
$('chat-user-name').textContent = data.user_name || 'Guest User';
$('chat-meta').textContent = 'ID: ' + data.session_id + ' β€’ Select a chat thread';
const msgs = data.messages || [];
const currentChatId = data.current_chat_id || 1;
let html = '';
for (let i = currentChatId; i >= 1; i--) {
const isThreadArchived = (data.archived_chats || []).includes(i);
const isSessionArchived = data.archived === true;
const isEffectivelyArchived = isThreadArchived || isSessionArchived;
// Hide archived chat threads unless we are currently in the Archived tab
if (_currentFilter === 'archived') {
if (!isEffectivelyArchived) continue;
} else {
if (isEffectivelyArchived) continue;
}
const chatMsgs = msgs.filter(m => (m.chat_id || 1) === i);
if (chatMsgs.length === 0 && i !== currentChatId) continue;
const firstUserMsg = chatMsgs.find(m => m.question);
const firstQuestion = firstUserMsg ? firstUserMsg.question : 'New Chat';
const truncatedQ = firstQuestion.length > 80 ? firstQuestion.substring(0, 80) + '...' : firstQuestion;
const chatRating = (data.chat_ratings || {})[String(i)];
let ratingBadge = '';
if (chatRating === 5) {
ratingBadge = `<span style="font-size:12px; color:#10b981; font-weight:700; background:#ecfdf5; border:1px solid #a7f3d0; border-radius:20px; padding:2px 8px;">&#128077; Thumbs Up</span>`;
} else if (chatRating === 1) {
ratingBadge = `<span style="font-size:12px; color:#ef4444; font-weight:700; background:#fef2f2; border:1px solid #fecaca; border-radius:20px; padding:2px 8px;">&#128078; Thumbs Down</span>`;
} else if (chatRating) {
ratingBadge = `<span style="font-size:12px; color:#f59e0b; font-weight:700; background:#fef9c3; border:1px solid #fde68a; border-radius:20px; padding:2px 8px;">&#9733; ${chatRating}/5</span>`;
}
html += `
<div class="session-item chat-thread-item ${isEffectivelyArchived ? 'session-item-archived' : ''}" data-chat-id="${i}" style="padding:16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<div style="font-weight:600; font-size:15px; color:var(--text);">Chat ${i}</div>
${ratingBadge}
</div>
<div style="font-size:13px; color:var(--text2); margin-bottom:8px;">"${esc(truncatedQ)}"</div>
<div style="font-size:11px; color:var(--text3);">${chatMsgs.length} messages</div>
</div>
`;
}
$('chat-list-area').innerHTML = html;
document.querySelectorAll('.chat-thread-item').forEach(el => {
const chatId = parseInt(el.dataset.chatId);
el.onclick = () => openSession(data.session_id, chatId);
el.oncontextmenu = (e) => {
const isEnded = (chatId < currentChatId) || (data.status === 'ENDED');
const isThreadArchived = (data.archived_chats || []).includes(chatId);
const isSessionArchived = data.archived === true;
window.showContextMenu(e, {
type: 'thread',
id: data.session_id,
chatId: chatId,
isEnded: isEnded,
isArchived: isThreadArchived || isSessionArchived,
isSessionArchived: isSessionArchived
});
};
});
$('btn-takeover').style.display = 'none';
$('btn-end-chat').style.display = 'none';
$('btn-export-single').style.display = 'none';
$('btn-block-user').style.display = 'none';
$('btn-transfer').style.display = 'none';
}
function renderChatMessages(data, chatId) {
$('chat-list-area').style.display = 'none';
$('messages-area').style.display = 'flex';
if ((data.current_chat_id || 1) > 1) {
$('btn-back-to-chats').style.display = 'flex';
} else {
$('btn-back-to-chats').style.display = 'none';
}
$('chat-user-name').textContent = (data.user_name || 'Guest User') + ((data.current_chat_id || 1) > 1 ? ` (Chat ${chatId})` : '');
const msgs = data.messages || [];
const filteredMsgs = msgs.filter(m => (m.chat_id || 1) === chatId);
$('chat-meta').textContent = 'ID: ' + data.session_id + ' β€’ ' + filteredMsgs.length + ' messages total';
if (!filteredMsgs.length) {
$('messages-area').innerHTML = '<div style="text-align:center;color:var(--text3);margin-top:40px">No conversation history.</div>';
} else {
let html = '';
filteredMsgs.forEach(m => {
if (m.question) {
html += `<div class="msg-wrapper user"><div class="msg-meta">User β€’ ${fmtDate(m.timestamp).split(' ')[1] || ''}</div><div class="msg-bubble">${esc(m.question)}</div></div>`;
}
if (m.answer) {
html += `<div class="msg-wrapper bot"><div class="msg-meta">${!m.question ? 'Human Agent' : 'AI Agent'} β€’ ${fmtDate(m.timestamp).split(' ')[1] || ''}</div><div class="msg-bubble">${esc(m.answer)}</div></div>`;
}
});
$('messages-area').innerHTML = html;
$('messages-area').scrollTop = $('messages-area').scrollHeight;
}
_activeStatus = data.status || 'AI';
_activeBlocked = data.blocked || false;
const isLatestChat = chatId === (data.current_chat_id || 1);
const btnTakeover = $('btn-takeover');
const btnEndChat = $('btn-end-chat');
$('btn-export-single').style.display = 'inline-block';
$('btn-delete-current-chat').style.display = 'inline-block';
$('btn-block-user').style.display = 'inline-block';
if (_activeStatus === 'ENDED' || !isLatestChat) {
btnTakeover.style.display = 'none';
btnEndChat.style.display = 'none';
$('btn-transfer').style.display = 'none';
$('chat-reply-area').classList.add('hidden');
} else {
btnTakeover.style.display = 'inline-block';
btnEndChat.style.display = 'inline-block';
if (_activeStatus === 'HUMAN_TAKEOVER') {
btnTakeover.textContent = 'Hand back to AI';
btnTakeover.style.color = '#ef4444';
btnTakeover.style.borderColor = '#ef4444';
$('chat-reply-area').classList.remove('hidden');
// Show transfer button when admin is handling the session
$('btn-transfer').style.display = 'inline-block';
} else {
btnTakeover.textContent = 'Take Over';
btnTakeover.style.color = 'var(--accent)';
btnTakeover.style.borderColor = 'var(--accent)';
$('chat-reply-area').classList.add('hidden');
$('btn-transfer').style.display = 'none';
}
}
// Update Block Button
const btnBlock = $('btn-block-user');
if (_activeBlocked) {
btnBlock.textContent = 'Unblock User';
btnBlock.style.color = '#10b981';
btnBlock.style.borderColor = '#10b981';
} else {
btnBlock.textContent = 'Block User';
btnBlock.style.color = 'var(--text2)';
btnBlock.style.borderColor = 'var(--border)';
}
}
function esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
$('btn-delete-session').onclick = async () => {
if (!_activeId || !confirm('Permanently delete this entire session and all its chats?')) return;
try {
await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId), { method: 'DELETE', headers: { Authorization: authHeader() } });
showToast('Session deleted');
// Remove notifications for this session
_notifications = _notifications.filter(n => n.sessionId !== _activeId);
_saveNotifications();
renderNotifications();
_updateBadge();
// Clean from localStorage ID lists
try {
const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
localStorage.setItem('martech_notified_ids', JSON.stringify(ids.filter(id => id !== _activeId)));
const alertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
delete alertIds[_activeId];
localStorage.setItem('martech_alert_ids', JSON.stringify(alertIds));
} catch (e) { }
delete _sessionDataCache[_activeId];
_activeId = null;
_activeSessionData = null;
$('chat-placeholder').style.display = 'flex';
$('chat-active').style.display = 'none';
loadSessions(); loadAnalytics();
} catch (e) { showToast('Delete failed', 'error'); }
};
$('btn-delete-current-chat').onclick = async () => {
if (!_activeId || !_activeChatId || !confirm('Permanently delete this specific chat thread?')) return;
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/chats/${_activeChatId}`, {
method: 'DELETE',
headers: { Authorization: authHeader() }
});
if (r.ok) {
showToast('Chat deleted');
invalidateSessionCache(_activeId);
openSession(_activeId); // re-opens session, which will go back to chat list if multiple chats exist, or load remaining chat
} else {
showToast('Delete failed', 'error');
}
} catch (e) { showToast('Delete failed', 'error'); }
};
// Takeover Modal Logic
document.querySelectorAll('input[name="takeover-role"]').forEach(radio => {
radio.addEventListener('change', (e) => {
document.querySelectorAll('#takeover-modal .export-option').forEach(el => el.classList.remove('selected'));
e.target.closest('.export-option').classList.add('selected');
if (e.target.value === 'custom') {
$('inp-custom-role').style.display = 'block';
$('inp-custom-role').focus();
} else {
$('inp-custom-role').style.display = 'none';
}
});
});
$('takeover-modal-close').onclick = () => $('takeover-modal').classList.remove('open');
$('btn-takeover').onclick = async () => {
if (!_activeId) return;
if (_activeStatus === 'HUMAN_TAKEOVER') {
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/end_takeover`, {
method: 'POST', headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'AI';
// Inform the user that the AI is back
const leavingRole = window._activeTakeoverRoleName || 'The representative';
await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: `${leavingRole} has left the chat. I am your AI assistant againβ€”how can I help you?`, chat_id: _activeChatId })
});
invalidateSessionCache(_activeId);
openSession(_activeId, _activeChatId);
} else {
showToast('Action failed', 'error');
}
} catch (e) { showToast('Action failed', 'error'); }
} else {
$('takeover-modal').classList.add('open');
}
};
$('btn-confirm-takeover').onclick = async () => {
if (!_activeId) return;
const role = document.querySelector('input[name="takeover-role"]:checked').value;
let msg = "";
if (role === 'hr') {
window._activeTakeoverRoleName = "Human Resources";
msg = "Hello, I am from Human Resources. I understand you need some assistance. How may I help you today?";
} else if (role === 'supervising_authority') {
window._activeTakeoverRoleName = "The Supervising Authority";
msg = "Hello, this is the Supervising Authority. I have taken over this chat to assist you further. What can I do for you?";
} else if (role === 'custom') {
const customRole = $('inp-custom-role').value.trim() || 'The representative';
window._activeTakeoverRoleName = customRole;
msg = `Hello, I am ${customRole}. How may I assist you today?`;
}
$('btn-confirm-takeover').textContent = 'Processing...';
$('btn-confirm-takeover').disabled = true;
try {
let r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/takeover`, {
method: 'POST', headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'HUMAN_TAKEOVER';
await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: msg, chat_id: _activeChatId })
});
$('takeover-modal').classList.remove('open');
invalidateSessionCache(_activeId);
openSession(_activeId, _activeChatId);
} else {
const errData = await r.json().catch(() => ({}));
showToast(errData.detail || 'Takeover failed', 'error');
}
} catch (e) {
showToast('Takeover failed', 'error');
} finally {
$('btn-confirm-takeover').textContent = 'Confirm Takeover';
$('btn-confirm-takeover').disabled = false;
}
};
$('btn-end-chat').onclick = async () => {
if (!_activeId || !confirm('Are you sure you want to completely end this chat? The user will be disconnected.')) return;
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/end_chat`, {
method: 'POST',
headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'ENDED';
invalidateSessionCache(_activeId);
openSession(_activeId);
}
} catch (e) { }
};
// ── Transfer Session ─────────────────────────────────────────────
let _selectedTransferTarget = null;
$('btn-transfer').onclick = async () => {
if (!_activeId || _activeStatus !== 'HUMAN_TAKEOVER') return;
_selectedTransferTarget = null;
$('btn-confirm-transfer').disabled = true;
$('transfer-admin-list').innerHTML = '<div style="text-align:center; padding:20px; color:var(--text3);">Loading...</div>';
$('transfer-modal').classList.add('open');
try {
const r = await fetch('/api/admin/transferable-admins', { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Failed');
const data = await r.json();
const admins = data.admins || [];
if (!admins.length) {
$('transfer-admin-list').innerHTML = '<div style="text-align:center; padding:20px; color:var(--text3);">No other admins available.</div>';
return;
}
$('transfer-admin-list').innerHTML = admins.map(a => {
const dutyDot = a.on_duty
? '<span style="color:#10b981;">●</span>'
: '<span style="color:var(--text3);">●</span>';
const dutyLabel = a.on_duty ? '' : ' <span style="font-size:10px; color:var(--text3);">(Off Duty)</span>';
const waIcon = a.whatsapp_number ? ' πŸ“±' : '';
const roleBadge = a.role === 'superadmin'
? '<span style="font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(234,179,8,0.15); color:#eab308;">Super</span>'
: '<span style="font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(99,102,241,0.15); color:#6366f1;">Admin</span>';
return `
<div class="transfer-admin-item" data-username="${a.username}"
style="padding:12px; border-radius:8px; border:1px solid var(--border); cursor:pointer; transition:.2s;
${!a.on_duty ? 'opacity:0.5;' : ''}">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="font-size:13px; font-weight:600;">${dutyDot} ${a.display_name}${dutyLabel}${waIcon}</div>
<div style="font-size:11px; color:var(--text3);">${a.username}</div>
</div>
${roleBadge}
</div>
</div>`;
}).join('');
document.querySelectorAll('.transfer-admin-item').forEach(el => {
el.onclick = () => {
document.querySelectorAll('.transfer-admin-item').forEach(x => x.style.borderColor = 'var(--border)');
el.style.borderColor = '#8b5cf6';
_selectedTransferTarget = el.dataset.username;
$('btn-confirm-transfer').disabled = false;
};
});
} catch (e) {
$('transfer-admin-list').innerHTML = '<div style="text-align:center; padding:20px; color:var(--red);">Failed to load admins.</div>';
}
};
$('btn-confirm-transfer').onclick = async () => {
if (!_activeId || !_selectedTransferTarget) return;
$('btn-confirm-transfer').textContent = 'Transferring...';
$('btn-confirm-transfer').disabled = true;
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ target_username: _selectedTransferTarget })
});
const data = await r.json();
if (r.ok) {
$('transfer-modal').classList.remove('open');
const waMsg = data.whatsapp_notified ? '' : ' (WhatsApp notification was not sent)';
showToast(`Session transferred to ${data.display_name}${waMsg}`);
invalidateSessionCache(_activeId);
loadSessions();
openSession(_activeId, _activeChatId);
} else {
showToast(data.detail || 'Transfer failed', 'error');
}
} catch (e) {
showToast('Transfer failed', 'error');
} finally {
$('btn-confirm-transfer').textContent = 'Transfer';
$('btn-confirm-transfer').disabled = false;
}
};
$('btn-block-user').onclick = async () => {
if (!_activeId) return;
try {
const endpoint = _activeBlocked ? 'unblock' : 'block';
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/' + endpoint, { method: 'POST', headers: { Authorization: authHeader() } });
if (r.ok) {
showToast(_activeBlocked ? 'User unblocked' : 'User blocked');
invalidateSessionCache(_activeId);
openSession(_activeId);
loadSessions(); // refresh session list to show block status
}
} catch (e) { showToast('Action failed', 'error'); }
};
$('btn-send-reply').onclick = async () => {
if (!_activeId) return;
const msg = $('inp-admin-reply').value.trim();
if (!msg) return;
$('inp-admin-reply').value = '';
$('btn-send-reply').disabled = true;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: msg, chat_id: _activeChatId })
});
if (r.ok) {
invalidateSessionCache(_activeId);
await openSession(_activeId, _activeChatId);
showToast('Message sent');
} else {
const errData = await r.json().catch(() => ({}));
showToast(errData.detail || 'Send failed', 'error');
}
} catch (e) { showToast('Send failed', 'error'); }
finally { $('btn-send-reply').disabled = false; }
};
$('inp-admin-reply').onkeydown = e => { if (e.key === 'Enter') $('btn-send-reply').click(); };
// Auto Refresh
async function silentRefreshActive() {
if (!_activeId || !_activeChatId) return;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId), { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const newData = await r.json();
const oldMsgCount = (_activeSessionData?.messages || []).length;
const newMsgCount = (newData.messages || []).length;
const oldStatus = _activeSessionData?.status;
const oldBlocked = _activeSessionData?.blocked;
const needsUpdate = newMsgCount > oldMsgCount ||
newData.status !== oldStatus ||
newData.blocked !== oldBlocked;
if (needsUpdate) {
_sessionDataCache[_activeId] = newData;
_activeSessionData = newData;
renderChatMessages(newData, _activeChatId);
}
} catch (e) { }
}
function setupAutoRefresh() {
clearInterval(_refreshInterval);
if ($('toggle-refresh').checked) {
_refreshInterval = setInterval(() => {
// Only silently refresh the active open chat β€” session list polling is
// already handled by the main setInterval in initApp() to avoid double-fetching.
const activeView = document.querySelector('.view-container.active');
if (!activeView) return;
if (activeView.id === 'view-dashboard') loadAnalytics();
if (activeView.id === 'view-sessions') {
silentRefreshActive(); // Silently refresh open chat to show new user messages
}
}, 3000); // 3 seconds β€” extremely fast notifications
}
}
$('toggle-refresh').onchange = setupAutoRefresh;
// ── Export functionality ──
let _exportMode = 'global';
$('btn-open-export').onclick = () => {
_exportMode = 'global';
document.querySelector('#export-modal h3').textContent = 'Export Chat History';
$('opt-all').style.display = 'block';
$('opt-zip').style.display = 'block';
$('export-modal').classList.add('open');
};
$('export-modal-close').onclick = () => $('export-modal').classList.remove('open');
document.querySelectorAll('input[name="export-scope"]').forEach(rad => {
rad.onchange = () => {
$('opt-all').classList.toggle('selected', $('opt-all').querySelector('input').checked);
$('opt-zip').classList.toggle('selected', $('opt-zip').querySelector('input').checked);
};
});
let _exportFmt = 'excel';
document.querySelectorAll('.format-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_exportFmt = btn.dataset.fmt;
}
});
async function downloadFile(url, filename) {
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Fetch failed');
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename; a.click();
}
$('btn-export-single').onclick = () => {
if (!_activeId) return;
_exportMode = 'single';
document.querySelector('#export-modal h3').textContent = 'Export Specific Chat';
$('opt-all').style.display = 'none';
$('opt-zip').style.display = 'none';
$('export-modal').classList.add('open');
};
$('btn-submit-export').onclick = async () => {
const scope = document.querySelector('input[name="export-scope"]:checked').value;
const fmt = _exportFmt;
const btn = $('btn-submit-export');
btn.disabled = true; btn.textContent = 'Generating...';
try {
if (_exportMode === 'single') {
const ext = fmt === 'excel' ? 'xlsx' : 'json';
await downloadFile(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=${fmt}`, `session_${_activeId}.${ext}`);
showToast('Export downloaded');
$('export-modal').classList.remove('open');
} else if (scope === 'all') {
const ext = fmt === 'excel' ? 'xlsx' : 'json';
await downloadFile(`/api/admin/export/all?format=${fmt}`, `all_chats_export.${ext}`);
showToast('Export downloaded');
} else {
if (!_allSessions.length) throw new Error('No sessions');
const zip = new JSZip();
const ext = fmt === 'excel' ? 'xlsx' : 'json';
for (const s of _allSessions) {
try {
const r = await fetch(`/api/admin/export/session/${encodeURIComponent(s.session_id)}?format=${fmt}`, { headers: { Authorization: authHeader() } });
if (r.ok) {
const blob = await r.blob();
const name = (s.user_name || s.session_id).replace(/[^a-z0-9_\-]/gi, '_');
zip.file(`${name}_${s.session_id.slice(-6)}.${ext}`, blob);
}
} catch (e) { }
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(zipBlob);
a.download = `chats_export_${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
showToast('ZIP Export downloaded');
}
$('export-modal').classList.remove('open');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Generate Export';
}
};
// ── WhatsApp Settings ──
function renderWhatsAppNumberInput(value = '') {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.gap = '8px';
div.innerHTML = `
<input type="text" class="inp-whatsapp-number" value="${value}" placeholder="e.g. 1234567890" style="flex:1; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<button type="button" class="btn-remove-number" style="background:transparent; border:none; color:var(--red); cursor:pointer; padding:0 8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
`;
div.querySelector('.btn-remove-number').onclick = () => {
div.remove();
if (document.querySelectorAll('.inp-whatsapp-number').length === 0) {
renderWhatsAppNumberInput();
}
};
$('whatsapp-numbers-container').appendChild(div);
}
$('btn-add-whatsapp-number').onclick = () => renderWhatsAppNumberInput();
$('inp-whatsapp-provider').addEventListener('change', (e) => {
if (e.target.value === 'baileys') {
$('meta-settings-group').style.display = 'none';
$('baileys-settings-group').style.display = 'block';
} else {
$('meta-settings-group').style.display = 'block';
$('baileys-settings-group').style.display = 'none';
}
});
async function loadWhatsAppSettings() {
try {
const r = await fetch('/api/admin/settings/whatsapp', { headers: { Authorization: authHeader() } });
if (r.ok) {
const data = await r.json();
$('whatsapp-numbers-container').innerHTML = '';
if (data.admin_number) {
const nums = data.admin_number.split(',').map(n => n.trim()).filter(Boolean);
if (nums.length) {
nums.forEach(n => renderWhatsAppNumberInput(n));
} else {
renderWhatsAppNumberInput();
}
} else {
renderWhatsAppNumberInput();
}
if (data.provider) {
$('inp-whatsapp-provider').value = data.provider;
$('inp-whatsapp-provider').dispatchEvent(new Event('change'));
}
$('inp-whatsapp-phone-id').value = data.phone_id || '';
$('inp-whatsapp-token').value = data.token ? 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’' : '';
$('inp-baileys-url').value = data.baileys_url || '';
$('inp-baileys-token').value = data.baileys_token ? 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’' : '';
}
} catch (e) { console.error('Failed to load WhatsApp settings', e); }
}
$('btn-save-whatsapp').onclick = async () => {
const inputs = Array.from(document.querySelectorAll('.inp-whatsapp-number'))
.map(i => i.value.trim().replace(/[^\d]/g, '')) // digits only β€” + added server-side
.filter(Boolean);
const number = inputs.join(',');
const provider = $('inp-whatsapp-provider').value;
const phoneId = $('inp-whatsapp-phone-id').value.trim();
const token = $('inp-whatsapp-token').value.trim();
const baileysUrl = $('inp-baileys-url').value.trim();
const baileysToken = $('inp-baileys-token').value.trim();
if (!number) { showToast('Please enter a WhatsApp number', 'error'); return; }
$('btn-save-whatsapp').disabled = true;
$('btn-save-whatsapp').textContent = 'Saving...';
try {
const body = { provider: provider, admin_number: number, phone_id: phoneId, baileys_url: baileysUrl };
if (token && !token.startsWith('β€’β€’')) body.token = token;
if (baileysToken && !baileysToken.startsWith('β€’β€’')) body.baileys_token = baileysToken;
const r = await fetch('/api/admin/settings/whatsapp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify(body)
});
if (r.ok) {
showToast('WhatsApp settings saved');
$('whatsapp-status').innerHTML = '<span style="color:var(--accent);">βœ“ Settings saved successfully</span>';
} else {
showToast('Save failed', 'error');
}
} catch (e) { showToast('Save failed', 'error'); }
finally {
$('btn-save-whatsapp').disabled = false;
$('btn-save-whatsapp').textContent = 'Save Settings';
}
};
$('btn-test-whatsapp').onclick = async () => {
$('btn-test-whatsapp').disabled = true;
$('btn-test-whatsapp').textContent = 'Sending...';
try {
const r = await fetch('/api/admin/settings/whatsapp/test', {
method: 'POST',
headers: { Authorization: authHeader() }
});
const data = await r.json();
if (r.ok) {
const dbg = data.debug || {};
showToast('Test message sent!');
$('whatsapp-status').innerHTML = `<span style="color:var(--accent);">βœ“ Sent via ${dbg.provider || '?'} to ${(dbg.numbers_sent_to || []).join(', ')}</span><br><span style="font-size:10px;color:var(--text3);">Connection: ${dbg.baileys_connection || 'N/A'}</span><br><span style="font-size:10px;color:var(--text3);word-break:break-all;">RAW: ${dbg.baileys_raw_response || 'none'}</span>`;
} else {
showToast(data.detail || 'Test failed', 'error');
$('whatsapp-status').innerHTML = `<span style="color:var(--red);">βœ— ${data.detail || 'Failed to send test message'}</span>`;
}
} catch (e) { showToast('Test failed', 'error'); }
finally {
$('btn-test-whatsapp').disabled = false;
$('btn-test-whatsapp').textContent = 'Send Test Message';
}
};
// ── Baileys QR Code ──
$('btn-baileys-qr').onclick = async () => {
const btn = $('btn-baileys-qr');
const status = $('baileys-qr-status');
const img = $('baileys-qr-img');
btn.disabled = true;
img.style.display = 'none';
let retryCount = 0;
const maxRetries = 15; // 45 seconds max
const pollQR = async () => {
status.innerHTML = '<span class="spinner" style="display:inline-block; width:14px; height:14px; border-width:2px; vertical-align:middle; margin-right:6px;"></span> Fetching QR code (this takes up to 20s)...';
try {
const r = await fetch('/api/admin/settings/whatsapp/baileys/qr', {
headers: { Authorization: authHeader() }
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || 'Failed to fetch QR');
if (data.status === 'connected') {
status.innerHTML = '<span style="color:var(--green); font-weight:600;">βœ“ WhatsApp is already connected!</span>';
btn.disabled = false;
} else if (data.status === 'generating') {
retryCount++;
if (retryCount >= maxRetries) {
status.innerHTML = '<span style="color:var(--red); font-weight:600;">Timed out waiting for QR code. Please click fetch again.</span>';
btn.disabled = false;
} else {
setTimeout(pollQR, 3000);
}
} else if (data.qr_base64) {
status.innerHTML = 'Scan this code with WhatsApp:';
img.src = data.qr_base64;
img.style.display = 'block';
btn.disabled = false;
} else {
status.innerHTML = 'Unexpected response from Baileys server.';
btn.disabled = false;
}
} catch (e) {
status.innerHTML = `<span style="color:var(--red);">Error: ${e.message}</span>`;
btn.disabled = false;
}
};
pollQR();
};
// ── Settings Panel Navigation ──
function initSettingsPanel() {
document.querySelectorAll('.settings-nav-item').forEach(el => {
el.onclick = () => {
document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.settings-section').forEach(s => s.classList.remove('active'));
el.classList.add('active');
$('settings-sec-' + el.dataset.settingsSec).classList.add('active');
};
});
}
// ── Notification settings: persist each toggle in localStorage ──
const NOTIF_SETTINGS_KEY = 'martech_notif_settings';
function loadNotifSettings() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(NOTIF_SETTINGS_KEY) || '{}'); } catch (e) { }
// Defaults: os_popup ON, sound_background ON, sound_foreground ON, sound_onfocus OFF, title_flash ON
const defaults = { os_popup: true, sound_background: true, sound_foreground: true, sound_onfocus: false, title_flash: true };
return Object.assign({}, defaults, saved);
}
function saveNotifSetting(key, value) {
const current = loadNotifSettings();
current[key] = value;
localStorage.setItem(NOTIF_SETTINGS_KEY, JSON.stringify(current));
}
window.saveNotifSetting = saveNotifSetting;
function applyNotifSettingsToUI() {
const s = loadNotifSettings();
const togOs = $('tog-notif-os');
const togBg = $('tog-notif-bgsound');
const togFg = $('tog-notif-fgsound');
const togReplay = $('tog-notif-replay');
const togFlash = $('tog-notif-flash');
const badgeOs = $('badge-notif-os');
if (togOs) togOs.checked = s.os_popup;
if (togBg) togBg.checked = s.sound_background;
if (togFg) togFg.checked = s.sound_foreground;
if (togReplay) togReplay.checked = s.sound_onfocus;
if (togFlash) togFlash.checked = s.title_flash;
// OS badge reflects actual browser permission, not just the toggle
if (badgeOs) {
const perm = 'Notification' in window ? Notification.permission : 'denied';
badgeOs.textContent = perm === 'granted' ? 'Granted' : perm === 'denied' ? 'Blocked' : 'Not set';
badgeOs.style.background = perm === 'granted' ? '#dcfce7' : '#fee2e2';
badgeOs.style.color = perm === 'granted' ? '#15803d' : '#b91c1c';
}
}
// Resizer Logic
const resizer = document.getElementById('sidebar-resizer');
const sidebar = document.getElementById('sidebar');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('dragging');
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth >= 200 && newWidth <= 600) {
sidebar.style.flexBasis = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('dragging');
document.body.style.cursor = 'default';
}
});
// ── Custom Context Menu Logic ──
let _contextTarget = null; // { type: 'session' | 'thread', id: string, chatId?: int, isEnded: boolean, isArchived: boolean }
document.addEventListener('click', (e) => {
const m = $('custom-context-menu');
if (!m) return;
if (!m.contains(e.target)) {
m.style.display = 'none';
_contextTarget = null;
}
});
window.showContextMenu = function (e, targetObj) {
e.preventDefault();
_contextTarget = targetObj;
const ctxMenu = $('custom-context-menu');
if (!ctxMenu) return;
const actionArchive = $('ctx-action-archive');
const actionUnarchive = $('ctx-action-unarchive');
const actionBlock = $('ctx-action-block');
// Configure options
if (targetObj.type === 'session') {
if (actionBlock) actionBlock.style.display = 'flex';
if (targetObj.isArchived) {
if (actionArchive) actionArchive.style.display = 'none';
if (actionUnarchive) actionUnarchive.style.display = 'flex';
} else {
if (actionArchive) actionArchive.style.display = 'flex';
if (actionUnarchive) actionUnarchive.style.display = 'none';
}
} else if (targetObj.type === 'thread') {
if (actionBlock) actionBlock.style.display = 'none';
if (targetObj.isArchived) {
if (actionArchive) actionArchive.style.display = 'none';
if (actionUnarchive) actionUnarchive.style.display = 'flex';
} else {
if (actionArchive) actionArchive.style.display = 'flex';
if (actionUnarchive) actionUnarchive.style.display = 'none';
}
}
ctxMenu.style.display = 'flex';
// Bounds checking
let x = e.clientX;
let y = e.clientY;
if (x + ctxMenu.offsetWidth > window.innerWidth) x -= ctxMenu.offsetWidth;
if (y + ctxMenu.offsetHeight > window.innerHeight) y -= ctxMenu.offsetHeight;
ctxMenu.style.left = x + 'px';
ctxMenu.style.top = y + 'px';
_bindContextMenuActions();
};
function _bindContextMenuActions() {
const ctxMenu = $('custom-context-menu');
const actionArchive = $('ctx-action-archive');
const actionUnarchive = $('ctx-action-unarchive');
const actionBlock = $('ctx-action-block');
const actionDelete = $('ctx-action-delete');
if (actionArchive) actionArchive.onclick = async (e) => {
e.stopPropagation();
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
if (_contextTarget.type === 'thread' && !_contextTarget.isEnded) {
showToast('Cannot archive an active chat. End the chat first.', 'error');
return;
}
try {
const url = _contextTarget.type === 'session'
? '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/archive'
: '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/chats/' + _contextTarget.chatId + '/archive';
// Optimistic UI
if (_contextTarget.type === 'session') {
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) s.archived = true;
renderSessionList(_allSessions);
} else if (_contextTarget.type === 'thread') {
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) {
if (!s.archived_chats) s.archived_chats = [];
if (!s.archived_chats.includes(_contextTarget.chatId)) {
s.archived_chats.push(_contextTarget.chatId);
}
// Immediately re-render the chat list to hide it
if (String(_activeId) === String(s.session_id || s.id)) {
if (_activeSessionData) {
if (!_activeSessionData.archived_chats) _activeSessionData.archived_chats = [];
if (!_activeSessionData.archived_chats.includes(_contextTarget.chatId)) {
_activeSessionData.archived_chats.push(_contextTarget.chatId);
}
renderChatList(_activeSessionData);
}
}
}
}
const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error('Failed');
showToast('Archived successfully');
} catch (e) {
showToast('Failed to archive', 'error');
loadSessions(); // Rollback
}
};
if (actionUnarchive) actionUnarchive.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
try {
let url;
if (_contextTarget.type === 'session') {
// Unarchiving the whole session
url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/unarchive';
// Optimistic UI: mark session as unarchived
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) s.archived = false;
renderSessionList(_allSessions);
// Invalidate cache so the re-opened session loads fresh data
invalidateSessionCache(_contextTarget.id);
} else {
// Unarchiving a single chat thread.
// ALWAYS use the chat-level endpoint β€” even if the parent session was
// archived, because we only want to restore this one thread, not the
// whole session. The backend should handle promoting the session out of
// archived state if it has at least one active thread.
url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) +
'/chats/' + _contextTarget.chatId + '/unarchive';
// Optimistic UI: remove this chatId from archived_chats only
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) {
if (!s.archived_chats) s.archived_chats = [];
const idx = s.archived_chats.indexOf(_contextTarget.chatId);
if (idx > -1) s.archived_chats.splice(idx, 1);
// If the session was fully archived, also clear session-level archived
// flag so it reappears in live sessions
if (_contextTarget.isSessionArchived) s.archived = false;
}
renderSessionList(_allSessions);
// Update the currently-open session data if it matches
if (String(_activeId) === String(_contextTarget.id)) {
if (_activeSessionData) {
if (!_activeSessionData.archived_chats) _activeSessionData.archived_chats = [];
const idx2 = _activeSessionData.archived_chats.indexOf(_contextTarget.chatId);
if (idx2 > -1) _activeSessionData.archived_chats.splice(idx2, 1);
if (_contextTarget.isSessionArchived) _activeSessionData.archived = false;
renderChatList(_activeSessionData);
}
// Invalidate cache so clicking the thread loads fresh data from server
invalidateSessionCache(_contextTarget.id);
}
}
const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error('Failed');
showToast('Unarchived successfully');
// Reload the session list from server to get the authoritative state
loadSessions();
} catch (e) {
showToast('Failed to unarchive', 'error');
loadSessions(); // Rollback
}
};
if (actionBlock) actionBlock.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget || _contextTarget.type !== 'session') return;
if (!confirm('Block this user completely?')) return;
try {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/block', { method: 'POST', headers: { Authorization: authHeader() } });
showToast('User blocked');
loadSessions();
} catch (e) { showToast('Block failed', 'error'); }
};
if (actionDelete) actionDelete.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
if (!confirm('Permanently delete this ' + _contextTarget.type + '?')) return;
try {
if (_contextTarget.type === 'session') {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id), { method: 'DELETE', headers: { Authorization: authHeader() } });
} else {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/chats/' + _contextTarget.chatId, { method: 'DELETE', headers: { Authorization: authHeader() } });
}
showToast('Deleted successfully');
if (_contextTarget.type === 'session' && String(_activeId) === String(_contextTarget.id)) {
_activeId = null;
$('chat-placeholder').style.display = 'flex';
$('chat-active').style.display = 'none';
} else if (_contextTarget.type === 'thread' && String(_activeId) === String(_contextTarget.id) && String(_activeChatId) === String(_contextTarget.chatId)) {
openSession(_activeId); // go back to threads
}
loadSessions();
loadAnalytics();
} catch (e) { showToast('Delete failed', 'error'); }
};
}
// ══════════════════════════════════════════════════════════════════════
// RBAC β€” Multi-Admin Permission System
// ══════════════════════════════════════════════════════════════════════
window._adminProfile = null;
async function fetchAdminProfile() {
try {
const r = await fetch('/api/admin/me', { headers: { Authorization: authHeader() } });
if (r.status === 401 || r.status === 403) {
_creds = null;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
return;
}
if (r.ok) {
window._adminProfile = await r.json();
}
} catch (e) {
console.warn('Failed to fetch admin profile:', e);
}
}
function hasPerm(perm) {
if (!window._adminProfile) return true; // Fallback: show everything until loaded
if (window._adminProfile.role === 'superadmin') return true;
const perms = window._adminProfile.permissions || [];
return perms.includes(perm);
}
function hasAnySettingsPerm() {
if (!window._adminProfile) return true;
if (window._adminProfile.role === 'superadmin') return true;
const perms = window._adminProfile.permissions || [];
return perms.some(p => p.startsWith('whatsapp.') || p.startsWith('settings.'));
}
function getFirstAllowedView() {
if (hasPerm('manage_sessions')) return 'sessions';
if (hasPerm('view_analytics')) return 'dashboard';
if (hasAnySettingsPerm()) return 'settings';
if (hasPerm('manage_admins')) return 'admin-users';
return 'dashboard'; // fallback
}
function updateProfileChip() {
const p = window._adminProfile;
if (!p) return;
const avatar = $('profile-avatar');
const uname = $('profile-username');
const badge = $('profile-role-badge');
if (avatar) avatar.textContent = (p.display_name || p.username || 'A')[0].toUpperCase();
if (uname) uname.textContent = p.display_name || p.username || 'Admin';
if (badge) {
if (p.role === 'superadmin') {
badge.textContent = 'Superadmin';
badge.style.background = '#fef3c7';
badge.style.color = '#92400e';
} else {
badge.textContent = 'Sub-admin';
badge.style.background = '#dbeafe';
badge.style.color = '#1e40af';
}
}
}
function applyPermissions() {
const p = window._adminProfile;
if (!p) return;
// ── Pass 1: Sidebar nav visibility ──
const navMap = {
'nav-dashboard': () => hasPerm('view_analytics'),
'nav-sla': () => hasPerm('view_analytics'),
'nav-sessions': () => hasPerm('manage_sessions'),
'nav-queued': () => hasPerm('manage_sessions'),
'nav-daily': () => hasPerm('manage_sessions'),
'nav-blocked': () => hasPerm('manage_sessions'),
'nav-archived': () => hasPerm('manage_sessions'),
'nav-settings': () => hasAnySettingsPerm(),
'nav-admin-users': () => hasPerm('manage_admins'),
};
for (const [id, check] of Object.entries(navMap)) {
const el = $(id);
if (el) el.style.display = check() ? '' : 'none';
}
// ── Pass 2: In-view sub-permission element hiding ──
// Sessions sub-permissions
const sessionElements = {
'chat-reply-area': 'sessions.reply',
'btn-takeover': 'sessions.takeover',
'btn-transfer': 'sessions.takeover',
'btn-end-chat': 'sessions.end_chat',
'btn-block-user': 'sessions.block',
'btn-delete-current-chat': 'sessions.delete',
'btn-delete-session': 'sessions.delete',
};
for (const [id, perm] of Object.entries(sessionElements)) {
const el = $(id);
if (el) {
if (hasPerm(perm)) {
el.classList.remove('rbac-hidden');
} else {
el.classList.add('rbac-hidden');
el.style.display = 'none';
}
}
}
// Analytics sub-permissions
const analyticsElements = {
'btn-open-export': 'analytics.export',
'btn-export-single': 'analytics.export',
};
for (const [id, perm] of Object.entries(analyticsElements)) {
const el = $(id);
if (el) el.style.display = hasPerm(perm) ? '' : 'none';
}
// Context menu items
const ctxElements = {
'ctx-action-archive': 'sessions.archive',
'ctx-action-unarchive': 'sessions.archive',
'ctx-action-block': 'sessions.block',
'ctx-action-delete': 'sessions.delete',
};
for (const [id, perm] of Object.entries(ctxElements)) {
const el = $(id);
if (el && !hasPerm(perm)) el.style.display = 'none';
}
// Settings sub-sections
const settingsNavItems = document.querySelectorAll('.settings-nav-item');
settingsNavItems.forEach(item => {
const sec = item.getAttribute('data-settings-sec');
if (!sec) return;
const secPermMap = {
'notifications': 'settings.notifications',
'config': 'settings.config',
'security': 'settings.security',
'version': 'settings.version',
'whatsapp': 'whatsapp.view',
};
if (secPermMap[sec] && !hasPerm(secPermMap[sec])) {
item.style.display = 'none';
const secEl = $('settings-sec-' + sec);
if (secEl) secEl.style.display = 'none';
}
});
// WhatsApp sub-permissions
if (!hasPerm('whatsapp.edit')) {
const saveBtn = $('btn-save-whatsapp');
if (saveBtn) saveBtn.style.display = 'none';
}
if (!hasPerm('whatsapp.test')) {
const testBtn = $('btn-test-whatsapp');
if (testBtn) testBtn.style.display = 'none';
}
if (!hasPerm('whatsapp.qr')) {
const qrBtn = $('btn-baileys-qr');
if (qrBtn) qrBtn.style.display = 'none';
}
// Password age warning
if (p.password_age_warning) {
const days = p.password_age_days || 0;
showToast(`Your password is ${days} days old. Consider changing it.`, 'error');
}
// Escalation settings (superadmin only)
const escNav = $('nav-settings-escalation');
const escSec = $('settings-sec-escalation');
if (p.role === 'superadmin') {
if (escNav) escNav.style.display = '';
if (escSec) escSec.style.display = '';
// Load escalation settings + status
_loadEscalationSettings();
// Save timeout handler
if ($('btn-save-escalation-timeout')) {
$('btn-save-escalation-timeout').onclick = async () => {
const val = parseInt($('inp-escalation-timeout').value);
if (isNaN(val) || val < 5 || val > 120) {
showToast('Timeout must be between 5 and 120 seconds', 'error');
return;
}
try {
const r = await fetch('/api/admin/settings/escalation', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ timeout_seconds: val }),
});
if (r.ok) showToast('Escalation timeout saved');
else {
const err = await r.json().catch(() => ({}));
showToast(err.detail || 'Save failed', 'error');
}
} catch (e) { showToast('Save failed', 'error'); }
};
}
} else {
if (escNav) escNav.style.display = 'none';
if (escSec) escSec.style.display = 'none';
}
}
// ══════════════════════════════════════════════════════════════════════
// Escalation Settings Loader
// ══════════════════════════════════════════════════════════════════════
async function _loadEscalationSettings() {
try {
const r = await fetch('/api/admin/settings/escalation/status', { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const d = await r.json();
// Set timeout value
if ($('inp-escalation-timeout')) {
$('inp-escalation-timeout').value = d.timeout_seconds || 20;
}
// Render eligible admins
const listEl = $('escalation-eligible-list');
if (listEl && d.eligible_admins) {
if (d.eligible_admins.length === 0) {
listEl.innerHTML = '<div style="padding:12px; text-align:center; color:var(--text3); font-size:12px; background:var(--bg3); border-radius:8px;">No eligible admins. All admins are either off-duty or lack takeover permission.</div>';
} else {
listEl.innerHTML = d.eligible_admins.map((a, i) => `
<div style="display:flex; align-items:center; gap:10px; padding:10px 14px; background:var(--bg3); border-radius:8px;">
<span style="font-size:11px; font-weight:700; background:var(--accent); color:#fff; width:22px; height:22px; display:flex; align-items:center; justify-content:center; border-radius:50%;">${i + 1}</span>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:500; color:var(--text);">${a.display_name || a.username}</div>
<div style="font-size:11px; color:var(--text3);">@${a.username}</div>
</div>
<span style="font-size:10px; font-weight:600; text-transform:uppercase; padding:2px 8px; border-radius:12px; ${a.role === 'superadmin' ? 'background:#fef3c7; color:#92400e;' : 'background:#dbeafe; color:#1e40af;'}">${a.role === 'superadmin' ? 'Superadmin' : 'Admin'}</span>
${a.has_whatsapp ? '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#10b981" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /></svg>' : '<span style="font-size:10px; color:var(--text3);">No WA</span>'}
</div>
`).join('');
}
}
// Render queue status
const queueEl = $('escalation-queue-status');
if (queueEl) {
if (d.queued_sessions === 0) {
queueEl.innerHTML = '<div style="display:flex; align-items:center; gap:8px; padding:4px;"><span style="width:8px; height:8px; border-radius:50%; background:#10b981;"></span><span style="font-size:13px; color:var(--text2);">No sessions in queue β€” all clear</span></div>';
} else {
let html = `<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;"><span style="width:8px; height:8px; border-radius:50%; background:#f59e0b; animation:pulse 1.5s infinite;"></span><span style="font-size:13px; font-weight:500; color:var(--text);">${d.queued_sessions} session${d.queued_sessions > 1 ? 's' : ''} in queue</span></div>`;
if (d.assignments && d.assignments.length > 0) {
html += d.assignments.map(a => `
<div style="display:flex; align-items:center; gap:8px; padding:8px 12px; background:var(--surface); border-radius:8px; margin-bottom:4px; border:1px solid var(--border);">
<span style="font-size:12px; color:var(--text2); flex:1;">${a.user_name || 'Guest'}</span>
<span style="font-size:11px; color:var(--accent); font-weight:500;">β†’ ${a.assigned_to}</span>
</div>
`).join('');
}
queueEl.innerHTML = html;
}
}
} catch (e) {
console.warn('Failed to load escalation settings:', e);
}
}
// ══════════════════════════════════════════════════════════════════════
// Admin Users Management
// ══════════════════════════════════════════════════════════════════════
let _adminUsers = [];
async function loadAdminUsers() {
const container = $('admin-users-table-body');
if (!container) return;
container.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--text3)"><div class="spinner"></div></td></tr>';
try {
const r = await fetch('/api/admin/users', { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Failed to load admins');
const data = await r.json();
_adminUsers = data.admins || [];
renderAdminTable(_adminUsers);
} catch (e) {
container.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--red)">Failed to load admin users.</td></tr>';
}
}
function renderAdminTable(admins) {
const container = $('admin-users-table-body');
if (!container) return;
if (!admins.length) {
container.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:40px; color:var(--text3)">No admin users found.</td></tr>';
return;
}
container.innerHTML = admins.map(a => {
const isSuperadmin = a.role === 'superadmin';
const isActive = a.is_active !== false;
const roleBadge = isSuperadmin
? '<span style="background:#fef3c7; color:#92400e; padding:2px 8px; border-radius:20px; font-size:11px; font-weight:600;">Superadmin</span>'
: '<span style="background:#dbeafe; color:#1e40af; padding:2px 8px; border-radius:20px; font-size:11px; font-weight:600;">Sub-admin</span>';
const statusBadge = !isActive
? '<span style="background:#fee2e2; color:#b91c1c; padding:2px 8px; border-radius:20px; font-size:11px; font-weight:600;">Suspended</span>'
: '<span style="background:#dcfce7; color:#15803d; padding:2px 8px; border-radius:20px; font-size:11px; font-weight:600;">Active</span>';
const permBadges = isSuperadmin
? '<span style="background:#fef3c7; color:#92400e; padding:2px 8px; border-radius:20px; font-size:10px; font-weight:600;">πŸ”‘ Full Access</span>'
: (a.permissions || []).filter(p => !p.includes('.')).map(p => {
const colors = { manage_sessions: '#dbeafe|#1e40af', view_analytics: '#f3e8ff|#7c3aed', manage_whatsapp: '#dcfce7|#15803d', manage_kb: '#fff7ed|#c2410c', manage_admins: '#fee2e2|#b91c1c' };
const [bg, fg] = (colors[p] || '#f1f5f9|#475569').split('|');
const labels = { manage_sessions: 'Sessions', view_analytics: 'Analytics', manage_whatsapp: 'WhatsApp', manage_kb: 'KB', manage_admins: 'Admins' };
return `<span style="background:${bg}; color:${fg}; padding:1px 7px; border-radius:20px; font-size:10px; font-weight:600;">${labels[p] || p}</span>`;
}).join(' ');
const lastLogin = a.last_login ? fmtDate(a.last_login) : '<span style="color:var(--text3)">Never</span>';
const rowOpacity = isActive ? '1' : '0.5';
const editBtn = hasPerm('admins.edit') ? `<button class="btn-icon" title="Edit" onclick="openEditAdminModal('${a.username}')" style="width:30px; height:30px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>` : '';
const deleteBtn = hasPerm('admins.delete') ? `<button class="btn-icon" title="Delete" onclick="deleteAdminUser('${a.username}')" style="width:30px; height:30px; color:var(--red);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>` : '';
return `<tr style="opacity:${rowOpacity}; border-bottom:1px solid var(--border);">
<td style="padding:12px 16px; font-weight:600; font-size:13px;">${a.display_name || a.username}<br><span style="font-weight:400; color:var(--text3); font-size:11px;">@${a.username}</span></td>
<td style="padding:12px 8px;">${roleBadge}</td>
<td style="padding:12px 8px;">${statusBadge}</td>
<td style="padding:12px 8px; font-size:12px; color:var(--text2);">${a.email || 'β€”'}</td>
<td style="padding:12px 8px;">${permBadges}</td>
<td style="padding:12px 8px; font-size:12px; color:var(--text2);">${lastLogin}</td>
<td style="padding:12px 8px; white-space:nowrap;">${editBtn}${deleteBtn}</td>
</tr>`;
}).join('');
}
// Expose to global scope for inline onclick handlers
window.openEditAdminModal = function(username) {
const admin = _adminUsers.find(a => a.username === username);
if (!admin) return;
$('modal-admin-title').textContent = 'Edit Admin: ' + username;
$('modal-admin-username').value = admin.username;
$('modal-admin-username').readOnly = true;
$('modal-admin-email').value = admin.email || '';
$('modal-admin-display-name').value = admin.display_name || '';
$('modal-admin-notes').value = admin.notes || '';
$('modal-admin-password-group').style.display = 'none';
$('modal-admin-role').value = admin.role;
// Set permission toggles
const perms = admin.permissions || [];
document.querySelectorAll('#admin-modal [data-perm-toggle]').forEach(tog => {
const perm = tog.getAttribute('data-perm-toggle');
tog.checked = perms.includes(perm);
});
// Update parent group visibility
updatePermGroupVisibility();
$('admin-modal').classList.add('open');
$('admin-modal').dataset.mode = 'edit';
$('admin-modal').dataset.username = username;
};
window.openCreateAdminModal = function() {
$('modal-admin-title').textContent = 'Create New Admin';
$('modal-admin-username').value = '';
$('modal-admin-username').readOnly = false;
$('modal-admin-email').value = '';
$('modal-admin-display-name').value = '';
$('modal-admin-notes').value = '';
$('modal-admin-password-group').style.display = '';
$('modal-admin-password').value = '';
$('modal-admin-role').value = 'sub_admin';
// Clear all permission toggles
document.querySelectorAll('#admin-modal [data-perm-toggle]').forEach(tog => {
tog.checked = false;
});
updatePermGroupVisibility();
$('admin-modal').classList.add('open');
$('admin-modal').dataset.mode = 'create';
$('admin-modal').dataset.username = '';
};
window.deleteAdminUser = async function(username) {
if (!confirm(`Delete admin "${username}"? This cannot be undone.`)) return;
try {
const r = await fetch('/api/admin/users/' + encodeURIComponent(username), {
method: 'DELETE', headers: { Authorization: authHeader() }
});
if (r.ok) {
showToast('Admin ' + username + ' deleted.');
loadAdminUsers();
} else {
const err = await r.json();
showToast(err.detail || 'Delete failed.', 'error');
}
} catch (e) { showToast('Network error.', 'error'); }
};
window.updatePermGroupVisibility = function() {
const role = $('modal-admin-role')?.value;
const permArea = $('admin-perm-toggles');
if (!permArea) return;
if (role === 'superadmin') {
permArea.style.display = 'none';
const badge = $('superadmin-full-badge');
if (badge) badge.style.display = '';
} else {
permArea.style.display = '';
const badge = $('superadmin-full-badge');
if (badge) badge.style.display = 'none';
}
// Show/hide sub-permission groups based on parent toggle
const parentMap = {
'manage_sessions': 'perm-group-sessions',
'view_analytics': 'perm-group-analytics',
'manage_whatsapp': 'perm-group-whatsapp',
'manage_admins': 'perm-group-admins',
};
for (const [parent, groupId] of Object.entries(parentMap)) {
const parentToggle = document.querySelector(`[data-perm-toggle="${parent}"]`);
const group = $(groupId);
if (group && parentToggle) {
group.style.display = parentToggle.checked ? '' : 'none';
}
}
}
window.saveAdminModal = async function() {
const mode = $('admin-modal').dataset.mode;
const username = mode === 'edit' ? $('admin-modal').dataset.username : $('modal-admin-username').value.trim();
const email = $('modal-admin-email').value.trim();
const displayName = $('modal-admin-display-name').value.trim();
const notes = $('modal-admin-notes').value.trim();
const role = $('modal-admin-role').value;
// Collect permissions
let permissions = [];
if (role === 'sub_admin') {
document.querySelectorAll('#admin-modal [data-perm-toggle]:checked').forEach(tog => {
permissions.push(tog.getAttribute('data-perm-toggle'));
});
}
try {
let url, method, body;
if (mode === 'create') {
const password = $('modal-admin-password').value;
if (!password || password.length < 8) {
showToast('Password must be at least 8 characters.', 'error');
return;
}
url = '/api/admin/users';
method = 'POST';
body = { username, email, password, role, permissions, display_name: displayName, notes };
} else {
url = '/api/admin/users/' + encodeURIComponent(username);
method = 'PUT';
body = { role, email, permissions: role === 'sub_admin' ? permissions : undefined, display_name: displayName, notes };
}
const r = await fetch(url, {
method, headers: { Authorization: authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
const result = await r.json();
$('admin-modal').classList.remove('open');
showToast(mode === 'create' ? `Admin "${username}" created!` : `Admin "${username}" updated!`);
// Show credential card for new admin
if (mode === 'create') {
showCredentialCard(username, $('modal-admin-password').value, email);
}
loadAdminUsers();
} else {
const err = await r.json();
showToast(err.detail || 'Failed to save.', 'error');
}
} catch (e) { showToast('Network error.', 'error'); }
}
function showCredentialCard(username, password, email) {
const cardText = `Admin Panel Login Details
─────────────────────────
URL: ${window.location.origin}/admin
Username: ${username}
Password: ${password}
─────────────────────────
Please log in and change your password immediately.`;
const overlay = $('credential-card-modal');
if (!overlay) return;
$('credential-card-text').textContent = cardText;
overlay.classList.add('open');
$('btn-copy-credentials').onclick = () => {
navigator.clipboard.writeText(cardText).then(() => showToast('Copied to clipboard!'));
};
$('btn-dismiss-credentials').onclick = () => overlay.classList.remove('open');
}
// ── Change Password ──
window.doChangePassword = async function() {
const curr = $('inp-current-password')?.value;
const newP = $('inp-new-password')?.value;
const conf = $('inp-confirm-password')?.value;
if (!curr || !newP || !conf) { showToast('All fields are required.', 'error'); return; }
if (newP !== conf) { showToast('New passwords do not match.', 'error'); return; }
if (newP.length < 8) { showToast('Password must be at least 8 characters.', 'error'); return; }
try {
const r = await fetch('/api/admin/change-password', {
method: 'POST',
headers: { Authorization: authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: curr, new_password: newP })
});
if (r.ok) {
$('change-password-modal').classList.remove('open');
showToast('Password changed. Please log in again.');
setTimeout(() => {
_creds = null;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}, 2000);
} else {
const err = await r.json();
showToast(err.detail || 'Failed to change password.', 'error');
}
} catch (e) { showToast('Network error.', 'error'); }
}
})();
</script>
<!-- Custom Context Menu -->
<div id="custom-context-menu">
<div class="ctx-menu-item" id="ctx-action-archive">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Archive</span>
</div>
<div class="ctx-menu-item" id="ctx-action-unarchive" style="display:none;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3h18v18H3z"></path>
<path d="M8 12l4-4 4 4"></path>
<path d="M12 8v8"></path>
</svg>
<span>Unarchive</span>
</div>
<div class="ctx-menu-item" id="ctx-action-block">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
<span>Block User</span>
</div>
<div class="ctx-divider"></div>
<div class="ctx-menu-item danger" id="ctx-action-delete">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span>Delete</span>
</div>
</div>
</body>
</html>