| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- <!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=>({'&':'&','<':'<','>':'>','"':'"'}[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">▾</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|