Files
BizGaze_Remote/server/public/dashboard.html
T
Sravan d50d4bde47 fix(icons): proper end-call (hang-up) glyph + cache-bust icons.js (v3)
- callEnd is now a rotated-handset hang-up icon (was a phone-off placeholder).
- All pages reference /icons.js?v=3 so browsers/proxies fetch the corrected
  file instead of a stale cached copy (fixes 'old end icon' + icons not
  appearing until a re-render when an old/404 icons.js was cached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:47:24 +05:30

444 lines
28 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Dashboard</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
input:focus,select:focus{outline:none;border-color:var(--brand);}
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
button:hover{background:var(--brand-d);}
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
button.mini:hover{background:var(--blue-soft);}
.row{display:flex;gap:.5rem;align-items:center;}
.muted{color:var(--muted);font-size:.85rem;}
table{width:100%;border-collapse:collapse;font-size:.88rem;}
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;}
.pill.off{background:#fee2e2;color:var(--red);}
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
.hidden{display:none;}
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
.tabs button.active{background:var(--blue);color:#fff;}
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
.filters .f{flex:1;min-width:140px;}
.filters .lbl{margin:.1rem 0 .15rem;}
.srch{max-width:320px;margin:.2rem 0 .9rem;}
.pager{display:flex;gap:.5rem;align-items:center;justify-content:flex-end;margin-top:.8rem;font-size:.82rem;color:var(--muted);}
.pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
.pager button:hover:not(:disabled){background:var(--blue-soft);}
.pager button:disabled{opacity:.4;cursor:default;}
.stats{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;}
.stat{flex:1;min-width:150px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.3rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
.stat .v{font-size:1.7rem;font-weight:800;color:var(--blue);line-height:1.1;}
.stat .k{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:.2rem;}
.formerr{color:var(--red);font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
.formerr.show::before{content:"⚠";font-size:1rem;}
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
.pwwrap{position:relative;} .pwwrap input{padding-right:2.6rem;}
.eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
.eye:hover{background:none;color:var(--blue);}
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
.ic{display:inline-block;vertical-align:middle}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div>
</header>
<main id="app"></main>
<script>
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function profileHTML(u){
const display=u.name||u.email;
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(display))+'</span>'
+ pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a href="/home">Home</a>'
+ '<a class="danger" id="plogout">Logout</a>'
+ '</div></div>';
}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
const app = document.getElementById('app');
const hdrRight = document.getElementById('hdrRight');
async function api(path, body, method = 'POST') {
const opt = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opt.body = JSON.stringify(body);
const r = await fetch(path, opt);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'request failed');
return data;
}
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
function view(html) { app.innerHTML = html; }
// ---------- Auth (login lives here; on success → home) ----------
async function authView() {
hdrRight.innerHTML = '';
let regOpen = false;
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
view(`
<div class="card" style="max-width:420px;margin:3rem auto">
${regOpen ? `<div class="tabs">
<button id="tabLogin" class="active">Sign in</button>
<button id="tabReg">Register team</button>
</div>` : ''}
<div id="loginForm">
<span class="lbl">Email</span>
<input id="li_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
${pwField("li_pw","password")}
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
<p id="li_err" class="formerr"></p>
</div>
${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
${pwField("rg_pw","min 8 characters")}
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
<p id="rg_err" class="formerr"></p>
</div>` : ''}
</div>`);
document.getElementById('li_btn').onclick = doLogin;
wireEyes();
onEnter(['li_email','li_pw'], doLogin);
if (regOpen) {
document.getElementById('tabLogin').onclick = () => toggle(true);
document.getElementById('tabReg').onclick = () => toggle(false);
document.getElementById('rg_btn').onclick = doRegister;
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
}
function toggle(login) {
document.getElementById('loginForm').classList.toggle('hidden', !login);
document.getElementById('regForm').classList.toggle('hidden', login);
document.getElementById('tabLogin').classList.toggle('active', login);
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
}
}
function showErr(id, msg) { const el = document.getElementById(id); el.textContent = msg; el.classList.add('show'); }
function clearErr(id) { const el = document.getElementById(id); el.textContent = ''; el.classList.remove('show'); }
async function doLogin() {
clearErr('li_err');
try {
const rem = document.getElementById('li_remember');
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
location.href = '/home';
} catch (e) {
showErr('li_err', /invalid credentials/i.test(e.message) ? 'Incorrect email or password. Please try again.' : e.message);
}
}
async function doRegister() {
clearErr('rg_err');
try {
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
location.href = '/home';
} catch (e) { showErr('rg_err', e.message); }
}
// ---------- Dashboard ----------
let ME = null, IS_ADMIN = false;
async function dashboard(me) {
ME = me; IS_ADMIN = (me.role === 'admin');
hdrRight.innerHTML = profileHTML(me); wireProfile();
view(`
<div class="stats" id="stats"></div>
<div class="card">
<h2>${IS_ADMIN ? 'Connection report — all agents' : 'My connection report'}</h2>
<div class="filters">
${IS_ADMIN ? '<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>' : ''}
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
</div>
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
<div id="repPager" class="pager"></div>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>
${IS_ADMIN ? `
<div class="card" id="keysCard">
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
<button id="kAdd">Generate key</button>
</div>
<div id="kOut"></div>
</div>
<div class="card" id="hooksCard">
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
<button id="hAdd">Add webhook</button>
</div>
<div id="hOut"></div>
</div>` : ''}`);
document.getElementById('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf;
if (IS_ADMIN) await populateAgentFilter();
await loadReport();
if (IS_ADMIN) {
document.getElementById('kAdd').onclick = createKey;
document.getElementById('hAdd').onclick = createHook;
await loadKeys();
await loadHooks();
}
}
// ---------- Integrations: API keys + webhooks (admin) ----------
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
function revealBox(label, value, note){
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
+ '<button class="mini" id="copyReveal">Copy</button></div>'
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
}
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
async function loadKeys(){
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
<tr style="${k.revoked?'opacity:.5':''}">
<td>${esc(k.name||'—')}</td>
<td class="muted">${esc(k.scopes||'')}</td>
<td>${fmtTs(k.created_at)}</td>
<td>${fmtTs(k.last_used_at)}</td>
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
}
async function createKey(){
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
try{
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
document.getElementById('kName').value='';
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
wireCopy(); loadKeys();
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
async function loadHooks(){
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
<tr style="${h.active?'':'opacity:.5'}">
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
<td class="muted">${esc(h.events||'')}</td>
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
}
async function createHook(){
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
const url=document.getElementById('hUrl').value.trim();
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
try{
const r = await api('/api/webhooks', { url, events }, 'POST');
document.getElementById('hUrl').value='';
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
wireCopy(); loadHooks();
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
const PER_PAGE = 5;
function pagerHTML(page, pages, total, fn){
if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
return `<button ${page<=1?'disabled':''} onclick="${fn}(${page-1})"> Prev</button>`
+ `<span>Page ${page} of ${pages} · ${total} total</span>`
+ `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next </button>`;
}
async function populateAgentFilter() {
try {
const rows = await api('/api/users', null, 'GET');
const sel = document.getElementById('fAgent'); if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
sel.value = cur;
} catch { /* non-admins cannot list agents */ }
}
function fmtDuration(ms) {
if (ms == null) return '—';
const s = Math.round(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60), r = s % 60;
if (m < 60) return m + 'm ' + r + 's';
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
}
let REPORT_ROWS = [], reportPage = 1, reportSearch = '';
function reportRowHTML(r){
const d = new Date(r.started_at);
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
return `<tr>
<td>${d.toLocaleDateString()}</td>
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
${IS_ADMIN ? `<td>${esc(r.agent_name || r.agent_email || '—')}</td>` : ''}
<td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
<td>${[
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
].join('') || '<span class="muted">—</span>'}</td>
</tr>`;
}
async function loadReport() {
const q = new URLSearchParams();
const fa = document.getElementById('fAgent');
if (fa && fa.value) q.set('agent', fa.value);
if (fFrom.value) q.set('from', fFrom.value);
if (fTo.value) q.set('to', fTo.value);
REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
reportPage = 1;
const s = document.getElementById('repSearch');
if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { reportSearch = s.value.trim().toLowerCase(); reportPage = 1; renderReport(); }); }
renderStats();
renderReport();
}
window.reportGo = (p) => { reportPage = p; renderReport(); };
function filteredRows(){
return reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
}
function renderStats(){
const el = document.getElementById('stats'); if (!el) return;
const rows = REPORT_ROWS;
const total = rows.length;
const totalMs = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
const recs = rows.filter(r => r.recording).length;
const cards = [
{ v: total, k: IS_ADMIN ? 'Total sessions' : 'My sessions' },
{ v: fmtDuration(totalMs), k: 'Time spent' },
{ v: recs, k: 'Recorded' },
];
el.innerHTML = cards.map(c => `<div class="stat"><div class="v">${esc(String(c.v))}</div><div class="k">${esc(c.k)}</div></div>`).join('');
}
function renderReport(){
const all = filteredRows();
const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
if (reportPage > pages) reportPage = pages;
const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
const cols = IS_ADMIN ? 6 : 5;
document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || `<tr><td colspan=${cols} class="muted">No sessions match.</td></tr>`;
document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
}
function reportData() {
return filteredRows().map((r) => {
const d = new Date(r.started_at);
return {
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
};
});
}
function exportExcel() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const head = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
const out = '' + [head, ...rows.map(r => [r.date, r.start].concat(IS_ADMIN ? [r.agent] : []).concat([r.ticket, r.spent]))]
.map(line => line.map(csvCell).join(',')).join('\r\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([out], { type: 'text/csv;charset=utf-8' }));
a.download = 'connection-report.csv';
a.click(); URL.revokeObjectURL(a.href);
}
function exportPdf() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
const fa = document.getElementById('fAgent');
const agentSel = IS_ADMIN ? (fa && fa.value || 'All agents') : (ME.name || ME.email);
const w = window.open('', '_blank');
const headCells = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
w.document.write('<html><head><title>Connection report</title><style>' +
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
'table{width:100%;border-collapse:collapse;font-size:12px}' +
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' +
'<h1>BizGaze Connect — Connection report</h1>' +
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
'<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
rows.map(r => '<tr><td>' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('</td><td>') + '</td></tr>').join('') +
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
w.document.close();
w.onload = () => { w.print(); };
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ---------- Boot ----------
// Login lives on /home — send logged-out visitors there.
(async function () {
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
catch { location.href = '/home'; }
})();
</script>
</body>
</html>