2026-06-09 16:47:43 +05:30
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2026-06-12 00:40:07 +05:30
< title > BizGaze Connect — Dashboard</ title >
2026-06-09 16:47:43 +05:30
< 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 : .75 rem 1.5 rem ; display : flex ; justify-content : space-between ; align-items : center ;}
. brandrow { display : flex ; align-items : center ; gap : .6 rem ;}
. logo { width : 30 px ; height : 30 px ; border-radius : 8 px ; background : var ( -- brand ); display : grid ; place-items : center ; font-weight : 800 ; color : var ( -- blue );}
2026-06-12 00:40:07 +05:30
. brand { font-weight : 700 ; color : #fff ; font-size : 1.05 rem ;} . brand span . y { color : var ( -- brand ); font-weight : 700 ;} . brand span . tag { color : #8ea3cf ; font-weight : 500 ; font-size : .85 rem ;}
2026-06-09 16:47:43 +05:30
main { max-width : 1020 px ; margin : 1.8 rem auto ; padding : 0 1 rem ;}
. card { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 14 px ; padding : 1.5 rem ; margin-bottom : 1.25 rem ; box-shadow : 0 6 px 18 px rgba ( 20 , 30 , 60 , .05 );}
h2 { font-size : 1 rem ; margin : 0 0 1 rem ; color : var ( -- blue );}
input , select { width : 100 % ; padding : .6 rem .7 rem ; border-radius : 10 px ; border : 2 px solid var ( -- line ); background : #fbfcfe ; color : var ( -- ink ); margin : .25 rem 0 ; font-size : .92 rem ;}
input : focus , select : focus { outline : none ; border-color : var ( -- brand );}
button { padding : .6 rem 1.1 rem ; background : var ( -- brand ); color : var ( -- ink ); border : none ; border-radius : 10 px ; font-weight : 700 ; cursor : pointer ; font-size : .92 rem ;}
button : hover { background : var ( -- brand - d );}
button . mini { padding : .32 rem .6 rem ; font-size : .76 rem ; font-weight : 600 ; background : #eef1f6 ; color : var ( -- blue ); border : 1 px solid var ( -- line );}
button . mini : hover { background : var ( -- blue - soft );}
. row { display : flex ; gap : .5 rem ; align-items : center ;}
. muted { color : var ( -- muted ); font-size : .85 rem ;}
table { width : 100 % ; border-collapse : collapse ; font-size : .88 rem ;}
th { color : var ( -- muted ); font-weight : 600 ; font-size : .76 rem ; text-transform : uppercase ; letter-spacing : .04 em ;}
th , td { text-align : left ; padding : .55 rem .5 rem ; border-bottom : 1 px solid var ( -- line );}
. pill { font-size : .74 rem ; font-weight : 600 ; padding : .15 rem .55 rem ; border-radius : 99 px ;}
2026-06-12 00:40:07 +05:30
. pill . on { background : #ecfdf3 ; color : #15803d ;}
2026-06-23 16:15:29 +05:30
. pill . off { background : #fee2e2 ; color : var ( -- red );}
. reveal { margin-top : 1 rem ; background : #f1f7ec ; border : 1 px solid #cfe8bf ; border-radius : 10 px ; padding : .8 rem 1 rem ;}
. reveal code { flex : 1 ; word-break : break-all ; background : #fff ; border : 1 px solid var ( -- line ); border-radius : 8 px ; padding : .5 rem .6 rem ; font-size : .85 rem ;}
. chk { display : flex ; align-items : center ; gap : .4 rem ; font-size : .85 rem ;}
. chk input { width : 16 px ; height : 16 px ; margin : 0 ; accent-color : var ( -- blue );}
2026-06-09 16:47:43 +05:30
. hidden { display : none ;}
. tabs { display : flex ; gap : .5 rem ; margin-bottom : 1.2 rem ;}
. tabs button { background : #eef1f6 ; color : var ( -- muted ); font-weight : 600 ;}
. tabs button . active { background : var ( -- blue ); color : #fff ;}
. lbl { display : block ; font-size : .74 rem ; color : var ( -- muted ); text-transform : uppercase ; letter-spacing : .06 em ; margin : .7 rem 0 .15 rem ;}
. filters { display : flex ; gap : .6 rem ; align-items : flex-end ; flex-wrap : wrap ; margin-bottom : 1 rem ;}
. filters . f { flex : 1 ; min-width : 140 px ;}
. filters . lbl { margin : .1 rem 0 .15 rem ;}
2026-06-10 16:46:03 +05:30
. srch { max-width : 320 px ; margin : .2 rem 0 .9 rem ;}
. pager { display : flex ; gap : .5 rem ; align-items : center ; justify-content : flex-end ; margin-top : .8 rem ; font-size : .82 rem ; color : var ( -- muted );}
. pager button { padding : .32 rem .7 rem ; font-size : .8 rem ; font-weight : 600 ; background : #eef1f6 ; color : var ( -- blue ); border : 1 px solid var ( -- line );}
. pager button : hover : not ( : disabled ) { background : var ( -- blue - soft );}
. pager button : disabled { opacity : .4 ; cursor : default ;}
2026-06-12 00:40:07 +05:30
. stats { display : flex ; gap : 1 rem ; flex-wrap : wrap ; margin-bottom : 1.25 rem ;}
. stat { flex : 1 ; min-width : 150 px ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 14 px ; padding : 1.1 rem 1.3 rem ; box-shadow : 0 6 px 18 px rgba ( 20 , 30 , 60 , .05 );}
. stat . v { font-size : 1.7 rem ; font-weight : 800 ; color : var ( -- blue ); line-height : 1.1 ;}
. stat . k { font-size : .78 rem ; color : var ( -- muted ); text-transform : uppercase ; letter-spacing : .05 em ; margin-top : .2 rem ;}
. formerr { color : var ( -- red ); font-weight : 600 ; font-size : .88 rem ; margin : .7 rem 0 0 ; min-height : 1 em ;}
. formerr . show { display : flex ; align-items : center ; gap : .5 rem ; background : #fee2e2 ; border : 1 px solid #fca5a5 ; border-radius : 9 px ; padding : .6 rem .75 rem ; animation : errShake .35 s ;}
. formerr . show :: before { content : "⚠" ; font-size : 1 rem ;}
@ keyframes errShake { 0 %, 100 % { transform : translateX ( 0 )} 20 %, 60 % { transform : translateX ( -5 px )} 40 %, 80 % { transform : translateX ( 5 px )}}
. pwwrap { position : relative ;} . pwwrap input { padding-right : 2.6 rem ;}
2026-06-10 16:46:03 +05:30
. eye { position : absolute ; right : .35 rem ; top : 50 % ; transform : translateY ( -50 % ); background : none ; border : none ; padding : .3 rem ; width : auto ; color : var ( -- muted ); display : inline-flex ; align-items : center ; cursor : pointer ;}
. eye : hover { background : none ; color : var ( -- blue );}
2026-06-09 16:47:43 +05:30
. profile { position : relative }
2026-06-12 00:40:07 +05:30
. profile . pbtn { display : flex ; align-items : center ; gap : .5 rem ; background : rgba ( 255 , 255 , 255 , .14 ); color : #fff ; border : 1 px solid #46598c ; border-radius : 10 px ; padding : .4 rem .85 rem .4 rem .5 rem ; font-weight : 600 ; font-size : .88 rem ; cursor : pointer }
2026-06-09 16:47:43 +05:30
. profile . pbtn : hover { background : rgba ( 255 , 255 , 255 , .24 )}
2026-06-12 00:40:07 +05:30
. profile . pbtn . pav { width : 28 px ; height : 28 px ; border-radius : 50 % ; background : var ( -- brand ); color : var ( -- blue ); display : grid ; place-items : center ; font-weight : 800 ; font-size : .78 rem }
. profile . pmenu { position : absolute ; right : 0 ; top : calc ( 100 % + 6 px ); background : #fff ; border : 1 px solid #e6e9ef ; border-radius : 10 px ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .18 ); min-width : 210 px ; overflow : hidden ; z-index : 5000 ; display : none }
2026-06-09 16:47:43 +05:30
. profile . pmenu . open { display : block }
2026-06-12 00:40:07 +05:30
. profile . pmenu . phead { padding : .7 rem .9 rem ; border-bottom : 1 px solid #eef1f6 }
. profile . pmenu . phead . n { font-weight : 700 ; font-size : .9 rem }
. profile . pmenu . phead . e { color : var ( -- muted ); font-size : .78 rem }
2026-06-09 16:47:43 +05:30
. profile . pmenu a { display : block ; padding : .6 rem .9 rem ; color : #1f2430 ; text-decoration : none ; font-size : .9 rem ; cursor : pointer }
. profile . pmenu a : hover { background : #f1f5f9 }
. profile . pmenu a . danger { color : #b91c1c ; border-top : 1 px solid #eef1f6 }
2026-06-23 16:15:29 +05:30
. ic { display : inline-block ; vertical-align : middle }
2026-06-09 16:47:43 +05:30
</ style >
2026-06-23 18:47:24 +05:30
< script src = "/icons.js?v=3" ></ script >
2026-06-09 16:47:43 +05:30
</ head >
< body >
< header >
2026-06-12 00:40:07 +05:30
< 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 >
2026-06-09 16:47:43 +05:30
< div class = "row" id = "hdrRight" ></ div >
</ header >
< main id = "app" ></ main >
< script >
2026-06-10 16:46:03 +05:30
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 ;};});}
2026-06-09 16:47:43 +05:30
function pEsc ( s ){ return String ( s == null ? '' : s ). replace ( /[&<>"]/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ]));}
2026-06-12 00:40:07 +05:30
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">▾</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>' ;
}
2026-06-09 16:47:43 +05:30
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 ; }
2026-06-12 00:40:07 +05:30
// ---------- Auth (login lives here; on success → home) ----------
2026-06-09 16:47:43 +05:30
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">
2026-06-12 00:40:07 +05:30
${ regOpen ? `<div class="tabs">
2026-06-09 16:47:43 +05:30
<button id="tabLogin" class="active">Sign in</button>
2026-06-12 00:40:07 +05:30
<button id="tabReg">Register team</button>
</div>` : '' }
2026-06-09 16:47:43 +05:30
<div id="loginForm">
<span class="lbl">Email</span>
<input id="li_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
2026-06-10 16:46:03 +05:30
${ pwField ( "li_pw" , "password" ) }
2026-06-09 16:47:43 +05:30
<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>
2026-06-12 00:40:07 +05:30
<p id="li_err" class="formerr"></p>
2026-06-09 16:47:43 +05:30
</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>
2026-06-10 16:46:03 +05:30
${ pwField ( "rg_pw" , "min 8 characters" ) }
2026-06-09 16:47:43 +05:30
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
2026-06-12 00:40:07 +05:30
<p id="rg_err" class="formerr"></p>
2026-06-09 16:47:43 +05:30
</div>` : '' }
</div>` );
document . getElementById ( 'li_btn' ). onclick = doLogin ;
2026-06-10 16:46:03 +05:30
wireEyes ();
2026-06-09 16:47:43 +05:30
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 );
}
}
2026-06-12 00:40:07 +05:30
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' ); }
2026-06-09 16:47:43 +05:30
async function doLogin () {
2026-06-12 00:40:07 +05:30
clearErr ( 'li_err' );
2026-06-09 16:47:43 +05:30
try {
const rem = document . getElementById ( 'li_remember' );
await api ( '/api/login' , { email : li_email . value , password : li_pw . value , remember : rem ? rem . checked : false });
2026-06-12 00:40:07 +05:30
location . href = '/home' ;
} catch ( e ) {
showErr ( 'li_err' , /invalid credentials/i . test ( e . message ) ? 'Incorrect email or password. Please try again.' : e . message );
}
2026-06-09 16:47:43 +05:30
}
async function doRegister () {
2026-06-12 00:40:07 +05:30
clearErr ( 'rg_err' );
2026-06-09 16:47:43 +05:30
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 });
2026-06-12 00:40:07 +05:30
location . href = '/home' ;
} catch ( e ) { showErr ( 'rg_err' , e . message ); }
2026-06-09 16:47:43 +05:30
}
// ---------- Dashboard ----------
2026-06-12 00:40:07 +05:30
let ME = null , IS_ADMIN = false ;
2026-06-09 16:47:43 +05:30
async function dashboard ( me ) {
2026-06-12 00:40:07 +05:30
ME = me ; IS_ADMIN = ( me . role === 'admin' );
hdrRight . innerHTML = profileHTML ( me ); wireProfile ();
2026-06-09 16:47:43 +05:30
view ( `
2026-06-12 00:40:07 +05:30
<div class="stats" id="stats"></div>
2026-06-09 16:47:43 +05:30
<div class="card">
2026-06-12 00:40:07 +05:30
<h2> ${ IS_ADMIN ? 'Connection report — all agents' : 'My connection report' } </h2>
2026-06-09 16:47:43 +05:30
<div class="filters">
2026-06-12 00:40:07 +05:30
${ IS_ADMIN ? '<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>' : '' }
2026-06-09 16:47:43 +05:30
<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>
2026-06-23 16:15:29 +05:30
<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>
2026-06-09 16:47:43 +05:30
</div>
2026-06-12 00:40:07 +05:30
${ 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>
2026-06-10 16:46:03 +05:30
<div id="repPager" class="pager"></div>
2026-06-09 16:47:43 +05:30
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
2026-06-23 16:15:29 +05:30
</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>` : '' } ` );
2026-06-09 16:47:43 +05:30
document . getElementById ( 'fApply' ). onclick = loadReport ;
document . getElementById ( 'fExcel' ). onclick = exportExcel ;
document . getElementById ( 'fPdf' ). onclick = exportPdf ;
2026-06-12 00:40:07 +05:30
if ( IS_ADMIN ) await populateAgentFilter ();
2026-06-09 16:47:43 +05:30
await loadReport ();
2026-06-23 16:15:29 +05:30
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>' ; }
2026-06-09 16:47:43 +05:30
}
2026-06-23 16:15:29 +05:30
window . deleteHook = async ( id )=>{ if ( ! confirm ( 'Delete this webhook?' )) return ; try { await api ( '/api/webhooks/delete' ,{ id }, 'POST' ); loadHooks (); } catch ( e ){} };
2026-06-09 16:47:43 +05:30
2026-06-10 16:46:03 +05:30
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>` ;
}
2026-06-09 16:47:43 +05:30
async function populateAgentFilter () {
try {
const rows = await api ( '/api/users' , null , 'GET' );
2026-06-12 00:40:07 +05:30
const sel = document . getElementById ( 'fAgent' ); if ( ! sel ) return ;
2026-06-09 16:47:43 +05:30
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 ;
2026-06-12 00:40:07 +05:30
} catch { /* non-admins cannot list agents */ }
2026-06-09 16:47:43 +05:30
}
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' ;
}
2026-06-10 16:46:03 +05:30
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>
2026-06-09 16:47:43 +05:30
<td> ${ d . toLocaleDateString () } </td>
<td class="muted"> ${ d . toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' } )}</td>
2026-06-12 00:40:07 +05:30
${ IS_ADMIN ? `<td> ${ esc ( r . agent_name || r . agent_email || '—' ) } </td>` : '' }
2026-06-09 16:47:43 +05:30
<td> ${ esc ( r . ticket || 'Direct session' ) } </td>
<td> ${ r . ended_at ? fmtDuration ( dur ) : '<span class="pill on">in progress</span>' } </td>
2026-06-10 16:46:03 +05:30
<td> ${ [
2026-06-23 16:15:29 +05:30
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>` : ''
2026-06-10 16:46:03 +05:30
]. join ( '' ) || '<span class="muted">—</span>' } </td>
2026-06-09 16:47:43 +05:30
</tr>` ;
2026-06-10 16:46:03 +05:30
}
async function loadReport () {
const q = new URLSearchParams ();
2026-06-12 00:40:07 +05:30
const fa = document . getElementById ( 'fAgent' );
if ( fa && fa . value ) q . set ( 'agent' , fa . value );
2026-06-10 16:46:03 +05:30
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 ;
2026-06-12 00:40:07 +05:30
const s = document . getElementById ( 'repSearch' );
if ( s && ! s . _w ){ s . _w = 1 ; s . addEventListener ( 'input' , () => { reportSearch = s . value . trim (). toLowerCase (); reportPage = 1 ; renderReport (); }); }
renderStats ();
2026-06-10 16:46:03 +05:30
renderReport ();
}
window . reportGo = ( p ) => { reportPage = p ; renderReport (); };
2026-06-12 00:40:07 +05:30
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 ( '' );
}
2026-06-10 16:46:03 +05:30
function renderReport (){
2026-06-12 00:40:07 +05:30
const all = filteredRows ();
2026-06-10 16:46:03 +05:30
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 );
2026-06-12 00:40:07 +05:30
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>` ;
2026-06-10 16:46:03 +05:30
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 ) } ` : '' ;
2026-06-09 16:47:43 +05:30
}
function reportData () {
2026-06-12 00:40:07 +05:30
return filteredRows (). map (( r ) => {
2026-06-09 16:47:43 +05:30
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 ; }
2026-06-12 00:40:07 +05:30
const head = [ 'Date' , 'Start time' ]. concat ( IS_ADMIN ? [ 'Agent' ] : []). concat ([ 'Ticket' , 'Time spent' ]);
2026-06-09 16:47:43 +05:30
const csvCell = ( v ) => '"' + String ( v ). replace ( /"/g , '""' ) + '"' ;
2026-06-12 00:40:07 +05:30
const out = ' ' + [ head , ... rows . map ( r => [ r . date , r . start ]. concat ( IS_ADMIN ? [ r . agent ] : []). concat ([ r . ticket , r . spent ]))]
2026-06-09 16:47:43 +05:30
. map ( line => line . map ( csvCell ). join ( ',' )). join ( '\r\n' );
const a = document . createElement ( 'a' );
2026-06-12 00:40:07 +05:30
a . href = URL . createObjectURL ( new Blob ([ out ], { type : 'text/csv;charset=utf-8' }));
a . download = 'connection-report.csv' ;
2026-06-09 16:47:43 +05:30
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' );
2026-06-12 00:40:07 +05:30
const fa = document . getElementById ( 'fAgent' );
const agentSel = IS_ADMIN ? ( fa && fa . value || 'All agents' ) : ( ME . name || ME . email );
2026-06-09 16:47:43 +05:30
const w = window . open ( '' , '_blank' );
2026-06-12 00:40:07 +05:30
const headCells = [ 'Date' , 'Start time' ]. concat ( IS_ADMIN ? [ 'Agent' ] : []). concat ([ 'Ticket' , 'Time spent' ]);
w . document . write ( '<html><head><title>Connection report</title><style>' +
2026-06-09 16:47:43 +05:30
'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>' +
2026-06-12 00:40:07 +05:30
'<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 ( '' ) +
2026-06-09 16:47:43 +05:30
'</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 ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ])); }
// ---------- Boot ----------
2026-06-12 00:40:07 +05:30
// Login lives on /home — send logged-out visitors there.
2026-06-09 16:47:43 +05:30
( async function () {
try { const me = await api ( '/api/me' , null , 'GET' ); dashboard ( me ); }
2026-06-12 00:40:07 +05:30
catch { location . href = '/home' ; }
2026-06-09 16:47:43 +05:30
})();
</ script >
</ body >
</ html >