Brak opisu
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

console.html 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>BizGaze Support — Staff Console</title>
  7. <style>
  8. :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; }
  9. *{box-sizing:border-box;}
  10. body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
  11. header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
  12. .brandrow{display:flex;align-items:center;gap:.6rem;}
  13. .logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
  14. .brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
  15. .who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
  16. main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
  17. .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);}
  18. h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
  19. 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;}
  20. input:focus,select:focus{outline:none;border-color:var(--brand);}
  21. button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
  22. button:hover{background:var(--brand-d);}
  23. button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
  24. button.ghost:hover{background:var(--blue-d);}
  25. button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
  26. button.mini:hover{background:var(--blue-soft);}
  27. button.mini.danger{color:var(--red);}
  28. .row{display:flex;gap:.5rem;align-items:center;}
  29. .muted{color:var(--muted);font-size:.85rem;}
  30. table{width:100%;border-collapse:collapse;font-size:.88rem;}
  31. th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
  32. th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
  33. .pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
  34. .pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
  35. .hidden{display:none;}
  36. .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
  37. .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
  38. .tabs button.active{background:var(--blue);color:#fff;}
  39. .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;}
  40. .quick h2{color:#fff;margin:0 0 .25rem;}
  41. .quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
  42. .quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
  43. .quick a:hover{background:var(--brand-d);}
  44. .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
  45. .filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
  46. .filters .f{flex:1;min-width:140px;}
  47. .filters .lbl{margin:.1rem 0 .15rem;}
  48. .srch{max-width:320px;margin:.2rem 0 .9rem;}
  49. .pager{display:flex;gap:.5rem;align-items:center;justify-content:flex-end;margin-top:.8rem;font-size:.82rem;color:var(--muted);}
  50. .pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
  51. .pager button:hover:not(:disabled){background:var(--blue-soft);}
  52. .pager button:disabled{opacity:.4;cursor:default;}
  53. .pwwrap{position:relative;}
  54. .pwwrap input{padding-right:2.6rem;}
  55. .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;}
  56. .eye:hover{background:none;color:var(--blue);}
  57. .profile{position:relative}
  58. .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}
  59. .profile .pbtn:hover{background:rgba(255,255,255,.24)}
  60. .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}
  61. .profile .pmenu.open{display:block}
  62. .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
  63. .profile .pmenu a:hover{background:#f1f5f9}
  64. .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
  65. </style>
  66. </head>
  67. <body>
  68. <header>
  69. <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>
  70. <div class="row" id="hdrRight"></div>
  71. </header>
  72. <main id="app"></main>
  73. <script>
  74. 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>';
  75. 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>';
  76. 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>';}
  77. 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;};});}
  78. function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
  79. 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>';}
  80. 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='/';};}
  81. function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
  82. makeBrandClickable();
  83. const app = document.getElementById('app');
  84. const hdrRight = document.getElementById('hdrRight');
  85. async function api(path, body, method = 'POST') {
  86. const opt = { method, headers: { 'Content-Type': 'application/json' } };
  87. if (body) opt.body = JSON.stringify(body);
  88. const r = await fetch(path, opt);
  89. const data = await r.json().catch(() => ({}));
  90. if (!r.ok) throw new Error(data.error || 'request failed');
  91. return data;
  92. }
  93. 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(); } }); }); }
  94. function view(html) { app.innerHTML = html; }
  95. // ---------- Auth ----------
  96. async function authView() {
  97. hdrRight.innerHTML = '';
  98. let regOpen = false;
  99. try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
  100. view(`
  101. <div class="card" style="max-width:420px;margin:3rem auto">
  102. <div class="tabs">
  103. <button id="tabLogin" class="active">Sign in</button>
  104. ${regOpen ? '<button id="tabReg">Register team</button>' : ''}
  105. </div>
  106. <div id="loginForm">
  107. <span class="lbl">Email</span>
  108. <input id="li_email" placeholder="you@bizgaze.com" type="email">
  109. <span class="lbl">Password</span>
  110. ${pwField("li_pw","password")}
  111. <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>
  112. <button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
  113. <p id="li_err" class="muted"></p>
  114. </div>
  115. ${regOpen ? `<div id="regForm" class="hidden">
  116. <span class="lbl">Team name</span>
  117. <input id="rg_team" placeholder="e.g. BizGaze Support">
  118. <span class="lbl">Email</span>
  119. <input id="rg_email" placeholder="you@bizgaze.com" type="email">
  120. <span class="lbl">Password</span>
  121. ${pwField("rg_pw","min 8 characters")}
  122. <button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
  123. <p id="rg_err" class="muted"></p>
  124. </div>` : ''}
  125. </div>`);
  126. document.getElementById('li_btn').onclick = doLogin;
  127. wireEyes();
  128. onEnter(['li_email','li_pw'], doLogin);
  129. if (regOpen) {
  130. document.getElementById('tabLogin').onclick = () => toggle(true);
  131. document.getElementById('tabReg').onclick = () => toggle(false);
  132. document.getElementById('rg_btn').onclick = doRegister;
  133. onEnter(['rg_team','rg_email','rg_pw'], doRegister);
  134. }
  135. function toggle(login) {
  136. document.getElementById('loginForm').classList.toggle('hidden', !login);
  137. document.getElementById('regForm').classList.toggle('hidden', login);
  138. document.getElementById('tabLogin').classList.toggle('active', login);
  139. const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
  140. }
  141. }
  142. async function doLogin() {
  143. try {
  144. const rem = document.getElementById('li_remember');
  145. await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
  146. location.reload();
  147. } catch (e) { li_err.textContent = e.message; }
  148. }
  149. async function doRegister() {
  150. try {
  151. await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
  152. await api('/api/login', { email: rg_email.value, password: rg_pw.value });
  153. location.reload();
  154. } catch (e) { rg_err.textContent = e.message; }
  155. }
  156. // ---------- Dashboard ----------
  157. let ME = null;
  158. async function dashboard(me) {
  159. ME = me;
  160. hdrRight.innerHTML = profileHTML((me.name||me.email)+' · '+me.role); wireProfile();
  161. view(`
  162. <div class="card quick">
  163. <div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
  164. <a href="/connect">Open connect page →</a>
  165. </div>
  166. <div class="card" id="agentsCard">
  167. <h2>Agents</h2>
  168. <input id="agSearch" class="srch" placeholder="Search agents by name or email">
  169. <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>
  170. <div id="agPager" class="pager"></div>
  171. <div class="row" style="margin-top:1rem;flex-wrap:wrap">
  172. <input id="agEmail" placeholder="agent email" style="max-width:200px">
  173. <input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
  174. <input id="agPw" placeholder="temporary password" style="max-width:170px">
  175. <select id="agRole" style="max-width:140px">
  176. <option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
  177. </select>
  178. <button id="agAdd">Add agent</button>
  179. </div>
  180. <p id="agOut" class="muted"></p>
  181. </div>
  182. <div class="card">
  183. <h2>Session report</h2>
  184. <div class="filters">
  185. <div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
  186. <div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
  187. <div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
  188. <button id="fApply">Apply</button>
  189. <button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
  190. <button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
  191. </div>
  192. <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>
  193. <div id="repPager" class="pager"></div>
  194. <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
  195. </div>`);
  196. if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
  197. else {
  198. document.getElementById('agAdd').onclick = addAgent;
  199. onEnter(['agEmail','agName','agPw'], addAgent);
  200. await loadAgents();
  201. }
  202. document.getElementById('fApply').onclick = loadReport;
  203. document.getElementById('fExcel').onclick = exportExcel;
  204. document.getElementById('fPdf').onclick = exportPdf;
  205. await populateAgentFilter();
  206. await loadReport();
  207. }
  208. async function addAgent() {
  209. try {
  210. const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
  211. agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
  212. agEmail.value = ''; agName.value = ''; agPw.value = '';
  213. loadAgents(); populateAgentFilter();
  214. } catch (e) { agOut.textContent = e.message; }
  215. }
  216. const PER_PAGE = 5;
  217. function pagerHTML(page, pages, total, fn){
  218. if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
  219. return `<button ${page<=1?'disabled':''} onclick="${fn}(${page-1})">‹ Prev</button>`
  220. + `<span>Page ${page} of ${pages} · ${total} total</span>`
  221. + `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next ›</button>`;
  222. }
  223. let AGENTS_ALL = [], agentPage = 1, agentSearch = '';
  224. function agentRowHTML(u){ return `
  225. <tr>
  226. <td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
  227. <td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
  228. <td>
  229. <button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
  230. <button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
  231. ${u.id === ME.id ? '' : (u.active === 0
  232. ? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
  233. : `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
  234. }
  235. ${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
  236. </td>
  237. </tr>`; }
  238. async function loadAgents() {
  239. AGENTS_ALL = await api('/api/users', null, 'GET');
  240. agentPage = 1;
  241. const s = document.getElementById('agSearch');
  242. if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { agentSearch = s.value.trim().toLowerCase(); agentPage = 1; renderAgents(); }); }
  243. renderAgents();
  244. }
  245. window.agentGo = (p) => { agentPage = p; renderAgents(); };
  246. function renderAgents(){
  247. const all = agentSearch ? AGENTS_ALL.filter(u => ((u.name||'')+' '+(u.email||'')).toLowerCase().includes(agentSearch)) : AGENTS_ALL;
  248. const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
  249. if (agentPage > pages) agentPage = pages;
  250. const slice = all.slice((agentPage-1)*PER_PAGE, (agentPage-1)*PER_PAGE + PER_PAGE);
  251. document.querySelector('#agents tbody').innerHTML = slice.map(agentRowHTML).join('') || '<tr><td colspan=5 class="muted">No matching agents.</td></tr>';
  252. document.getElementById('agPager').innerHTML = pagerHTML(agentPage, pages, all.length, 'agentGo');
  253. }
  254. window.resetPw = async (id, email) => {
  255. const pw = prompt(`New password for ${email} (min 8 characters):`);
  256. if (!pw) return;
  257. try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
  258. catch (e) { agOut.textContent = e.message; }
  259. };
  260. window.renameAgent = async (id, email) => {
  261. const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
  262. if (!name) return;
  263. try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
  264. catch (e) { agOut.textContent = e.message; }
  265. };
  266. window.manage = async (id, action) => {
  267. try { await api('/api/users/manage', { id, action }); loadAgents(); }
  268. catch (e) { agOut.textContent = e.message; }
  269. };
  270. window.delAgent = async (id, email) => {
  271. if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
  272. try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
  273. catch (e) { agOut.textContent = e.message; }
  274. };
  275. // ---------- Session report ----------
  276. async function populateAgentFilter() {
  277. try {
  278. const rows = await api('/api/users', null, 'GET');
  279. const sel = document.getElementById('fAgent');
  280. const cur = sel.value;
  281. sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
  282. sel.value = cur;
  283. } catch { /* non-admins cannot list agents; filter stays "All" */ }
  284. }
  285. function fmtDuration(ms) {
  286. if (ms == null) return '—';
  287. const s = Math.round(ms / 1000);
  288. if (s < 60) return s + 's';
  289. const m = Math.floor(s / 60), r = s % 60;
  290. if (m < 60) return m + 'm ' + r + 's';
  291. return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
  292. }
  293. let REPORT_ROWS = [], reportPage = 1, reportSearch = '';
  294. function reportRowHTML(r){
  295. const d = new Date(r.started_at);
  296. const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
  297. return `<tr>
  298. <td>${d.toLocaleDateString()}</td>
  299. <td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
  300. <td>${esc(r.agent_name || r.agent_email || '—')}</td>
  301. <td>${esc(r.ticket || 'Direct session')}</td>
  302. <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
  303. <td>${[
  304. 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>` : '',
  305. 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>` : ''
  306. ].join('') || '<span class="muted">—</span>'}</td>
  307. </tr>`;
  308. }
  309. async function loadReport() {
  310. const q = new URLSearchParams();
  311. if (fAgent.value) q.set('agent', fAgent.value);
  312. if (fFrom.value) q.set('from', fFrom.value);
  313. if (fTo.value) q.set('to', fTo.value);
  314. REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
  315. reportPage = 1;
  316. renderReport();
  317. }
  318. window.reportGo = (p) => { reportPage = p; renderReport(); };
  319. function renderReport(){
  320. const all = reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
  321. const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
  322. if (reportPage > pages) reportPage = pages;
  323. const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
  324. document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || '<tr><td colspan=6 class="muted">No sessions match.</td></tr>';
  325. document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
  326. const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
  327. repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
  328. }
  329. function reportData() {
  330. return REPORT_ROWS.map((r) => {
  331. const d = new Date(r.started_at);
  332. return {
  333. date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
  334. agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
  335. spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
  336. };
  337. });
  338. }
  339. function exportExcel() {
  340. const rows = reportData();
  341. if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
  342. const head = ['Date','Start time','Agent','Ticket','Time spent'];
  343. const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
  344. const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
  345. .map(line => line.map(csvCell).join(',')).join('\r\n');
  346. const a = document.createElement('a');
  347. a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
  348. a.download = 'session-report.csv';
  349. a.click(); URL.revokeObjectURL(a.href);
  350. }
  351. function exportPdf() {
  352. const rows = reportData();
  353. if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
  354. const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
  355. const agentSel = fAgent.value || 'All agents';
  356. const w = window.open('', '_blank');
  357. w.document.write('<html><head><title>Session report</title><style>' +
  358. 'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
  359. 'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
  360. '.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
  361. 'table{width:100%;border-collapse:collapse;font-size:12px}' +
  362. 'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
  363. 'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
  364. '</style></head><body>' +
  365. '<h1>BizGaze Support — Session report</h1>' +
  366. '<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
  367. '<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
  368. rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
  369. '</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
  370. w.document.close();
  371. w.onload = () => { w.print(); };
  372. }
  373. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
  374. // ---------- Boot ----------
  375. (async function () {
  376. try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
  377. catch { authView(); }
  378. })();
  379. </script>
  380. </body>
  381. </html>