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" >
< title > BizGaze Support — Staff Console</ 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 : .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 );}
. brand { font-weight : 700 ; color : #fff ; font-size : 1.05 rem ;} . brand span { color : var ( -- brand ); font-weight : 600 ;}
. who { color : #dbe4f5 ; font-size : .85 rem ; margin-right : .7 rem ;}
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 . ghost { background : transparent ; color : #dbe4f5 ; border : 1 px solid #46598c ; font-weight : 600 ;}
button . ghost : hover { background : var ( -- blue - 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 );}
button . mini . danger { color : var ( -- red );}
. 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 ;}
. pill . on { background : #ecfdf3 ; color : #15803d ;} . pill . off { background : #fee2e2 ; color : var ( -- red );}
. 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 ;}
. quick { display : flex ; align-items : center ; justify-content : space-between ; gap : 1 rem ; background : linear-gradient ( 120 deg , var ( -- blue ), var ( -- blue - d )); color : #fff ; border : none ;}
. quick h2 { color : #fff ; margin : 0 0 .25 rem ;}
. quick p { margin : 0 ; color : #cdd7ee ; font-size : .88 rem ;}
. quick a { background : var ( -- brand ); color : var ( -- ink ); text-decoration : none ; font-weight : 700 ; padding : .7 rem 1.3 rem ; border-radius : 10 px ; white-space : nowrap ;}
. quick a : hover { background : var ( -- brand - d );}
. 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 ;}
. pwwrap { position : relative ;}
. pwwrap input { padding-right : 2.6 rem ;}
. 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 }
. profile . pbtn { display : flex ; align-items : center ; gap : .4 rem ; background : rgba ( 255 , 255 , 255 , .14 ); color : #fff ; border : 1 px solid #46598c ; border-radius : 10 px ; padding : .45 rem .85 rem ; font-weight : 600 ; font-size : .88 rem ; cursor : pointer }
. profile . pbtn : hover { background : rgba ( 255 , 255 , 255 , .24 )}
. 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 : 190 px ; overflow : hidden ; z-index : 5000 ; display : none }
. profile . pmenu . open { display : block }
. 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 }
</ style >
</ 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 > Support</ span > < span style = "color:#8ea3cf;font-weight:500;font-size:.85rem" > · Console</ span ></ div ></ div >
< 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 ]));}
function profileHTML ( name ){ return '<div class="profile"><button class="pbtn" id="pbtn">' + pEsc ( name ) + ' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">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 = '/' ;};}
function makeBrandClickable (){ document . querySelectorAll ( '.brandrow,.wordmark' ). forEach ( el =>{ el . style . cursor = 'pointer' ; el . addEventListener ( 'click' ,()=>{ location . href = '/' ;});});}
makeBrandClickable ();
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 ----------
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">
<div class="tabs">
<button id="tabLogin" class="active">Sign in</button>
${ regOpen ? '<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>
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>
<p id="li_err" class="muted"></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>
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>
<p id="rg_err" class="muted"></p>
</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 );
}
}
async function doLogin () {
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 . reload ();
} catch ( e ) { li_err . textContent = e . message ; }
}
async function doRegister () {
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 . reload ();
} catch ( e ) { rg_err . textContent = e . message ; }
}
// ---------- Dashboard ----------
let ME = null ;
async function dashboard ( me ) {
ME = me ;
hdrRight . innerHTML = profileHTML (( me . name || me . email ) + ' · ' + me . role ); wireProfile ();
view ( `
<div class="card quick">
<div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
<a href="/connect">Open connect page →</a>
</div>
<div class="card" id="agentsCard">
<h2>Agents</h2>
2026-06-10 16:46:03 +05:30
<input id="agSearch" class="srch" placeholder="Search agents by name or email">
2026-06-09 16:47:43 +05:30
<table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
2026-06-10 16:46:03 +05:30
<div id="agPager" class="pager"></div>
2026-06-09 16:47:43 +05:30
<div class="row" style="margin-top:1rem;flex-wrap:wrap">
<input id="agEmail" placeholder="agent email" style="max-width:200px">
<input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
<input id="agPw" placeholder="temporary password" style="max-width:170px">
<select id="agRole" style="max-width:140px">
<option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
</select>
<button id="agAdd">Add agent</button>
</div>
<p id="agOut" class="muted"></p>
</div>
<div class="card">
<h2>Session report</h2>
<div class="filters">
<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">⬇ Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
</div>
2026-06-10 16:46:03 +05:30
<table id="report"><thead><tr><th>Date</th><th>Start time</th><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>
2026-06-09 16:47:43 +05:30
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>` );
if ( me . role !== 'admin' ) document . getElementById ( 'agentsCard' ). style . display = 'none' ;
else {
document . getElementById ( 'agAdd' ). onclick = addAgent ;
onEnter ([ 'agEmail' , 'agName' , 'agPw' ], addAgent );
await loadAgents ();
}
document . getElementById ( 'fApply' ). onclick = loadReport ;
document . getElementById ( 'fExcel' ). onclick = exportExcel ;
document . getElementById ( 'fPdf' ). onclick = exportPdf ;
await populateAgentFilter ();
await loadReport ();
}
async function addAgent () {
try {
const r = await api ( '/api/users' , { email : agEmail . value , name : agName . value , password : agPw . value , role : agRole . value });
agOut . textContent = `Agent ${ r . email } added. Share the email + temporary password — they sign in at /connect.` ;
agEmail . value = '' ; agName . value = '' ; agPw . value = '' ;
loadAgents (); populateAgentFilter ();
} catch ( e ) { agOut . textContent = e . message ; }
}
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>` ;
}
let AGENTS_ALL = [], agentPage = 1 , agentSearch = '' ;
function agentRowHTML ( u ){ return `
2026-06-09 16:47:43 +05:30
<tr>
<td> ${ esc ( u . email ) } </td><td> ${ esc ( u . name || '—' ) } </td><td> ${ esc ( u . role ) } </td>
<td><span class="pill ${ u . active === 0 ? 'off' : 'on' } "> ${ u . active === 0 ? 'deactivated' : 'active' } </span></td>
<td>
<button class="mini" onclick="resetPw(' ${ u . id } ',' ${ esc ( u . email ) } ')">Reset password</button>
<button class="mini" onclick="renameAgent(' ${ u . id } ',' ${ esc ( u . email ) } ')">Edit name</button>
${ u . id === ME . id ? '' : ( u . active === 0
? `<button class="mini" onclick="manage(' ${ u . id } ','activate')">Activate</button>`
: `<button class="mini danger" onclick="manage(' ${ u . id } ','deactivate')">Deactivate</button>` )
}
${ u . id === ME . id ? '' : `<button class="mini danger" onclick="delAgent(' ${ u . id } ',' ${ esc ( u . email ) } ')">Delete</button>` }
</td>
2026-06-10 16:46:03 +05:30
</tr>` ; }
async function loadAgents () {
AGENTS_ALL = await api ( '/api/users' , null , 'GET' );
agentPage = 1 ;
const s = document . getElementById ( 'agSearch' );
if ( s && ! s . _w ){ s . _w = 1 ; s . addEventListener ( 'input' , () => { agentSearch = s . value . trim (). toLowerCase (); agentPage = 1 ; renderAgents (); }); }
renderAgents ();
}
window . agentGo = ( p ) => { agentPage = p ; renderAgents (); };
function renderAgents (){
const all = agentSearch ? AGENTS_ALL . filter ( u => (( u . name || '' ) + ' ' + ( u . email || '' )). toLowerCase (). includes ( agentSearch )) : AGENTS_ALL ;
const pages = Math . max ( 1 , Math . ceil ( all . length / PER_PAGE ));
if ( agentPage > pages ) agentPage = pages ;
const slice = all . slice (( agentPage - 1 ) * PER_PAGE , ( agentPage - 1 ) * PER_PAGE + PER_PAGE );
document . querySelector ( '#agents tbody' ). innerHTML = slice . map ( agentRowHTML ). join ( '' ) || '<tr><td colspan=5 class="muted">No matching agents.</td></tr>' ;
document . getElementById ( 'agPager' ). innerHTML = pagerHTML ( agentPage , pages , all . length , 'agentGo' );
2026-06-09 16:47:43 +05:30
}
window . resetPw = async ( id , email ) => {
const pw = prompt ( `New password for ${ email } (min 8 characters):` );
if ( ! pw ) return ;
try { await api ( '/api/users/manage' , { id , action : 'reset-password' , password : pw }); agOut . textContent = `Password reset for ${ email } . They were signed out everywhere.` ; }
catch ( e ) { agOut . textContent = e . message ; }
};
window . renameAgent = async ( id , email ) => {
const name = prompt ( `Display name for ${ email } (as in the BizGaze app):` );
if ( ! name ) return ;
try { await api ( '/api/users/manage' , { id , action : 'rename' , name }); loadAgents (); }
catch ( e ) { agOut . textContent = e . message ; }
};
window . manage = async ( id , action ) => {
try { await api ( '/api/users/manage' , { id , action }); loadAgents (); }
catch ( e ) { agOut . textContent = e . message ; }
};
window . delAgent = async ( id , email ) => {
if ( ! confirm ( `Delete ${ email } ? This cannot be undone. (Tip: Deactivate keeps their history.)` )) return ;
try { await api ( '/api/users/manage' , { id , action : 'delete' }); loadAgents (); populateAgentFilter (); }
catch ( e ) { agOut . textContent = e . message ; }
};
// ---------- Session report ----------
async function populateAgentFilter () {
try {
const rows = await api ( '/api/users' , null , 'GET' );
const sel = document . getElementById ( 'fAgent' );
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; filter stays "All" */ }
}
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>
<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>
2026-06-10 16:46:03 +05:30
<td> ${ [
r . recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/ ${ esc ( r . recording ) } " download>⬇ 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>⬇ Text</a>` : ''
]. 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 ();
if ( fAgent . value ) q . set ( 'agent' , fAgent . 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 ;
renderReport ();
}
window . reportGo = ( p ) => { reportPage = p ; renderReport (); };
function renderReport (){
const all = reportSearch ? REPORT_ROWS . filter ( r => (( r . agent_name || '' ) + ' ' + ( r . agent_email || '' ) + ' ' + ( r . ticket || '' )). toLowerCase (). includes ( reportSearch )) : REPORT_ROWS ;
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 );
document . querySelector ( '#report tbody' ). innerHTML = slice . map ( reportRowHTML ). join ( '' ) || '<tr><td colspan=6 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 ) } ` : '' ;
2026-06-09 16:47:43 +05:30
}
function reportData () {
return REPORT_ROWS . 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' , 'Agent' , 'Ticket' , 'Time spent' ];
const csvCell = ( v ) => '"' + String ( v ). replace ( /"/g , '""' ) + '"' ;
const csv = '\ufeff' + [ head , ... rows . map ( r => [ r . date , r . start , r . agent , r . ticket , r . spent ])]
. map ( line => line . map ( csvCell ). join ( ',' )). join ( '\r\n' );
const a = document . createElement ( 'a' );
a . href = URL . createObjectURL ( new Blob ([ csv ], { type : 'text/csv;charset=utf-8' }));
a . download = 'session-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 agentSel = fAgent . value || 'All agents' ;
const w = window . open ( '' , '_blank' );
w . document . write ( '<html><head><title>Session 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 Support — Session report</h1>' +
'<div class="meta">Agent: ' + esc ( agentSel ) + ' · Period: ' + esc ( period ) + ' · Generated ' + new Date (). toLocaleString () + '</div>' +
'<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
rows . map ( r => '<tr><td>' + [ r . date , r . start , esc ( r . agent ), 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 ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ])); }
// ---------- Boot ----------
( async function () {
try { const me = await api ( '/api/me' , null , 'GET' ); dashboard ( me ); }
catch { authView (); }
})();
</ script >
</ body >
</ html >