Files
BizGaze_Remote/server/public/dashboard.html
T
Sravan ba8bfc3f46 feat: BizGaze Connect home, BizGaze login, modular backend, /api/v1
User-facing
- New post-login home (/home): chat rail + Share/Connect (embedded) + Meeting; login lives here when logged out
- Landing: "Log in with BizGaze" + no-login screen share
- Console replaced by a role-scoped Dashboard (/dashboard): admins see all team sessions, others see only their own; stats + CSV/PDF export
- Recordings saved as MP4 (H.264/AAC) with WebM fallback; old .webm still downloadable
- Fix: duplicate "Sign in" on the login card

Auth / integration
- BizGaze as identity provider: /api/login validates against BIZGAZE_LOGIN_URL (env-gated) and provisions a local user
- Phase 2 start: /api/v1 alias for all /api routes; Authorization: Bearer accepted across HTTP + WS; login returns a token (for native desktop/mobile clients)

Backend refactor (Phase 1, behavior-preserving)
- Split server.js into config/lib/session/presence/routes/static/signaling + repos (data-access) + bizgaze (service)
- All SQL behind repos.js, tenant-scoped (tenantId == team_id for now)
- e2e updated to current flow (21/21 pass before and after)

Docs: ARCHITECTURE.md (target architecture + phased plan), CLAUDE.md repo layout, .env.example BIZGAZE_LOGIN_URL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:40:07 +05:30

349 строки
21 KiB
HTML
Исходник Ответственный История

Этот файл содержит невидимые символы Юникода
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;}
.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}
</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 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">⬇ Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ 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>`);
document.getElementById('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf;
if (IS_ADMIN) await populateAgentFilter();
await loadReport();
}
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>⬇ 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();
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>