| <!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; |
| } |
| |
| |
| .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 { |
| 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 { |
| display: none; |
| height: 100vh; |
| width: 100vw; |
| flex-direction: row; |
| } |
| |
| |
| .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 { |
| 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 { |
| 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; |
| } |
| |
| |
| .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%; |
| } |
| |
| |
| #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; |
| } |
| |
| |
| #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); |
| } |
| |
| |
| .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); |
| } |
| |
| |
| @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; |
| } |
| |
| |
| .chat-reply-area { |
| display: flex; |
| } |
| |
| .chat-reply-area.hidden { |
| display: none !important; |
| } |
| |
| |
| #view-settings { |
| padding: 32px; |
| } |
| |
| |
| #view-sessions { |
| padding: 0; |
| } |
| |
| |
| .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-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 { |
| 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> |
|
|
| |
| <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> |
|
|
| |
| <div id="app"> |
|
|
| |
| <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 class="main-content" style="flex:1; min-width:0; display:flex; flex-direction:column; height:100vh;"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="view-container" id="view-settings" style="padding:0; flex-direction:row !important ;"> |
|
|
| |
| <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> |
|
|
| |
| <div style="flex:1; overflow-y:auto; padding:32px 40px;"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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"> |
| |
| </div> |
| <div id="chat-list-area" |
| style="display:none; flex-direction:column; gap:16px; padding:32px; overflow-y:auto; flex:1;"> |
| |
| </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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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 = {}; |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| 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; |
| } |
| |
| |
| let _notifiedQueuedIds = new Set(); |
| let _notifications = []; |
| let _snoozed = localStorage.getItem('martech_snoozed') === '1'; |
| |
| |
| function _loadPersistedNotifications() { |
| try { |
| const saved = JSON.parse(localStorage.getItem('martech_notifications') || '[]'); |
| |
| 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) { } |
| } |
| |
| let _audioCtx = null; |
| let _audioKeepalive = null; |
| let _pendingSoundType = null; |
| let _titleFlashInterval = null; |
| 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); } |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| let _notifAudioUrl = null; |
| let _disconnectAudioUrl = null; |
| |
| function _generateWavUrl(samples, sampleRate = 22050) { |
| |
| 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); |
| view.setUint16(22, 1, true); |
| 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); |
| |
| 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); |
| 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 => { |
| |
| 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; |
| } |
| |
| |
| 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)); |
| |
| _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(); |
| }; |
| |
| |
| (async function checkAuth() { |
| try { |
| const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); |
| if (stored && stored.user && stored.otp_verified) { |
| _creds = stored; |
| |
| 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; |
| |
| const dutyDot = document.getElementById('sidebar-duty-dot'); |
| if (dutyDot) { |
| dutyDot.style.background = window._adminProfile.on_duty !== false ? '#10b981' : '#6b7280'; |
| } |
| } |
| } catch (e) { |
| |
| 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) { } |
| })(); |
| |
| |
| |
| function _startTitleFlash(msg) { |
| if (_titleFlashInterval) return; |
| 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; |
| } |
| |
| |
| 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; |
| } |
| _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; |
| if (!('Notification' in window) || Notification.permission !== 'granted') return; |
| |
| |
| if ('serviceWorker' in navigator) { |
| const reg = await navigator.serviceWorker.getRegistration(); |
| if (reg) { |
| await reg.showNotification(title, { |
| body, |
| tag, |
| renotify: true, |
| 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; |
| } |
| } |
| |
| |
| const n = new Notification(title, { body, tag, silent: false }); |
| n.onclick = () => { window.focus(); n.close(); }; |
| } |
| |
| function initApp() { |
| _loadPersistedNotifications(); |
| $('toggle-snooze').checked = _snoozed; |
| |
| |
| applyPermissions(); |
| updateProfileChip(); |
| |
| |
| |
| _initAudioCtx(); |
| |
| if (_audioCtx) _audioCtx.resume().catch(() => { }); |
| |
| _startAudioKeepalive(); |
| |
| registerServiceWorker(); |
| |
| |
| document.addEventListener('click', () => { |
| if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume().catch(() => { }); |
| }); |
| |
| |
| 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); |
| } |
| }); |
| |
| |
| if ($('btn-change-password')) { |
| $('btn-change-password').onclick = () => { |
| const overlay = $('change-password-modal'); |
| if (overlay) overlay.classList.add('open'); |
| }; |
| |
| |
| $('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; |
| |
| const dutyDot = document.getElementById('sidebar-duty-dot'); |
| if (dutyDot) dutyDot.style.background = data.on_duty !== false ? '#10b981' : '#6b7280'; |
| |
| 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); } |
| } |
| |
| |
| |
| 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; |
| |
| 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(() => { }); |
| |
| |
| _initAudioUrls(); |
| localStorage.setItem('martech_audio_enabled', '1'); |
| playNotifSound(); |
| 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); |
| } |
| } |
| |
| |
| 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' } |
| } |
| }); |
| } |
| |
| |
| $('btn-notifications').onclick = (e) => { |
| e.stopPropagation(); |
| $('notif-dropdown').classList.toggle('show'); |
| if ($('notif-dropdown').classList.contains('show')) { |
| |
| _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) { |
| |
| if (sessionId) { |
| try { |
| const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]'); |
| if (!ids.includes(sessionId)) { |
| ids.push(sessionId); |
| |
| localStorage.setItem('martech_notified_ids', JSON.stringify(ids.slice(-50))); |
| } |
| |
| 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) { |
| |
| if (soundType === 'disconnect') playDisconnectSound(); |
| else playNotifSound(); |
| } |
| |
| if (document.visibilityState !== 'visible') { |
| |
| |
| if (ns.sound_onfocus) { |
| _pendingSoundType = soundType; |
| } |
| if (ns.title_flash) { |
| _startTitleFlash('New Alert!'); |
| } |
| } |
| } |
| |
| |
| 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(''); |
| |
| |
| 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)); |
| }; |
| }); |
| |
| |
| 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'; |
| } |
| |
| |
| 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 (_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; |
| } |
| } |
| |
| |
| 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'); |
| |
| |
| if (countEl) countEl.textContent = queued.length + (queued.length === 1 ? ' waiting' : ' waiting'); |
| |
| |
| _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; |
| } |
| |
| |
| 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(''); |
| |
| |
| container.querySelectorAll('.queue-item').forEach(el => { |
| el.onclick = (e) => { |
| if (e.target.classList.contains('queue-takeover-btn')) return; |
| const sid = el.dataset.sid; |
| |
| 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(); |
| _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'); |
| |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); |
| $('nav-sessions').classList.add('active'); |
| switchView('sessions'); |
| invalidateSessionCache(sessionId); |
| |
| |
| 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(); |
| } 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 |
| }); |
| }; |
| }); |
| } |
| |
| |
| $('session-list').addEventListener('scroll', () => { |
| const el = $('session-list'); |
| if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) { |
| loadSessions(false); |
| } |
| }); |
| |
| |
| 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; |
| |
| _activeId = String(id); |
| |
| if (!chatId) { |
| _activeChatId = null; |
| renderSessionList(_allSessions); |
| } else { |
| _activeChatId = chatId; |
| } |
| |
| $('chat-placeholder').style.display = 'none'; |
| $('chat-active').style.display = 'flex'; |
| |
| try { |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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;">👍 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;">👎 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;">★ ${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'); |
| |
| $('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'; |
| } |
| } |
| |
| |
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| } |
| |
| $('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'); |
| |
| |
| _notifications = _notifications.filter(n => n.sessionId !== _activeId); |
| _saveNotifications(); |
| renderNotifications(); |
| _updateBadge(); |
| |
| |
| 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); |
| } else { |
| showToast('Delete failed', 'error'); |
| } |
| } catch (e) { showToast('Delete failed', 'error'); } |
| }; |
| |
| |
| 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'; |
| |
| |
| 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) { } |
| }; |
| |
| |
| 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(); |
| } |
| } 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(); }; |
| |
| |
| 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(() => { |
| |
| |
| const activeView = document.querySelector('.view-container.active'); |
| if (!activeView) return; |
| if (activeView.id === 'view-dashboard') loadAnalytics(); |
| if (activeView.id === 'view-sessions') { |
| silentRefreshActive(); |
| } |
| }, 3000); |
| } |
| } |
| $('toggle-refresh').onchange = setupAutoRefresh; |
| |
| |
| 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'; |
| } |
| }; |
| |
| |
| 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, '')) |
| .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'; |
| } |
| }; |
| |
| |
| $('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; |
| |
| 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(); |
| }; |
| |
| |
| 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'); |
| }; |
| }); |
| } |
| |
| |
| const NOTIF_SETTINGS_KEY = 'martech_notif_settings'; |
| |
| function loadNotifSettings() { |
| let saved = {}; |
| try { saved = JSON.parse(localStorage.getItem(NOTIF_SETTINGS_KEY) || '{}'); } catch (e) { } |
| |
| 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; |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| 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'; |
| } |
| }); |
| |
| let _contextTarget = null; |
| |
| 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'); |
| |
| |
| 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'; |
| |
| 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'; |
| |
| |
| 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); |
| } |
| |
| 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(); |
| } |
| }; |
| |
| if (actionUnarchive) actionUnarchive.onclick = async () => { |
| ctxMenu.style.display = 'none'; |
| if (!_contextTarget) return; |
| |
| try { |
| let url; |
| if (_contextTarget.type === 'session') { |
| |
| url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/unarchive'; |
| |
| |
| const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id)); |
| if (s) s.archived = false; |
| renderSessionList(_allSessions); |
| |
| invalidateSessionCache(_contextTarget.id); |
| |
| } else { |
| |
| |
| |
| |
| |
| url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + |
| '/chats/' + _contextTarget.chatId + '/unarchive'; |
| |
| |
| 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 (_contextTarget.isSessionArchived) s.archived = false; |
| } |
| renderSessionList(_allSessions); |
| |
| |
| 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); |
| } |
| |
| invalidateSessionCache(_contextTarget.id); |
| } |
| } |
| |
| const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } }); |
| if (!res.ok) throw new Error('Failed'); |
| |
| showToast('Unarchived successfully'); |
| |
| loadSessions(); |
| } catch (e) { |
| showToast('Failed to unarchive', 'error'); |
| loadSessions(); |
| } |
| }; |
| |
| 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); |
| } |
| |
| loadSessions(); |
| loadAnalytics(); |
| } catch (e) { showToast('Delete failed', 'error'); } |
| }; |
| } |
| |
| |
| |
| |
| |
| 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; |
| 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'; |
| } |
| |
| 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; |
| |
| |
| 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'; |
| } |
| |
| |
| |
| |
| 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'; |
| } |
| } |
| } |
| |
| |
| 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'; |
| } |
| |
| |
| 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'; |
| } |
| |
| |
| 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'; |
| } |
| }); |
| |
| |
| 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'; |
| } |
| |
| |
| if (p.password_age_warning) { |
| const days = p.password_age_days || 0; |
| showToast(`Your password is ${days} days old. Consider changing it.`, 'error'); |
| } |
| |
| |
| const escNav = $('nav-settings-escalation'); |
| const escSec = $('settings-sec-escalation'); |
| if (p.role === 'superadmin') { |
| if (escNav) escNav.style.display = ''; |
| if (escSec) escSec.style.display = ''; |
| |
| |
| _loadEscalationSettings(); |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| |
| |
| |
| 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(); |
| |
| |
| if ($('inp-escalation-timeout')) { |
| $('inp-escalation-timeout').value = d.timeout_seconds || 20; |
| } |
| |
| |
| 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(''); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| 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(''); |
| } |
| |
| |
| 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; |
| |
| |
| 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); |
| }); |
| |
| |
| 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'; |
| |
| |
| 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'; |
| } |
| |
| |
| 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; |
| |
| |
| 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!`); |
| |
| |
| 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'); |
| } |
| |
| |
| 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> |
| |
| <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> |
|
|