351 lines
19 KiB
HTML
351 lines
19 KiB
HTML
|
|
<!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;}
|
||
|
|
.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>
|
||
|
|
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>
|
||
|
|
<input id="li_pw" placeholder="password" type="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>
|
||
|
|
<input id="rg_pw" placeholder="min 8 characters" type="password">
|
||
|
|
<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;
|
||
|
|
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>
|
||
|
|
<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 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></tr></thead><tbody></tbody></table>
|
||
|
|
<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; }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadAgents() {
|
||
|
|
const rows = await api('/api/users', null, 'GET');
|
||
|
|
document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
|
||
|
|
<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>`).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
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 = [];
|
||
|
|
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);
|
||
|
|
const rows = await api('/api/report?' + q.toString(), null, 'GET');
|
||
|
|
REPORT_ROWS = rows;
|
||
|
|
document.querySelector('#report tbody').innerHTML = rows.map((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>
|
||
|
|
</tr>`;
|
||
|
|
}).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
|
||
|
|
const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
||
|
|
repSummary.textContent = rows.length ? `${rows.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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||
|
|
|
||
|
|
// ---------- Boot ----------
|
||
|
|
(async function () {
|
||
|
|
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
||
|
|
catch { authView(); }
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|