Files
BizGaze_Remote/server/public/console.html
T

403 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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 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:.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{color:var(--brand);font-weight:600;}
.who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
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.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
button.ghost:hover{background:var(--blue-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);}
button.mini.danger{color:var(--red);}
.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);}
.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;}
.quick{display:flex;align-items:center;justify-content:space-between;gap:1rem;background:linear-gradient(120deg,var(--blue),var(--blue-d));color:#fff;border:none;}
.quick h2{color:#fff;margin:0 0 .25rem;}
.quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
.quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
.quick a:hover{background:var(--brand-d);}
.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;}
.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:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.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:190px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.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}
</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>
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 profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</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>
${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="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>
${pwField("rg_pw","min 8 characters")}
<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;
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);
}
}
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>
<input id="agSearch" class="srch" placeholder="Search agents by name or email">
<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>
<div id="agPager" class="pager"></div>
<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>
<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>
<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; }
}
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 `
<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>
</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');
}
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';
}
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>
<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>⬇ 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>
</tr>`;
}
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)}` : '';
}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ---------- Boot ----------
(async function () {
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
catch { authView(); }
})();
</script>
</body>
</html>