Files
Rpa_Skills/skills/rpa-dashboard-no-ai/templates/reference/tebra_dashboard.html
T

2105 lines
113 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
:root{
--sky-top:#8fc9e8; --sky-mid:#bfe3f2; --sky-bottom:#eaf6fb;
--glass:rgba(255,255,255,0.60); --glass-strong:rgba(255,255,255,0.78);
--border:rgba(255,255,255,0.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd;
--bad:#c0392b; --bad-bg:#fdecea;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
color:var(--ink);overflow:hidden;
background:linear-gradient(180deg,var(--sky-top) 0%,var(--sky-mid) 45%,var(--sky-bottom) 100%)}
.app{display:flex;height:100vh}
/* SIDEBAR */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);
backdrop-filter:blur(14px);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;border-bottom:1px solid rgba(255,255,255,.6);display:flex;align-items:center;gap:11px}
.logo-box{width:40px;height:40px;border-radius:12px;
background:linear-gradient(180deg,#eaf6fb,#cfe9f5);display:flex;
align-items:center;justify-content:center;flex-shrink:0;border:1px solid #b6dcee;
box-shadow:0 2px 8px rgba(61,155,208,.18)}
.logo-box svg{width:20px;height:20px;stroke:#fff}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700;color:var(--ink)}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-group{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;padding:0 10px;margin:6px 0 8px;font-weight:700}
.nav-item{display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;
margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor}
.nav-item:hover{background:rgba(255,255,255,.6);color:var(--ink)}
/* active nav = blue → black gradient (matches the sky-blue buttons) */
.nav-item.active{background:linear-gradient(135deg,#3d9bd0,#16202a);color:#fff;
box-shadow:0 4px 12px rgba(20,50,70,.22)}
.nav-item.active:hover{background:linear-gradient(135deg,#3d9bd0,#16202a);color:#fff}
.sidebar-foot{padding:14px;border-top:1px solid rgba(255,255,255,.6)}
.user-row{display:flex;align-items:center;gap:10px;padding:9px;border-radius:10px;cursor:pointer;transition:background .15s}
.user-row:hover{background:rgba(255,255,255,.6)}
.logout-btn{display:none;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:8px;
padding:10px;border:1px solid #f0c6c0;border-radius:10px;background:var(--bad-bg);color:var(--bad);
font-size:12.5px;font-weight:700;cursor:pointer;transition:all .15s}
.logout-btn svg{width:15px;height:15px;stroke:currentColor}
.logout-btn.show{display:flex}
.logout-btn:hover{background:var(--bad);color:#fff;border-color:var(--bad)}
.avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,#3d9bd0,#16202a);display:flex;
align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0;
box-shadow:0 2px 8px rgba(61,155,208,.28)}
.u-name{font-size:13px;font-weight:700;color:var(--ink)}
.u-role{font-size:10.5px;color:var(--muted)}
/* MAIN */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:linear-gradient(180deg,rgba(255,255,255,.82),rgba(230,244,251,.62));
border-bottom:1px solid var(--border);backdrop-filter:blur(14px);
padding:20px 28px;display:flex;align-items:center;gap:14px;flex-shrink:0;
box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800;color:var(--ink)}
.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
/* trigger button in the top bar */
.top-run-btn{display:flex;align-items:center;gap:8px;padding:10px 20px;border-radius:11px;border:none;
font-size:13px;font-weight:600;cursor:pointer;transition:all .18s ease}
.top-run-btn svg{width:16px;height:16px;stroke:currentColor}
.top-run-btn.off{background:rgba(255,255,255,.55);color:var(--muted);cursor:not-allowed}
.top-run-btn.on{background:linear-gradient(180deg,#5cb2e2,#3d9bd0);color:#fff;
box-shadow:0 2px 6px rgba(61,155,208,.35)}
.top-run-btn.on:hover{box-shadow:0 6px 16px rgba(61,155,208,.4);transform:translateY(-1px)}
.top-run-btn.on:active{transform:scale(.97);box-shadow:0 1px 3px rgba(61,155,208,.3)}
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
background:var(--glass-strong);cursor:pointer;font-size:16px;line-height:1;
display:flex;align-items:center;justify-content:center;transition:all .18s ease;
box-shadow:0 1px 3px rgba(20,50,70,.12)}
.mode-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.2)}
.mode-btn:active{transform:scale(.94)}
.top-run-btn.going{background:var(--warn-bg);color:var(--warn);cursor:not-allowed}
.top-ctrl-btn{display:flex;align-items:center;gap:6px;padding:10px 16px;border-radius:11px;
border:1px solid var(--border);font-size:13px;font-weight:700;cursor:pointer;background:var(--glass-strong);color:var(--ink)}
.top-ctrl-btn.hidden{display:none}
.top-ctrl-btn.pause:hover{background:var(--warn-bg);color:var(--warn);border-color:#f0dcae}
.top-ctrl-btn.stop{background:var(--bad-bg);color:var(--bad);border-color:#f0c6c0}
.top-ctrl-btn.stop:hover{background:var(--bad);color:#fff}
.top-ctrl-btn:disabled{opacity:.5;cursor:not-allowed}
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border)}
.dot{width:8px;height:8px;border-radius:50%;transition:all .3s}
.dot.ready{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
.ind-text{font-size:12px;font-weight:700;color:var(--ink)}
.user-chip{display:flex;align-items:center;gap:9px;background:var(--glass-strong);padding:6px 14px 6px 6px;border-radius:24px;border:1px solid var(--border)}
.chip-avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#3d9bd0,#16202a);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0}
.chip-name{font-size:12.5px;font-weight:700;color:var(--ink)}
.chip-role{font-size:10px;color:var(--muted)}
/* content is a panel tucked under the topbar / beside the sidebar — round its
top-left corner so the sidebar⇄topbar⇄content bend curves nicely (see ref). */
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px;
background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,0));
box-shadow:inset 2px 2px 0 rgba(255,255,255,.35)}
.content::-webkit-scrollbar{width:8px}
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.8);border-radius:8px}
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;
backdrop-filter:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.card-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.c-icon{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;
background:var(--glass-strong)}
.c-icon svg{width:20px;height:20px;stroke:var(--accent-dark)}
.c-title{font-size:15px;font-weight:700;color:var(--ink)}
.c-sub{font-size:12px;color:var(--muted);margin-top:2px}
/* DROP ZONE */
.drop-zone{border:2px dashed #a9cbe0;border-radius:14px;padding:36px 20px;text-align:center;cursor:pointer;transition:all .2s;background:rgba(255,255,255,.35)}
.drop-zone:hover,.drop-zone.over{border-color:var(--accent-dark);background:rgba(255,255,255,.6)}
.drop-icon{width:44px;height:44px;stroke:#7ba7c4;margin:0 auto 12px;display:block}
.drop-zone:hover .drop-icon,.drop-zone.over .drop-icon{stroke:var(--accent-dark)}
.drop-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:5px}
.drop-sub{font-size:12.5px;color:var(--muted)}
.browse-link{color:var(--accent-dark);cursor:pointer;font-weight:700;text-decoration:underline}
.tags{display:flex;gap:7px;justify-content:center;margin-top:14px;flex-wrap:wrap}
.tag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.6);border:1px solid var(--border);padding:4px 11px;border-radius:6px;font-weight:600}
/* FILE ROW */
.file-row{display:flex;align-items:center;gap:14px;padding:15px 16px;background:var(--ok-bg);border-radius:12px;border:1px solid #bfe6cc;margin-top:14px}
.f-details{flex:1;min-width:0}
.f-name{font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-path{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f-badge{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font-size:11px;color:var(--ok);font-weight:700}
.f-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block}
.f-remove{color:var(--muted);cursor:pointer;font-size:20px;padding:3px 9px;border-radius:8px;flex-shrink:0}
.f-remove:hover{color:var(--bad);background:var(--bad-bg)}
/* SHEET TABS + PREVIEW */
.sheet-tabs{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
.sheet-btn{padding:9px 15px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.5);color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.08)}
.sheet-btn:hover{background:rgba(255,255,255,.8);transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.15)}
.sheet-btn:active{transform:scale(.97)}
.sheet-btn.active{background:linear-gradient(180deg,#5cb2e2,#3d9bd0);border-color:#3d9bd0;color:#fff}
.sheet-preview{margin-top:14px;display:none}
.sheet-preview.show{display:block}
.preview-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.preview-title{font-size:13px;font-weight:700;color:var(--ink)}
.preview-count{font-size:11px;color:var(--muted);font-weight:600}
.tbl-wrap{border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:280px;background:rgba(255,255,255,.5)}
.tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c3d6e4;border-radius:8px}
table.data-tbl{width:100%;border-collapse:collapse;font-size:12px}
table.data-tbl th{background:rgba(255,255,255,.85);color:var(--ink);font-weight:700;text-align:left;padding:9px 12px;position:sticky;top:0;border-bottom:1px solid var(--border);white-space:nowrap}
table.data-tbl td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.7);color:#2b3f4a;white-space:nowrap}
table.data-tbl .mask{color:#9fb0b8;letter-spacing:2px}
.preview-empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}
.clear-bar{display:none;align-items:center;gap:12px;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.7)}
.clear-bar.show{display:flex}
.clear-btn{padding:9px 16px;border-radius:10px;border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(192,57,43,.12)}
.clear-btn:hover{background:var(--bad);color:#fff;transform:translateY(-1px);box-shadow:0 4px 10px rgba(192,57,43,.25)}
.clear-btn:active{transform:scale(.97)}
.clear-hint{font-size:11.5px;color:var(--muted)}
/* RUN BUTTON */
.run-btn{width:100%;padding:14px;border-radius:12px;border:none;font-size:14px;font-weight:700;cursor:pointer;
display:flex;align-items:center;justify-content:center;gap:9px;margin-top:16px;transition:all .2s}
.run-btn.off{background:rgba(255,255,255,.5);color:var(--muted);cursor:not-allowed}
.run-btn.on{background:var(--accent-dark);color:#fff}
.run-btn.on:hover{transform:translateY(-2px);box-shadow:0 10px 26px rgba(20,50,70,.25)}
.run-btn.going{background:rgba(255,255,255,.6);color:var(--accent-dark);cursor:not-allowed}
/* PROGRESS */
.prog-wrap{display:none;margin-bottom:6px}
.prog-wrap.show{display:block}
.prog-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.prog-lbl{font-size:13px;font-weight:700;color:var(--ink);margin-right:auto}
.prog-time{font-size:12.5px;font-weight:700;color:var(--muted);
background:rgba(127,196,240,.12);padding:2px 9px;border-radius:8px;white-space:nowrap}
.prog-pct{font-size:13px;color:var(--accent-dark);font-weight:800}
.prog-bg{height:8px;background:rgba(255,255,255,.6);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;background:linear-gradient(90deg,#28c26f,#1f9d57);border-radius:8px;width:0%;transition:width .5s ease}
.steps{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;border:1px solid transparent}
.step.done{background:var(--ok-bg);border-color:#cdeed8}
.step.active{background:var(--warn-bg);border-color:#f0dcae}
.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;font-weight:700}
.step.done .step-dot{background:var(--ok);color:#fff}
.step.active .step-dot{background:var(--warn);color:#fff}
.step.pending .step-dot{background:rgba(255,255,255,.7);color:var(--muted)}
.step-lbl{font-size:13px;font-weight:600}
.step.done .step-lbl{color:var(--ok)}
.step.active .step-lbl{color:var(--warn)}
.step.pending .step-lbl{color:var(--muted)}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:200px;overflow-y:auto;
font-family:'Consolas','Courier New',monospace;font-size:13.5px;line-height:1.85}
.log::-webkit-scrollbar{width:6px}
.log::-webkit-scrollbar-thumb{background:#3a4a55;border-radius:6px}
.log-line{color:#9fb6c4}
.log-line.info{color:#7fc4f0}
.log-line.success{color:#5fe0a0}
.log-line.warn{color:#f5c563}
.log-line.error{color:#f68c82}
/* CHECKPOINT TRACKER (7 boxes: pending -> active -> done) */
.cp-wrap{margin-top:14px}
.cp-title{font-size:12px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:8px}
.cp-track{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}
.cp-box{position:relative;border-radius:11px;padding:11px 10px 10px;
border:1.5px solid rgba(127,196,240,.20);background:rgba(127,196,240,.06);
display:flex;flex-direction:column;gap:5px;min-height:74px;transition:all .25s}
.cp-box .cp-num{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;
background:rgba(255,255,255,.6);color:var(--muted)}
.cp-box .cp-name{font-size:12px;font-weight:700;color:var(--ink);line-height:1.25}
.cp-box .cp-meta{font-size:10.5px;font-weight:700;color:var(--muted);margin-top:auto}
/* pending = default (dim) */
.cp-box.pending{opacity:.55}
/* active = highlighted + pulsing */
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);
box-shadow:0 0 0 3px rgba(245,197,99,.14)}
.cp-box.active .cp-num{background:var(--warn);color:#fff;animation:cp-pulse 1.2s ease-in-out infinite}
.cp-box.active .cp-meta{color:var(--warn)}
/* done = green check */
.cp-box.done{border-color:#cdeed8;background:var(--ok-bg)}
.cp-box.done .cp-num{background:var(--ok);color:#fff}
.cp-box.done .cp-meta{color:var(--ok)}
/* stuck = red */
.cp-box.stuck{border-color:#f0b6b0;background:var(--bad-bg)}
.cp-box.stuck .cp-num{background:var(--bad);color:#fff}
.cp-box.stuck .cp-meta{color:var(--bad)}
@keyframes cp-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,197,99,.5)}50%{box-shadow:0 0 0 6px rgba(245,197,99,0)}}
body.dark .cp-box .cp-name{color:#cfe0ec}
body.dark .cp-box .cp-num{background:rgba(255,255,255,.12)}
/* REPORT */
.report-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.rt-file{font-size:12px;color:var(--muted);margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.btn{padding:9px 15px;border-radius:11px;border:1px solid #9fcbe6;
background:linear-gradient(180deg,#d4edfa,#a9d8ee);
color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.14)}
.btn:hover{background:linear-gradient(180deg,#c6e7f8,#94cde9);transform:translateY(-1px);
box-shadow:0 4px 12px rgba(61,155,208,.28)}
.btn:active{transform:translateY(0) scale(.97);box-shadow:0 1px 2px rgba(20,50,70,.12)}
.btn:disabled{transform:none;box-shadow:none}
.btn.dark{background:linear-gradient(180deg,#5cb2e2,#3d9bd0);color:#fff;border-color:#3d9bd0}
.btn.dark:hover{background:linear-gradient(180deg,#6bbce8,#489fd4)}
.pager{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px}
.pager.hidden{display:none}
.pager .pg-info{font-size:12.5px;color:var(--muted);font-weight:600}
.btn:disabled{opacity:.45;cursor:not-allowed}
.status-sel{border:none;border-radius:20px;padding:5px 12px;font-size:11px;font-weight:700;cursor:pointer;color:#fff;-webkit-appearance:none;appearance:none}
.status-sel:disabled{opacity:.8;cursor:default}
.status-sel.success{background:var(--ok)}
.status-sel.attention{background:var(--warn)}
.status-sel.failed{background:var(--bad)}
.status-sel option{color:#1c2b33;background:#fff}
.rtbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:rgba(255,255,255,.55)}
.rtbl-wrap::-webkit-scrollbar{width:9px;height:9px}
.rtbl-wrap::-webkit-scrollbar-thumb{background:#c3d6e4;border-radius:9px}
table.rtbl{border-collapse:separate;border-spacing:0;font-size:12px;white-space:nowrap}
table.rtbl th{background:#eaf3f8;color:var(--ink);font-weight:700;text-align:left;padding:11px 14px;
position:sticky;top:0;z-index:3;border-bottom:1px solid var(--border)}
table.rtbl td{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.75);color:#2b3f4a}
/* frozen (sticky-left) columns — left/width set inline per column */
table.rtbl th.fz,table.rtbl td.fz{position:sticky;z-index:2;background:#f2f8fb;
box-shadow:6px 0 8px -6px rgba(20,50,70,.25)}
table.rtbl th.fz{z-index:4;background:#e3eff5}
/* row action buttons (edit / delete / save / cancel) */
.act-btn{width:30px;height:30px;border-radius:8px;border:1px solid var(--border);
background:var(--glass-strong);cursor:pointer;font-size:13px;line-height:1;
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.12);margin-right:5px}
.act-btn:hover{transform:translateY(-1px);box-shadow:0 4px 9px rgba(20,50,70,.2)}
.act-btn:active{transform:scale(.94)}
.act-btn.del:hover{background:var(--bad);border-color:var(--bad)}
.act-btn.save{background:var(--ok);border-color:var(--ok)}
.act-btn.cancel:hover{background:var(--warn-bg);border-color:#f0dcae}
/* inline edit inputs */
.ed-input{width:130px;padding:6px 8px;border:1px solid #9cc3da;border-radius:8px;
font-size:12px;background:#fff;color:var(--ink);font-family:inherit}
.ed-input:focus{outline:none;border-color:var(--accent-dark);box-shadow:0 0 0 3px rgba(22,32,42,.12)}
/* date picker(s) in the report toolbar */
.date-inp{padding:8px 10px;border:1px solid var(--border);border-radius:10px;
background:var(--glass-strong);color:var(--ink);font-size:12.5px;font-weight:600;
font-family:inherit;transition:all .18s ease}
.date-inp:hover{background:#fff}
.date-range{display:flex;align-items:center;gap:9px;margin:-4px 0 14px;padding:12px 14px;
background:var(--glass);border:1px solid var(--border);border-radius:12px;flex-wrap:wrap}
.dr-lbl{font-size:12px;font-weight:700;color:var(--muted)}
.dr-hint{font-size:11px;color:var(--muted);flex-basis:100%;margin-top:2px}
/* confirm dialog */
.modal-mask{position:fixed;inset:0;background:rgba(10,20,30,.45);display:none;
align-items:center;justify-content:center;z-index:60}
.modal-mask.show{display:flex}
.modal{background:var(--glass-strong);backdrop-filter:blur(16px);border:1px solid var(--border);
border-radius:16px;padding:24px;width:360px;box-shadow:0 18px 50px rgba(10,30,45,.35)}
.modal-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:8px}
.modal-msg{font-size:12.5px;color:var(--muted);margin-bottom:18px;line-height:1.5}
.modal-btns{display:flex;gap:10px;justify-content:flex-end}
.modal-btn{padding:9px 18px;border-radius:10px;font-size:12.5px;font-weight:600;cursor:pointer;
border:1px solid var(--border);transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.12)}
.modal-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.18)}
.modal-btn:active{transform:scale(.97)}
.modal-btn.plain{background:var(--glass-strong);color:var(--ink)}
.modal-btn.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.pdf-mark{font-weight:800;font-size:14px}
.pdf-mark.ok{color:var(--ok)}
.pdf-mark.no{color:var(--bad)}
.pdf-mark.muted{color:var(--muted);opacity:.6}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700}
.pill.success{background:var(--ok);color:#fff}
.pill.attention{background:var(--warn);color:#fff}
.pill.failed{background:var(--bad);color:#fff}
.pill.dup{background:#95a3ac;color:#fff}
.pill.role-admin{background:linear-gradient(135deg,#3d9bd0,#16202a);color:#fff}
.pill.role-operator{background:#7fa8c0;color:#fff}
/* ---- Admin tab ---- */
/* The "Assign access" card must sit above "Users with access" so the employee
search dropdown (which overflows the card) isn't clipped behind it. Each
.card is its own stacking context (backdrop-filter), so raising the whole
card is required — a z-index on .emp-results alone can't escape it.
NB: use the explicit id, NOT :first-of-type — the first <div> in the tab is
the .section-label, so :first-of-type matches no .card. */
#adm-assign-card{position:relative;z-index:5}
.adm-assign-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.emp-search{position:relative;flex:1;min-width:260px}
.emp-search .ed-input{width:100%;padding:10px 15px;border:1px solid var(--border);
border-radius:12px;background:var(--glass-strong);color:var(--ink);font-size:13px;
font-family:inherit;transition:all .18s ease}
.emp-search .ed-input:hover{background-color:#fff;border-color:var(--accent-dark)}
.emp-search .ed-input:focus{outline:none;border-color:var(--accent-dark);
box-shadow:0 0 0 3px rgba(22,32,42,.12)}
body.dark .emp-search .ed-input{background-color:rgba(255,255,255,.06)}
body.dark .emp-search .ed-input:hover{background-color:rgba(255,255,255,.12)}
/* Role picker (Admin / Operator) — custom-styled select with its own chevron */
.role-select{appearance:none;-webkit-appearance:none;-moz-appearance:none;
padding:10px 38px 10px 15px;min-width:140px;border:1px solid var(--border);
border-radius:12px;background:var(--glass-strong);color:var(--ink);
font-size:13px;font-weight:700;font-family:inherit;cursor:pointer;
transition:all .18s ease;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%235c7382' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 13px center;background-size:15px}
.role-select:hover{background-color:#fff;border-color:var(--accent-dark)}
.role-select:focus{outline:none;border-color:var(--accent-dark);
box-shadow:0 0 0 3px rgba(22,32,42,.12)}
.role-select option{color:#1c2b33;background:#fff;font-weight:600}
body.dark .role-select{background-color:rgba(255,255,255,.06);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2390a8bd' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")}
body.dark .role-select:hover{background-color:rgba(255,255,255,.12)}
body.dark .role-select option{color:#e7eef4;background:#16222c}
.emp-results{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:40;
background:#fff;border:1px solid var(--border);border-radius:12px;
box-shadow:0 12px 30px rgba(22,46,66,.18);max-height:260px;overflow:auto}
.emp-item{display:flex;justify-content:space-between;gap:12px;padding:9px 13px;
cursor:pointer;font-size:13px;border-bottom:1px solid rgba(0,0,0,.04)}
.emp-item:last-child{border-bottom:none}
.emp-item:hover{background:#eaf3f8}
.emp-sel{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;
border-radius:20px;background:linear-gradient(180deg,#d4edfa,#a9d8ee);
font-size:12.5px;font-weight:700;color:#123a52}
.emp-sel-x{cursor:pointer;font-size:15px;line-height:1;color:#3f6379}
.adm-note{margin-top:10px;font-size:12px;color:var(--muted)}
.adm-note.ok{color:var(--ok)}
.adm-note.err{color:var(--bad)}
.adm-remove{background:none;border:1px solid var(--bad);color:var(--bad);
border-radius:8px;padding:4px 10px;font-size:11.5px;font-weight:700;cursor:pointer}
.adm-remove:hover{background:var(--bad);color:#fff}
.adm-remove.confirm{background:var(--bad);color:#fff}
body.dark .emp-results{background:#16222c;border-color:#2b3d4b}
body.dark .emp-item{border-bottom:1px solid rgba(255,255,255,.05)}
body.dark .emp-item:hover{background:#1d2f3c}
body.dark .emp-sel{background:linear-gradient(180deg,#2e5d90,#1d3d5e);color:#dbeaf5}
body.dark .emp-sel-x{color:#9fc0d6}
.empty-state{text-align:center;padding:50px 20px}
.empty-title{font-size:15px;font-weight:700;color:var(--muted);margin-bottom:4px}
.empty-sub{font-size:12px;color:var(--muted)}
.hidden{display:none!important}
input[type=file]{display:none}
/* ── DASHBOARD ────────────────────────────────────────────────────────────────── */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;
background:var(--glass-strong);border:1px solid var(--border);
box-shadow:0 4px 14px rgba(20,50,70,.10);transition:all .18s ease}
.kpi:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(20,50,70,.16)}
.kpi-ic{width:42px;height:42px;border-radius:12px;display:flex;align-items:center;
justify-content:center;flex-shrink:0}
.kpi-ic svg{width:20px;height:20px;stroke:#fff}
.kpi.total .kpi-ic{background:linear-gradient(135deg,#5cb2e2,#3d9bd0)}
.kpi.done .kpi-ic{background:linear-gradient(135deg,#35b374,#1f9d57)}
.kpi.prog .kpi-ic{background:linear-gradient(135deg,#d9a13c,#b7791f)}
.kpi.notdone .kpi-ic{background:linear-gradient(135deg,#d95a4a,#c0392b)}
.kpi-val{font-size:26px;font-weight:800;color:var(--ink);line-height:1.1}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
.kpi.prog .kpi-ic{position:relative}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;
border:2px solid #d9a13c;opacity:.6;animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:16px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;flex-shrink:0;
background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--sky-bottom);
border:1px solid var(--border);display:flex;flex-direction:column;align-items:center;
justify-content:center;box-shadow:inset 0 2px 6px rgba(20,50,70,.08)}
.dh-num{font-size:26px;font-weight:800;color:var(--ink);line-height:1}
.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase;
letter-spacing:.6px;margin-top:3px}
.donut-legend{display:flex;flex-direction:column;gap:9px;min-width:170px}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;color:var(--ink)}
.dl-dot{width:10px;height:10px;border-radius:3px;flex-shrink:0}
.dl-count{margin-left:auto;font-weight:800}
.era-stats{display:flex;gap:10px;margin-bottom:14px}
.es{flex:1;text-align:center;padding:10px 6px;border-radius:12px;background:rgba(255,255,255,.45);
border:1px solid var(--border)}
.es-val{font-size:21px;font-weight:800;line-height:1.1}
.es-lbl{font-size:10.5px;color:var(--muted);font-weight:700;margin-top:3px}
.ok-ink{color:var(--ok)} .bad-ink{color:var(--bad)} .warn-ink{color:var(--warn)}
.era-bar{display:flex;height:12px;border-radius:7px;overflow:hidden;gap:2px;background:rgba(20,50,70,.08)}
.era-bar .seg{height:100%}
.era-bar .seg.c{background:var(--ok)} .era-bar .seg.e{background:var(--bad)} .era-bar .seg.p{background:var(--warn)}
.era-bar-note{font-size:11px;color:var(--muted);margin-top:8px;font-weight:600}
.dash-toolbar{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.chipbar{display:flex;gap:8px;flex:1}
.chip{padding:7px 15px;border-radius:20px;border:1px solid var(--border);cursor:pointer;
background:rgba(255,255,255,.5);color:var(--muted);font-size:12px;font-weight:700;
transition:all .15s ease}
.chip:hover{transform:translateY(-1px);color:var(--ink)}
.chip.active{background:linear-gradient(135deg,#3d9bd0,#16202a);color:#fff;border-color:transparent;
box-shadow:0 3px 9px rgba(20,50,70,.25)}
.batch-list{display:flex;flex-direction:column;gap:11px}
.batch-card{display:flex;align-items:center;gap:16px;padding:15px 18px;border-radius:15px;
background:var(--glass-strong);border:1px solid var(--border);
box-shadow:0 3px 10px rgba(20,50,70,.08);transition:all .18s ease}
.batch-card:hover{transform:translateY(-2px);box-shadow:0 8px 18px rgba(20,50,70,.15)}
.bc-status{width:10px;height:44px;border-radius:6px;flex-shrink:0}
.bc-status.completed{background:linear-gradient(180deg,#35b374,#1f9d57)}
.bc-status.inprogress{background:linear-gradient(180deg,#d9a13c,#b7791f)}
.bc-status.notcompleted{background:linear-gradient(180deg,#d95a4a,#c0392b)}
.bc-status.cleared{background:linear-gradient(180deg,#8b7fe0,#6b5bd6)}
.bc-id{min-width:150px}
.bc-id .b-num{font-size:14.5px;font-weight:800;color:var(--ink)}
.bc-id .b-date{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
.today-chip{display:inline-block;margin-left:7px;padding:1.5px 8px;border-radius:9px;
font-size:9.5px;font-weight:800;background:linear-gradient(135deg,#5cb2e2,#3d9bd0);
color:#fff;vertical-align:1.5px;letter-spacing:.4px}
.batch-chip{margin-left:auto;flex-shrink:0;display:flex;align-items:center;gap:7px;
padding:5px 13px;border-radius:20px;font-size:11.5px;font-weight:700;color:#fff;
letter-spacing:.2px;background:linear-gradient(135deg,#5cb2e2,#3d9bd0);
box-shadow:0 3px 9px rgba(20,50,70,.22);user-select:text}
.batch-chip b{font-weight:900;letter-spacing:.5px}
.batch-chip .bch-dot{width:7px;height:7px;border-radius:50%;
background:rgba(255,255,255,.85)}
.batch-chip.live .bch-dot{animation:blink 1.2s infinite}
.bc-prog{flex:1;min-width:130px}
.bc-prog .bp-head{display:flex;justify-content:space-between;font-size:11px;
color:var(--muted);font-weight:700;margin-bottom:5px}
.bc-prog .bp-bg{height:9px;border-radius:6px;background:rgba(20,50,70,.10);overflow:hidden}
.bc-prog .bp-fill{height:100%;border-radius:6px;transition:width .5s ease;
background:linear-gradient(90deg,#5cb2e2,#3d9bd0)}
.batch-card .bp-fill.full{background:linear-gradient(90deg,#35b374,#1f9d57)}
.bc-eras{display:flex;gap:7px;flex-shrink:0}
.era-chip{padding:4px 10px;border-radius:9px;font-size:11px;font-weight:800;border:1px solid transparent}
.era-chip.c{background:var(--ok-bg);color:var(--ok)}
.era-chip.e{background:var(--bad-bg);color:var(--bad)}
.era-chip.p{background:var(--warn-bg);color:var(--warn)}
.bc-pill{padding:6px 14px;border-radius:20px;font-size:11px;font-weight:800;flex-shrink:0;
display:flex;align-items:center;gap:6px}
.bc-pill .pd{width:7px;height:7px;border-radius:50%}
.bc-pill.completed{background:var(--ok-bg);color:var(--ok)} .bc-pill.completed .pd{background:var(--ok)}
.bc-pill.inprogress{background:var(--warn-bg);color:var(--warn)} .bc-pill.inprogress .pd{background:var(--warn);animation:blink 1.2s infinite}
.bc-pill.notcompleted{background:var(--bad-bg);color:var(--bad)} .bc-pill.notcompleted .pd{background:var(--bad)}
.bc-pill.cleared{background:rgba(107,91,214,.14);color:#6b5bd6} .bc-pill.cleared .pd{background:#6b5bd6}
body.dark .bc-pill.cleared{color:#b3a9f2} body.dark .bc-pill.cleared .pd{background:#b3a9f2}
/* batch clear: who asked / who cleared, and the admin's Approve button */
.bc-clear-note{font-size:11px;font-weight:600;color:#6b5bd6;margin-top:3px}
body.dark .bc-clear-note{color:#b3a9f2}
.bc-approve{padding:6px 12px;font-size:11.5px;flex-shrink:0}
.clear-important{margin-top:12px;padding:10px 12px;border-radius:9px;
border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px}
/* ── batch drill-down (inline accordion, single-open) ─────────────────────── */
.batch-item{display:flex;flex-direction:column}
.batch-card{cursor:pointer;position:relative;z-index:1}
.bc-id{user-select:text} /* keep the batch number copyable */
.bc-caret{flex-shrink:0;width:16px;text-align:center;font-size:13px;
color:var(--muted);transition:transform .18s ease}
.batch-item.open .bc-caret{transform:rotate(180deg);color:var(--accent-dark)}
/* While open the card and its panel are ONE surface: kill the lift (it would
float the card off its own detail and clip the join) and weld the corners. */
.batch-item.open .batch-card,
.batch-item.open .batch-card:hover{transform:none;box-shadow:none;
border-bottom-left-radius:0;border-bottom-right-radius:0;
border-bottom-color:transparent}
.bc-detail{border:1px solid var(--border);border-top:0;border-radius:0 0 15px 15px;
background:var(--glass);padding:8px 18px 13px 44px;
box-shadow:0 3px 10px rgba(20,50,70,.08)}
.bd-co{padding:7px 0;border-bottom:1px dashed rgba(20,50,70,.12)}
.bd-co:last-child{border-bottom:0}
.bd-co-head{display:flex;align-items:center;gap:9px}
.bd-name{font-size:12.5px;font-weight:700;color:var(--ink);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bd-co-head .bd-name{flex:1}
.bd-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.bd-dot.completed{background:var(--ok)}
.bd-dot.inprogress{background:var(--warn)}
.bd-dot.notcompleted{background:var(--bad)}
.bd-pill{padding:3px 10px;border-radius:20px;font-size:10px;font-weight:800;flex-shrink:0}
.bd-pill.completed{background:var(--ok-bg);color:var(--ok)}
.bd-pill.inprogress{background:var(--warn-bg);color:var(--warn)}
.bd-pill.notcompleted{background:var(--bad-bg);color:var(--bad)}
.bd-locs{margin:6px 0 2px 4px;padding-left:13px;
border-left:2px solid rgba(20,50,70,.12);
display:flex;flex-direction:column;gap:5px}
.bd-loc{display:flex;align-items:center;gap:8px}
.bd-loc .bd-name{flex:1;font-weight:600;color:var(--muted)}
.bd-none{font-size:11.5px;color:var(--muted);font-style:italic}
.bd-msg{font-size:12px;color:var(--muted);padding:5px 0}
.bd-msg.bad{color:var(--bad)}
.bd-load{display:flex;align-items:center;gap:9px;font-size:12px;
color:var(--muted);padding:7px 0}
.bd-load .bd-dot{animation:blink 1.2s infinite}
@media (max-width:1080px){.kpi-row{grid-template-columns:repeat(2,1fr)}.dash-grid{grid-template-columns:1fr}}
/* ── DARK MODE (body.dark) — same layout, night palette ─────────────────────── */
body.dark{
--sky-top:#0f1b26;--sky-mid:#13212e;--sky-bottom:#182a38;
--glass:rgba(22,34,44,.72);--glass-strong:rgba(30,45,58,.85);
--border:rgba(120,160,190,.22);
--ink:#e6eef4;--muted:#8fa6b4;--accent-dark:#2e5d80;
--ok:#2fbf72;--ok-bg:rgba(31,157,87,.16);
--warn:#d9a13c;--warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b;--bad-bg:rgba(192,57,43,.16);
}
body.dark .topbar{background:linear-gradient(180deg,rgba(30,45,58,.92),rgba(22,34,44,.7));
box-shadow:0 6px 18px rgba(0,0,0,.28)}
body.dark .content{background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));
box-shadow:inset 2px 2px 0 rgba(255,255,255,.08)}
body.dark .nav-item:hover{background:rgba(255,255,255,.07);color:var(--ink)}
body.dark .kpi,body.dark .batch-card,body.dark .donut-hole{box-shadow:0 4px 14px rgba(0,0,0,.3)}
body.dark .es{background:rgba(255,255,255,.05)}
body.dark .chip{background:rgba(255,255,255,.07)}
body.dark .chip.active{background:linear-gradient(135deg,#3d9bd0,#16202a)}
body.dark .era-bar{background:rgba(255,255,255,.08)}
body.dark .bc-prog .bp-bg{background:rgba(255,255,255,.10)}
body.dark .donut{background:conic-gradient(rgba(255,255,255,.10) 0 100%)}
body.dark .bc-detail{background:rgba(255,255,255,.04);
box-shadow:0 4px 14px rgba(0,0,0,.3)}
body.dark .bd-co{border-bottom-color:rgba(255,255,255,.10)}
body.dark .bd-locs{border-left-color:rgba(255,255,255,.12)}
body.dark .user-row:hover{background:rgba(255,255,255,.07)}
body.dark .logo-area,body.dark .sidebar-foot{border-color:rgba(120,160,190,.15)}
body.dark .drop-zone{background:rgba(255,255,255,.04);border-color:#3a5568}
body.dark .drop-zone:hover,body.dark .drop-zone.over{background:rgba(255,255,255,.08);border-color:#7fb3d5}
body.dark .tag{background:rgba(255,255,255,.07)}
body.dark .top-run-btn.off{background:rgba(255,255,255,.08)}
body.dark .file-row{background:var(--ok-bg);border-color:rgba(47,191,114,.35)}
body.dark .sheet-btn{background:rgba(255,255,255,.07)}
body.dark .sheet-btn:hover{background:rgba(255,255,255,.14)}
body.dark .tbl-wrap{background:rgba(255,255,255,.03)}
body.dark table.data-tbl th{background:#1d2f3c;color:var(--ink)}
body.dark table.data-tbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
body.dark .clear-bar{border-top-color:rgba(120,160,190,.15)}
body.dark .clear-btn{border-color:rgba(224,90,75,.4)}
body.dark .logout-btn{border-color:rgba(224,90,75,.4)}
body.dark .run-btn.off,body.dark .run-btn.going{background:rgba(255,255,255,.08)}
body.dark .prog-bg{background:rgba(255,255,255,.12)}
body.dark .step.pending .step-dot{background:rgba(255,255,255,.12)}
body.dark .step.done{border-color:rgba(47,191,114,.3)}
body.dark .step.active{border-color:rgba(217,161,60,.3)}
body.dark .log{background:#0d161e}
body.dark .btn{background:linear-gradient(180deg,#2a3f50,#223442);color:var(--ink)}
body.dark .btn:hover{background:linear-gradient(180deg,#31495c,#283d4d)}
body.dark .btn.dark{background:linear-gradient(180deg,#3b6a90,#2e5d80);color:#fff}
body.dark .date-inp:hover{background:rgba(255,255,255,.1)}
body.dark .date-range{background:rgba(255,255,255,.04)}
body.dark .rtbl-wrap{background:rgba(255,255,255,.03)}
body.dark table.rtbl th{background:#1d2f3c}
body.dark table.rtbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
body.dark table.rtbl td.fz{background:#1a2937}
body.dark table.rtbl th.fz{background:#213445}
body.dark .status-sel option{color:#e6eef4;background:#1d2f3c}
body.dark .pill.dup{background:#54636f}
body.dark .ed-input{background:#152430;border-color:#3a5568}
body.dark .ed-input:focus{box-shadow:0 0 0 3px rgba(127,179,213,.15)}
body.dark .content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18)}
body.dark .rtbl-wrap::-webkit-scrollbar-thumb,body.dark .tbl-wrap::-webkit-scrollbar-thumb{background:#33495a}
body.dark .modal-mask{background:rgba(0,8,15,.6)}
@media (max-width:820px){
.sidebar{width:66px;min-width:66px}
.logo-title,.logo-sub,.nav-group,.nav-item span,.u-name,.u-role{display:none}
.logo-area{justify-content:center;padding:18px 0}
.nav-item{justify-content:center}
}
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<div class="sidebar">
<div class="logo-area">
<div class="logo-box">
<img src="%%LOGO%%" alt="logo">
</div>
<div>
<div class="logo-title">Tebra ERA</div>
<div class="logo-sub">Payment process &middot; v%%VERSION%%</div>
</div>
</div>
<div class="nav">
<div class="nav-group">Menu</div>
<div class="nav-item" id="n-dashboard" onclick="gotoTab('dashboard')">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
<span>Dashboard</span>
</div>
<div class="nav-item active" id="n-run" onclick="gotoTab('run')">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span>Run</span>
</div>
<div class="nav-item" id="n-report" onclick="gotoTab('report')">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>
<span>Report</span>
</div>
<div class="nav-item hidden" id="n-admin" onclick="gotoTab('admin')">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin</span>
</div>
</div>
<div class="sidebar-foot">
<div class="user-row" onclick="toggleLogout()" title="Click to log out">
<div class="avatar" id="side-avatar">U</div>
<div style="flex:1;min-width:0">
<div class="u-name" id="side-user-name">User</div>
<div class="u-role" id="side-role">Signed in</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</div>
<button class="logout-btn" id="logout-btn" onclick="doLogout()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout
</button>
</div>
</div>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div>
<div class="tb-title" id="tb-title">Run automation</div>
<div class="tb-sub" id="tb-sub">Upload the input Excel and start ERA processing</div>
</div>
<div class="tb-right">
<button class="top-run-btn off" id="top-run-btn" onclick="doRun()">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="top-run-lbl">Upload a file to continue</span>
</button>
<button class="mode-btn" id="mode-btn" onclick="toggleDark()" title="Toggle dark mode">🌙</button>
<div class="indicator">
<div class="dot ready" id="sys-dot"></div>
<div class="ind-text" id="sys-txt">System ready</div>
</div>
</div>
</div>
<div class="content">
<!-- RUN TAB -->
<div id="tab-run">
<div class="section-label">Input configuration</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div>
<div class="c-title">Input Excel upload</div>
<div class="c-sub">Input_Login_Locations.xlsx (Login / Company sheets)</div>
</div>
<button class="btn" style="margin-left:auto;flex-shrink:0"
onclick="pyBridge&&pyBridge.download_sample_input()"
title="Download a filled-in example showing exactly how to fill the Login, Company and Settings sheets">&#11015; Sample input</button>
</div>
<div class="drop-zone" id="dz" ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)"
onclick="pyBridge&&pyBridge.browse_file()">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<div class="drop-title">Drop your Excel file here</div>
<div class="drop-sub">or <span class="browse-link" onclick="event.stopPropagation();pyBridge&&pyBridge.browse_file()">browse files</span></div>
<div class="tags"><span class="tag">.xlsx</span><span class="tag">.xlsm</span><span class="tag">.xls</span></div>
</div>
<input type="file" id="fi" accept=".xlsx,.xlsm,.xls" onchange="fileChosen(this.files[0])">
<div class="file-row hidden" id="file-row">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="f-details">
<div class="f-name" id="f-name">—</div>
<div class="f-path" id="f-path">—</div>
<div class="f-badge">Ready to process</div>
</div>
<div class="f-remove" onclick="removeFile()" title="Remove">&times;</div>
</div>
<div class="sheet-tabs hidden" id="sheet-tabs"></div>
<div class="sheet-preview" id="sheet-preview">
<div class="preview-head">
<div class="preview-title" id="preview-title">Sheet</div>
<div class="preview-count" id="preview-count"></div>
</div>
<div class="tbl-wrap" id="tbl-wrap"></div>
</div>
<div class="clear-bar" id="clear-bar">
<button class="clear-btn" onclick="removeFile()">Clear file</button>
<span class="clear-hint">Uploaded the wrong file? Clear and choose again.</span>
</div>
</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
<div>
<div class="c-title">Run automation</div>
<div class="c-sub">Login → Select company → Clearinghouse → Post payments</div>
</div>
<div class="batch-chip hidden" id="batch-chip"
title="The batch this run is tracked under — quote this number when reporting an issue">
<span class="bch-dot"></span>Batch <b id="batch-chip-num">—</b>
</div>
</div>
<div class="prog-wrap" id="prog-wrap">
<div class="prog-head">
<span class="prog-lbl" id="prog-lbl">Initializing...</span>
<span class="prog-time" id="prog-time"></span>
<span class="prog-pct" id="prog-pct">0%</span>
</div>
<div class="prog-bg"><div class="prog-fill" id="prog-fill"></div></div>
<div class="steps" id="steps"></div>
<div class="log" id="log"></div>
<div class="cp-wrap">
<div class="cp-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Progress checkpoints
<span id="cp-elapsed" style="margin-left:auto;font-weight:700;color:var(--accent-dark)"></span>
</div>
<div class="cp-track" id="cp-track"></div>
</div>
</div>
</div>
</div>
<!-- DASHBOARD TAB -->
<div id="tab-dashboard" class="hidden">
<div class="section-label">Batch overview</div>
<div class="kpi-row">
<div class="kpi total">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="kpi-total">0</div><div class="kpi-lbl">Total batches</div></div>
</div>
<div class="kpi done">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="kpi-done">0</div><div class="kpi-lbl">Completed</div></div>
</div>
<div class="kpi prog">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="kpi-prog">0</div><div class="kpi-lbl">In progress</div></div>
</div>
<div class="kpi notdone">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="kpi-notdone">0</div><div class="kpi-lbl">Not completed</div></div>
</div>
</div>
<div class="dash-grid">
<div class="card donut-card">
<div class="c-title" style="margin-bottom:14px">Batch status split</div>
<div class="donut-wrap">
<div class="donut" id="dash-donut"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div>
<div class="donut-legend" id="donut-legend"></div>
</div>
</div>
<div class="card era-card">
<div class="c-title" style="margin-bottom:14px">ERA totals (all batches)</div>
<div class="era-stats">
<div class="es"><div class="es-val ok-ink" id="es-done">0</div><div class="es-lbl">✓ Completed</div></div>
<div class="es"><div class="es-val bad-ink" id="es-err">0</div><div class="es-lbl">✗ Error</div></div>
<div class="es"><div class="es-val warn-ink" id="es-pend">0</div><div class="es-lbl">● Pending</div></div>
</div>
<div class="era-bar" id="era-bar"></div>
<div class="era-bar-note" id="era-bar-note"></div>
</div>
</div>
<div class="dash-toolbar">
<div class="chipbar" id="dash-chips">
<button class="chip active" data-f="all" onclick="dashFilter('all')">All</button>
<button class="chip" data-f="completed" onclick="dashFilter('completed')">Completed</button>
<button class="chip" data-f="inprogress" onclick="dashFilter('inprogress')">In progress</button>
<button class="chip" data-f="notcompleted" onclick="dashFilter('notcompleted')">Not completed</button>
<button class="chip" data-f="cleared" onclick="dashFilter('cleared')">Cleared</button>
</div>
</div>
<div id="dash-empty" class="card empty-state hidden">
<div class="empty-title">No batches yet</div>
<div class="empty-sub">Upload an input Excel on the Run tab to start your first batch.</div>
</div>
<div class="batch-list" id="batch-list"></div>
</div>
<!-- REPORT TAB -->
<div id="tab-report" class="hidden">
<div class="section-label">Run report</div>
<div class="kpi-row" style="grid-template-columns:repeat(3,1fr)">
<div class="kpi done">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="rc-success">0</div><div class="kpi-lbl">Success</div></div>
</div>
<div class="kpi prog">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="rc-attention">0</div><div class="kpi-lbl">Need attention</div></div>
</div>
<div class="kpi notdone">
<div class="kpi-ic"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg></div>
<div class="kpi-body"><div class="kpi-val" id="rc-failed">0</div><div class="kpi-lbl">Failed</div></div>
</div>
</div>
<div class="report-toolbar">
<div class="rt-file" id="rt-file">No report loaded</div>
<button class="btn" id="bydate-btn" onclick="toggleDateRange()">⬇ By date</button>
<button class="btn dark" onclick="downloadReport()">⬇ Download Excel</button>
<button class="btn hidden" id="rpt-deleted-btn"
onclick="pyBridge&&pyBridge.load_deleted_rows()">🗑 View deleted rows</button>
</div>
<div class="date-range hidden" id="date-range">
<span class="dr-lbl">From</span>
<input type="date" class="date-inp" id="rpt-from">
<span class="dr-lbl">To</span>
<input type="date" class="date-inp" id="rpt-to">
<button class="btn dark" onclick="downloadByRange()">⬇ Download range</button>
<button class="btn" onclick="toggleDateRange()">Cancel</button>
<span class="dr-hint">Same From &amp; To → that single day · different → the whole range.</span>
</div>
<div class="date-range hidden" id="report-search">
<span class="dr-lbl">Search</span>
<select class="date-inp" id="search-col" onchange="applySearch()"></select>
<input class="ed-input" id="search-val" placeholder="Type a value…" style="width:220px" oninput="applySearch()">
<button class="btn" onclick="clearSearch()">Clear</button>
<span class="dr-lbl" id="search-info"></span>
</div>
<div id="report-empty" class="card empty-state">
<div class="empty-title">No report yet</div>
<div class="empty-sub">Run the automation, or click “Open report…” to load a log file.</div>
</div>
<div class="rtbl-wrap hidden" id="rtbl-wrap"></div>
<div class="pager hidden" id="pager"></div>
</div>
<!-- ADMIN TAB (Super Admin + Admin only — nav item hidden for Operators) -->
<div id="tab-admin" class="hidden">
<div class="section-label">Access management</div>
<div class="card" id="adm-assign-card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg></div>
<div>
<div class="c-title">Assign access</div>
<div class="c-sub" id="adm-assign-sub">Search an employee, pick a role, assign</div>
</div>
</div>
<div class="adm-assign-row">
<div class="emp-search">
<input class="ed-input" id="emp-q" placeholder="Search employee (name or ID)…"
oninput="empSearchInput()" autocomplete="off">
<div class="emp-results hidden" id="emp-results"></div>
</div>
<div class="emp-sel hidden" id="emp-sel">
<span class="emp-sel-name" id="emp-sel-name"></span>
<span class="emp-sel-x" onclick="clearEmpSel()" title="Clear selection">&times;</span>
</div>
<select class="role-select" id="adm-role">
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
</select>
<button class="btn dark" onclick="assignAccess()">Assign</button>
</div>
<div class="adm-note" id="adm-note"></div>
</div>
<div class="card">
<div class="card-head">
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<div style="flex:1">
<div class="c-title">Users with access</div>
<div class="c-sub">Everyone holding Admin or Operator access</div>
</div>
<button class="btn" onclick="pyBridge&&pyBridge.load_admin()">⟳ Refresh</button>
</div>
<div id="adm-empty" class="empty-state hidden">
<div class="empty-title">No access granted yet</div>
<div class="empty-sub">Use “Assign access” above to grant Admin or Operator access.</div>
</div>
<div class="tbl-wrap" id="adm-tbl"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete confirm dialog -->
<div class="modal-mask" id="del-mask">
<div class="modal">
<div class="modal-title">Delete this row?</div>
<div class="modal-msg" id="del-msg">The report row will be permanently removed. This cannot be undone.</div>
<div class="modal-btns">
<button class="modal-btn plain" onclick="hideDelete()">Cancel</button>
<button class="modal-btn danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<!-- Batch clear: operator request / admin clear / dashboard approve / results.
Title, message and buttons are filled in by showClearModal(). -->
<div class="modal-mask" id="discard-mask">
<div class="modal">
<div class="modal-title" id="discard-title"></div>
<div class="modal-msg" id="discard-msg"></div>
<div class="modal-btns">
<button class="modal-btn plain" id="discard-cancel" onclick="hideDiscard()">Cancel</button>
<button class="modal-btn danger" id="discard-ok" onclick="confirmDiscard()"></button>
</div>
</div>
</div>
<!-- Deleted rows (admin audit) -->
<div class="modal-mask" id="deleted-mask">
<div class="modal" style="max-width:920px;width:92%">
<div class="modal-title">🗑 Deleted rows — audit</div>
<div class="modal-msg" id="deleted-msg">Rows removed from reports, and who removed them.</div>
<input class="ed-input" id="deleted-filter" oninput="renderDeletedTable()"
placeholder="Filter — emp id, practice, check#, who deleted…"
style="width:100%;margin-bottom:10px">
<div id="deleted-wrap" class="tbl-wrap" style="max-height:60vh;overflow:auto"></div>
<div class="modal-btns">
<button class="modal-btn plain" onclick="hideDeleted()">Close</button>
</div>
</div>
</div>
<!-- Rerun / batch-not-completed message box -->
<div class="modal-mask" id="rerun-mask">
<div class="modal">
<div class="modal-title">⚠️ This batch is not completed</div>
<div class="modal-msg" id="rerun-msg">
Some work is still pending. Click <b>Rerun</b> to finish it.
</div>
<div id="rerun-lists" style="margin:-6px 0 16px 0;font-size:12.5px;line-height:1.7"></div>
<div class="modal-btns">
<button class="modal-btn plain" onclick="hideRerunModal()">Later</button>
<button class="modal-btn" style="background:var(--ok);border-color:var(--ok);color:#fff"
onclick="rerunFromModal()">Rerun now</button>
</div>
</div>
</div>
<script>
let pyBridge=null, fileReady=false, filePath='', running=false, currentStep=-1, resuming=false;
const STEPS=%%STEPS%%;
new QWebChannel(qt.webChannelTransport, function(channel){
pyBridge = channel.objects.pyBridge;
// DO NOT connect js_message here. Incoming messages are delivered ONLY via
// window.__pyMsg below (driven by the Python js_message -> _push_to_js slot).
// Connecting the signal here TOO delivered every message twice → duplicate
// log lines (the early ones showed once only because this async callback had
// not run yet). One path only.
addLog('Bridge connected — ready','success');
});
window.__pyMsg = function(s){ const m=JSON.parse(s); handlePyMessage(m.event,m.data); };
function handlePyMessage(event,data){
if(event==='file_selected') setFile(data.name,data.path,data.sheets);
else if(event==='sheet_data') renderSheetData(data);
else if(event==='step'){ setProgress(data.pct,data.label); advanceStep(data.label); }
else if(event==='log') addLog(data.msg,data.type);
else if(event==='status'){ updateStatus(data.state,data.msg); if(data.state==='done')onDone(); if(data.state==='error')onError(data.msg); }
else if(event==='report_data') renderReport(data);
else if(event==='dashboard_data') renderDashboard(data);
else if(event==='batch_detail') onBatchDetail(data);
else if(event==='batch_state') setBatchChip(data.batch_id, data.live);
else if(event==='deleted_rows') showDeleted(data);
else if(event==='set_user') setUser(data.username, data.emp_id, data.role);
else if(event==='admin_data') renderAdmin(data);
else if(event==='emp_results') renderEmpResults(data);
else if(event==='admin_msg') onAdminMsg(data);
else if(event==='automation_state') onAutomationState(data.state);
else if(event==='resume_state') onResumeState(data);
else if(event==='clear_preview') onClearPreview(data);
else if(event==='clear_result') onClearResult(data);
else if(event==='batch_cleared') onBatchCleared();
else if(event==='reset_ui') resetUi();
else if(event==='apply_dark') applyDark(data.on);
else if(event==='cpstate') cpState(data.kind, data.cp);
}
/* Fresh state after logout -> re-login (possibly a different user). */
function resetUi(){
resuming=false; running=false; stopping=false; refreshCheck=false;
removeFile();
reportState=null;
dashState=null; dashF='all'; dashAll=false;
dashOpen=null; dashDetail={}; dashLoading={};
setBatchChip('',false);
adminRole=null; clearEmpSel(); hideEmpResults(); setAdmNote('','');
document.getElementById('emp-q').value='';
document.getElementById('adm-tbl').innerHTML='';
document.getElementById('prog-wrap').classList.remove('show');
document.getElementById('log').innerHTML='';
updateStatus('ready','System ready');
gotoTab('run');
updateRunBtn();
}
// Pause/Stop controls were removed — a run always goes to completion.
function showRunControls(on){ /* no-op */ }
/* Sent by Python after a mid-run page reload (loadFinished) and by the
double-run guard. 'stopping' = reload was used as STOP: the worker halts at
its next safe checkpoint; keep `running` true until the final "Stopped by
user" status arrives so a second run can't start during the wind-down. */
function onAutomationState(state){
if(state==='stopping'){
running=true; stopping=true;
updateStatus('running','Stopping automation...');
updateRunBtn();
addLog('Stop requested — the run will halt at the next safe point. The batch stays Pending, so you can resume.','warn');
} else if(state==='running'){
running=true; stopping=false;
updateStatus('running','Running automation...');
updateRunBtn();
}
}
// Resume: a still-Pending batch for today means upload is locked and the
// Trigger button becomes "Resume automation" (loads the fixed input file).
function onResumeState(data){
// One-shot branch for the refresh button: the batch is fully Completed
// (no pending work), so also clear the Excel -> fresh "upload a file" state.
if(refreshCheck && (!data || !data.pending)){
refreshCheck=false;
// The batch is finished, so clear unconditionally. NOT removeFile(): that
// is the user's Clear button, and while `resuming` is still set (a resumed
// batch that just completed) it opens the discard-batch dialog.
resuming=false;
resetFileUi();
addLog('Batch completed — file cleared, upload a new one to start.','info');
return;
}
refreshCheck=false;
if(!data || !data.pending){ resuming=false; hideRerunModal(); updateRunBtn(); return; }
resuming=true;
addLog("Resuming today's run (Batch #"+data.batch_id+") — upload is locked.","warn");
if(data.exists && pyBridge){
pyBridge.process_dropped_path(data.input_path); // -> file_selected -> setFile (preloads sheets)
} else {
addLog("Input file missing for resume: "+(data.input_path||"(unknown)"),"error");
}
updateRunBtn();
// (The old "not completed — click Rerun" popup was removed per request. A
// pending batch is still made obvious: the upload stays locked and the
// Trigger button reads "Resume automation", plus the log line above.)
}
/* ---- Rerun / not-completed message box ---- */
function showRerunModal(data){
const listEl=document.getElementById('rerun-lists');
const done=(data&&data.done)||[], todo=(data&&data.todo)||[];
let html='';
if(done.length)
html+='<div style="color:var(--ok)"><b>✓ Completed:</b> '+
done.map(escapeHtml).join(', ')+'</div>';
if(todo.length)
html+='<div style="color:var(--warn)"><b>⏳ Needs to complete:</b> '+
todo.map(escapeHtml).join(', ')+'</div>';
listEl.innerHTML=html;
document.getElementById('rerun-msg').innerHTML =
'Batch #'+((data&&data.batch_id)||'')+' did not finish. '+
'The completed items are skipped — click <b>Rerun now</b> to finish only what is left.';
document.getElementById('rerun-mask').classList.add('show');
}
function hideRerunModal(){ document.getElementById('rerun-mask').classList.remove('show'); }
function rerunFromModal(){
hideRerunModal();
if(running||stopping) return;
if(!fileReady){
// The input file is still being (re)loaded for the resume — try shortly.
addLog('Preparing the input file for rerun… click Rerun again in a moment.','warn');
return;
}
doRun(); // same as clicking "Resume automation" — resumes the pending batch
}
let userRole='operator';
const ROLE_LABEL={super:'Super Admin',admin:'Admin',operator:'Operator'};
function setUser(name, empId, role){
userRole=role||'operator';
const ini=(name||'U').slice(0,2).toUpperCase();
document.getElementById('side-user-name').textContent=name;
document.getElementById('side-avatar').textContent=ini;
document.getElementById('side-role').textContent =
(empId ? ('ID: '+empId+' · ') : '') + (ROLE_LABEL[userRole]||'Operator');
// Operators never see the Admin tab (Python re-checks the role anyway).
const isAdmin = (userRole==='super'||userRole==='admin');
document.getElementById('n-admin').classList.toggle('hidden', !isAdmin);
// The Report tab "View deleted rows" audit button is admin/super only.
const rd=document.getElementById('rpt-deleted-btn');
if(rd) rd.classList.toggle('hidden', !isAdmin);
}
// Click the bottom-left profile to reveal Logout; click Logout to sign out.
function toggleLogout(){ document.getElementById('logout-btn').classList.toggle('show'); }
function doLogout(){
if(running){ addLog('Cannot logout while the automation is running.','warn'); return; }
if(pyBridge) pyBridge.logout();
}
/* ---- dark / night mode ---- */
function applyDark(on){
document.body.classList.toggle('dark', !!on);
const b=document.getElementById('mode-btn'); if(b) b.textContent=on?'☀️':'🌙';
try{ localStorage.setItem('tebra_dark', on?'1':'0'); }catch(e){}
if(pyBridge) pyBridge.set_dark(!!on); // persist for the login page too
}
function toggleDark(){ applyDark(!document.body.classList.contains('dark')); }
(function(){ let on=false; try{ on=localStorage.getItem('tebra_dark')==='1'; }catch(e){} if(on) applyDark(true); })();
const TAB_INFO={run:['Run automation','Upload the input Excel and start ERA processing'],
dashboard:['Dashboard','Your batches at a glance — completed, in progress, not completed'],
report:['Run report','Success / Need Attention / Failed from the posting log'],
admin:['Admin','Manage who has Admin and Operator access']};
function gotoTab(t){
if(t==='admin' && !(userRole==='super'||userRole==='admin')) return;
['run','dashboard','report','admin'].forEach(id=>{
document.getElementById('tab-'+id).classList.toggle('hidden',id!==t);
// keep the Operator's Admin nav item hidden while clearing 'active'
const nav=document.getElementById('n-'+id);
nav.classList.toggle('active',id===t);
if(id==='admin') nav.classList.toggle('hidden',!(userRole==='super'||userRole==='admin'));
});
document.getElementById('tb-title').textContent=TAB_INFO[t][0];
document.getElementById('tb-sub').textContent=TAB_INFO[t][1];
if(t==='report' && pyBridge) pyBridge.load_latest_report();
if(t==='dashboard' && pyBridge) pyBridge.load_dashboard();
if(t==='admin' && pyBridge) pyBridge.load_admin();
}
/* The manual refresh button was removed. Resume/rerun is now detected
automatically on every login (check_resume_state), and the report/dashboard
refresh on tab-click (gotoTab). softResetProgress + refreshCheck/stopping are
kept only for the context-menu page-reload safety net (onDone/onError). */
let refreshCheck=false, stopping=false;
function softResetProgress(){
currentStep=-1;
if(runTimer){ clearInterval(runTimer); runTimer=null; }
runStart=0;
const pt=document.getElementById('prog-time'); if(pt) pt.textContent='';
document.getElementById('prog-wrap').classList.remove('show');
document.getElementById('log').innerHTML='';
updateStatus('ready','System ready');
}
/* ---- dashboard ---- */
let dashState=null, dashF='all';
// Drill-down: id of the ONE expanded card (single-open accordion), a per-batch
// cache of what Python sent back, and an in-flight guard so a double click
// cannot queue two workers behind the shared read connection's lock.
let dashOpen=null, dashDetail={}, dashLoading={};
const DASH_META={completed:['Completed','var(--ok)'],
inprogress:['In progress','var(--warn)'],
notcompleted:['Not completed','var(--bad)'],
cleared:['Cleared','#6b5bd6']};
// CompanyProgress / LocationProgress are Pending | Success. Map onto the three
// visual buckets the dashboard already uses so the colours match the cards.
const CO_STATE={Success:'completed',Pending:'inprogress'};
function coCls(st){ return CO_STATE[st]||'notcompleted'; }
/* Single-open accordion. Clicking the open card closes it; clicking another
card closes the first. The detail arrives ASYNC, so we re-render immediately
with a loading row and again when the reply lands. */
function toggleBatch(id){
id=String(id);
if(dashOpen===id){ dashOpen=null; renderBatchList(); return; }
dashOpen=id;
if(!dashDetail[id] && !dashLoading[id]){
if(pyBridge){ dashLoading[id]=true; pyBridge.load_batch_detail(id); }
else dashDetail[id]={error:'No Python bridge — UI preview only.'};
}
renderBatchList();
}
function onBatchDetail(data){
const id=String((data&&data.batch_id)||'');
if(!id) return;
delete dashLoading[id];
dashDetail[id] = (!data || data.companies==null)
? {error:(data&&data.msg)||'Could not load batch details.'}
: {companies:data.companies};
// Late reply for a card the user already closed (or switched away from):
// keep it cached, but don't repaint.
if(dashOpen===id) renderBatchList();
}
function countUp(el,target){
// rAF is throttled/paused in hidden tabs — set the real value first so the
// number is always correct, then animate only when visible.
el.textContent=target;
if(document.hidden)return;
const start=performance.now(), dur=600, from=0;
function tick(now){
const k=Math.min(1,(now-start)/dur), eased=1-Math.pow(1-k,3);
el.textContent=Math.round(from+(target-from)*eased);
if(k<1)requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
let dashAll=false;
function renderDashboard(data){
if(!data.batches){ addLog(data.msg||'Dashboard unavailable','error'); return; }
dashState=data.batches;
dashAll=!!data.all_users;
// Fresh dashboard data -> every cached detail is now potentially stale.
dashDetail={}; dashLoading={};
if(dashOpen && !dashState.some(b=>b.batch_id===dashOpen)){
dashOpen=null; // that batch is gone
} else if(dashOpen && pyBridge){
dashLoading[dashOpen]=true;
pyBridge.load_batch_detail(dashOpen); // re-fetch, stays open
}
// One counter per DASH_META state — the legend and donut iterate those keys,
// so a state without a counter here renders as NaN.
const n={completed:0,inprogress:0,notcompleted:0,cleared:0}, era={c:0,e:0,p:0};
dashState.forEach(b=>{ n[b.state]++; era.c+=b.era_completed; era.e+=b.era_error; era.p+=b.era_pending; });
countUp(document.getElementById('kpi-total'),dashState.length);
countUp(document.getElementById('kpi-done'),n.completed);
countUp(document.getElementById('kpi-prog'),n.inprogress);
countUp(document.getElementById('kpi-notdone'),n.notcompleted);
document.querySelector('.kpi.prog').classList.toggle('live',n.inprogress>0);
// donut: conic-gradient with 2px surface gaps between non-zero segments
const total=dashState.length;
document.getElementById('dh-num').textContent=total;
const donut=document.getElementById('dash-donut');
if(total===0){ donut.style.background='conic-gradient(var(--border) 0 100%)'; }
else{
const parts=Object.keys(DASH_META).map(st=>[st,n[st]]).filter(p=>p[1]>0);
const GAP=parts.length>1?1.2:0; // degrees ≈ 2px gap at this radius
let deg=0, stops=[];
parts.forEach(([st,cnt])=>{
const span=cnt/total*360;
stops.push(`transparent ${deg}deg ${deg+GAP/2}deg`);
stops.push(`${DASH_META[st][1]} ${deg+GAP/2}deg ${deg+span-GAP/2}deg`);
stops.push(`transparent ${deg+span-GAP/2}deg ${deg+span}deg`);
deg+=span;
});
donut.style.background=`conic-gradient(${stops.join(',')})`;
}
document.getElementById('donut-legend').innerHTML=
Object.keys(DASH_META).map(st=>
`<div class="dl-row"><span class="dl-dot" style="background:${DASH_META[st][1]}"></span>`+
`${DASH_META[st][0]}<span class="dl-count">${n[st]}</span></div>`).join('');
// ERA totals card
document.getElementById('es-done').textContent=era.c;
document.getElementById('es-err').textContent=era.e;
document.getElementById('es-pend').textContent=era.p;
const eraTotal=era.c+era.e+era.p, bar=document.getElementById('era-bar');
bar.innerHTML = eraTotal===0 ? '' :
[['c',era.c],['e',era.e],['p',era.p]].filter(s=>s[1]>0)
.map(s=>`<div class="seg ${s[0]}" style="flex:${s[1]}"></div>`).join('');
document.getElementById('era-bar-note').textContent =
eraTotal===0 ? 'No ERAs logged yet.' :
`${eraTotal} ERA row(s) across ${total} batch(es) — ✓ ${era.c} · ✗ ${era.e} · ● ${era.p}`;
renderBatchList();
}
/* ---- deleted-rows audit modal (admin) ---- */
let deletedData=null;
function showDeleted(data){
if(data.denied){ addLog('Only an admin can view deleted rows.','warn'); return; }
deletedData=data;
const f=document.getElementById('deleted-filter'); if(f) f.value='';
renderDeletedTable();
document.getElementById('deleted-mask').classList.add('show');
}
function renderDeletedTable(){
const data=deletedData; if(!data) return;
const wrap=document.getElementById('deleted-wrap');
const msg=document.getElementById('deleted-msg');
if(data.rows===null){ wrap.innerHTML=''; msg.textContent=data.msg||'Could not load deleted rows.'; return; }
const hdr=data.header||[], all=data.rows||[];
const q=(document.getElementById('deleted-filter').value||'').trim().toLowerCase();
const rows=q ? all.filter(r=>r.cells.some(c=>String(c).toLowerCase().includes(q))) : all;
msg.textContent = !all.length ? 'No deleted rows yet.'
: (rows.length+' of '+all.length+' deleted row(s)'+(q?' (filtered)':'')+' — and who removed each.');
wrap.innerHTML = !rows.length ? '' :
'<table class="data-tbl" style="width:100%;border-collapse:collapse;font-size:12.5px">'
+'<tr>'+hdr.map(h=>'<th>'+escapeHtml(h)+'</th>').join('')+'</tr>'
+rows.map(r=>'<tr>'+r.cells.map(c=>'<td>'+escapeHtml(c)+'</td>').join('')+'</tr>').join('')
+'</table>';
}
function hideDeleted(){ document.getElementById('deleted-mask').classList.remove('show'); }
function dashFilter(f){
dashF=f;
document.querySelectorAll('#dash-chips .chip').forEach(c=>
c.classList.toggle('active',c.dataset.f===f));
renderBatchList();
}
function renderBatchList(){
if(!dashState)return;
const list=document.getElementById('batch-list');
const rows=dashState.filter(b=>dashF==='all'||b.state===dashF);
document.getElementById('dash-empty').classList.toggle('hidden',rows.length>0);
// The expanded card may have just been filtered out — collapse it so the
// caret state and dashOpen can never disagree with what is on screen.
if(dashOpen && !rows.some(b=>b.batch_id===dashOpen)) dashOpen=null;
list.innerHTML=rows.map(b=>{
// progress = companies done/total; fall back to ERA counts for batches
// whose companies were never registered
let done=b.companies_done, tot=b.companies_total, unit='companies';
if(tot===0){ done=b.era_completed; tot=b.era_total; unit='ERAs'; }
const pct=tot===0?0:Math.round(done/tot*100);
const eras=[];
if(b.era_completed)eras.push(`<span class="era-chip c" title="ERAs completed">✓ ${b.era_completed}</span>`);
if(b.era_error)eras.push(`<span class="era-chip e" title="ERAs with errors">✗ ${b.era_error}</span>`);
if(b.era_pending)eras.push(`<span class="era-chip p" title="ERAs pending">● ${b.era_pending}</span>`);
// Admin/Super Admin view spans every user — show who owns each batch.
const owner=(dashAll && b.emp)?(' · '+b.emp):'';
const open=(dashOpen===b.batch_id);
// Batch clear: who cleared it, or who is waiting for an admin to.
const awaiting=b.clear_requested_by && (b.state==='inprogress'||b.state==='notcompleted');
const clearNote=b.state==='cleared'
? `<div class="bc-clear-note">Cleared by ${escH(b.cleared_by)}${b.cleared_at?' · '+escH(b.cleared_at):''}</div>`
: awaiting
? `<div class="bc-clear-note">Clear requested by ${escH(b.clear_requested_by)}${b.clear_requested_at?' · '+escH(b.clear_requested_at):''}</div>`
: '';
// Admin view only; Python re-checks the role, so this button is a
// convenience, not the security boundary. stopPropagation: the card's own
// click toggles the drill-down.
const approve=(dashAll && awaiting)
? `<button class="btn bc-approve" onclick="event.stopPropagation();askApproveClear('${b.batch_id}')">Approve clear</button>`
: '';
// b.batch_id is str(BIGINT) from progress.get_dashboard — digits only, so
// it is safe unescaped inside these onclick attributes.
return `<div class="batch-item${open?' open':''}">
<div class="batch-card" onclick="toggleBatch('${b.batch_id}')"
title="${open?'Hide':'Show'} the companies in this batch">
<div class="bc-status ${b.state}"></div>
<div class="bc-id">
<div class="b-num">#${b.batch_id}${b.is_today?'<span class="today-chip">TODAY</span>':''}</div>
<div class="b-date">${b.date}${owner}</div>
${clearNote}
</div>
<div class="bc-prog">
<div class="bp-head"><span>${done}/${tot} ${unit}</span><span>${pct}%</span></div>
<div class="bp-bg"><div class="bp-fill${pct===100?' full':''}" style="width:${pct}%"></div></div>
</div>
<div class="bc-eras">${eras.join('')||'<span class="era-chip" style="color:var(--muted)">no ERAs</span>'}</div>
${approve}
<div class="bc-pill ${b.state}"><span class="pd"></span>${DASH_META[b.state][0]}</div>
<div class="bc-caret">▾</div>
</div>
${open?renderBatchDetail(b.batch_id):''}
</div>`;
}).join('');
}
/* The expanded panel: companies with their status, locations nested under a
company that HasLocations. Status only — no ERA counts. */
function renderBatchDetail(id){
const d=dashDetail[id];
if(!d) return `<div class="bc-detail"><div class="bd-load">
<span class="bd-dot inprogress"></span>Loading companies…</div></div>`;
if(d.error) return `<div class="bc-detail">
<div class="bd-msg bad">${escH(d.error)}</div></div>`;
const cos=d.companies||[];
if(!cos.length) return `<div class="bc-detail">
<div class="bd-msg">No companies were tracked for this batch.</div></div>`;
return '<div class="bc-detail">'+cos.map(c=>{
const locs=c.locations||[];
let sub='';
if(c.has_locations){
sub = locs.length
? '<div class="bd-locs">'+locs.map(l=>
`<div class="bd-loc"><span class="bd-dot ${coCls(l.status)}"></span>`+
`<span class="bd-name">${escH(l.name)}</span>`+
`<span class="bd-pill ${coCls(l.status)}">${escH(l.status)}</span></div>`
).join('')+'</div>'
: '<div class="bd-locs"><div class="bd-loc bd-none">No locations recorded yet.</div></div>';
}
return `<div class="bd-co">
<div class="bd-co-head">
<span class="bd-dot ${coCls(c.status)}"></span>
<span class="bd-name">${escH(c.name)}</span>
<span class="bd-pill ${coCls(c.status)}">${escH(c.status)}</span>
</div>${sub}</div>`;
}).join('')+'</div>';
}
/* ---- Admin tab (access management) ----
Visibility: Super Admin + Admin. All permission rules are re-enforced in the
Python bridge; the JS below only shapes the UI to match:
assign Admin/Operator -> super, admin
remove Operator -> super, admin
remove Admin -> super only (Admin can NOT remove another Admin) */
let adminRole=null, selEmp=null, empTimer=null;
function escH(s){ const d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
function renderAdmin(data){
if(data.denied){ gotoTab('run'); return; }
adminRole=data.role;
// Both Super Admin and Admin may grant Admin or Operator access.
document.getElementById('adm-assign-sub').textContent =
'Search an employee, pick a role (Admin or Operator), assign';
const tbl=document.getElementById('adm-tbl');
if(data.rows===null){ tbl.innerHTML=''; setAdmNote(data.msg||'Could not load the access list.','err'); return; }
document.getElementById('adm-empty').classList.toggle('hidden',data.rows.length>0);
if(!data.rows.length){ tbl.innerHTML=''; return; }
// Super may remove anyone; an Admin may remove Operators only.
const canRemoveRow=r=>adminRole==='super'||(adminRole==='admin'&&r.role==='Operator');
const showRemoveCol=data.rows.some(canRemoveRow);
tbl.innerHTML='<table class="data-tbl" style="width:100%;border-collapse:collapse;font-size:12.5px">'
+'<tr><th>Employee ID</th><th>Name</th><th>Role</th><th>Assigned by</th><th>Assigned on</th>'
+(showRemoveCol?'<th></th>':'')+'</tr>'
+data.rows.map(r=>'<tr>'
+`<td>${escH(r.emp_id)}</td><td>${escH(r.name)}</td>`
+`<td><span class="pill role-${r.role==='Admin'?'admin':'operator'}">${escH(r.role)}</span></td>`
+`<td>${escH(r.assigned_by)}</td><td>${escH(r.assigned_on)}</td>`
+(showRemoveCol?`<td style="text-align:right">${canRemoveRow(r)?`<button class="adm-remove" onclick="removeAccess(this,'${escH(r.emp_id)}','${escH(r.role)}')">Remove</button>`:''}</td>`:'')
+'</tr>').join('')
+'</table>';
}
/* Employee search — debounced proxy to the employee microservice. */
function empSearchInput(){
clearTimeout(empTimer);
const q=document.getElementById('emp-q').value.trim();
if(q.length<2){ hideEmpResults(); return; }
empTimer=setTimeout(()=>{ if(pyBridge) pyBridge.search_employees(q); },250);
}
function renderEmpResults(data){
// Ignore stale responses for a query the user has already typed past.
if(data.q!==document.getElementById('emp-q').value.trim()) return;
if(data.msg) setAdmNote(data.msg,'err');
const box=document.getElementById('emp-results');
if(!data.employees.length){ hideEmpResults(); return; }
// args are HTML-attribute-escaped (&#39;) so names with quotes can't break out
const attr=v=>JSON.stringify(v).replace(/'/g,'&#39;').replace(/</g,'\\u003c');
box.innerHTML=data.employees.map(e=>
`<div class="emp-item" onclick='pickEmp(${attr(e.emp_id)},${attr(e.name)})'>`
+`<span>${escH(e.name)}</span>`
+`<span style="color:var(--muted);font-size:11.5px;font-weight:600">${escH(e.emp_id)}</span>`
+`</div>`).join('');
box.classList.remove('hidden');
}
function hideEmpResults(){ const b=document.getElementById('emp-results'); b.classList.add('hidden'); b.innerHTML=''; }
function pickEmp(id,name){
selEmp={emp_id:id,name:name};
document.getElementById('emp-sel-name').textContent=name+' ('+id+')';
document.getElementById('emp-sel').classList.remove('hidden');
document.getElementById('emp-q').value='';
hideEmpResults();
}
function clearEmpSel(){ selEmp=null; document.getElementById('emp-sel').classList.add('hidden'); }
function assignAccess(){
if(!selEmp){ setAdmNote('Search and select an employee first.','err'); return; }
const role=document.getElementById('adm-role').value;
if(pyBridge) pyBridge.assign_access(JSON.stringify({emp_id:selEmp.emp_id,name:selEmp.name,role:role}));
}
/* Remove = two-click confirm (first click arms the button, second executes). */
function removeAccess(btn,empId,role){
if(!btn.classList.contains('confirm')){
btn.classList.add('confirm'); btn.textContent='Confirm remove?';
setTimeout(()=>{ btn.classList.remove('confirm'); btn.textContent='Remove'; },3500);
return;
}
if(pyBridge) pyBridge.remove_access(empId,role||'');
}
function setAdmNote(msg,kind){
const n=document.getElementById('adm-note');
n.textContent=msg; n.className='adm-note '+(kind||'');
}
function onAdminMsg(data){
setAdmNote(data.msg, data.ok?'ok':'err');
if(data.ok) clearEmpSel();
}
/* ---- drag & drop / file ---- */
function dzOver(e){e.preventDefault();document.getElementById('dz').classList.add('over');}
function dzLeave(){document.getElementById('dz').classList.remove('over');}
function dzDrop(e){e.preventDefault();dzLeave();const f=e.dataTransfer.files[0];if(f)fileChosen(f);}
function fileChosen(f){
if(!f)return;
const ext='.'+f.name.split('.').pop().toLowerCase();
if(!['.xlsx','.xlsm','.xls'].includes(ext)){addLog('Invalid file type — use .xlsx .xlsm .xls','warn');return;}
// Always route through the bridge so it resolves the real path + detects
// sheets (QWebEngine hides f.path for browser-picked files, so send the name).
if(pyBridge) pyBridge.process_dropped_path(f.path || f.name);
else setFile(f.name, f.path || f.name, []);
}
function setFile(name,path,sheets){
fileReady=true; filePath=path;
document.getElementById('dz').style.display='none';
document.getElementById('file-row').classList.remove('hidden');
document.getElementById('f-name').textContent=name;
document.getElementById('f-path').textContent=path;
document.getElementById('clear-bar').classList.add('show');
updateRunBtn();
addLog('File loaded: '+name,'success');
renderSheetButtons(sheets||[]);
}
function renderSheetButtons(sheets){
const tabs=document.getElementById('sheet-tabs');
document.getElementById('sheet-preview').classList.remove('show');
if(!sheets.length){tabs.classList.add('hidden');tabs.innerHTML='';return;}
tabs.classList.remove('hidden');
tabs.innerHTML=sheets.map((s,i)=>`<div class="sheet-btn" id="sb-${i}" onclick="openSheet('${s.replace(/'/g,"\\'")}',${i})">${escapeHtml(s)}</div>`).join('');
addLog('Detected '+sheets.length+' sheet(s): '+sheets.join(', '),'info');
// Auto-open the first sheet so the user sees the Excel right after upload.
openSheet(sheets[0], 0);
}
function openSheet(name,idx){
document.querySelectorAll('.sheet-btn').forEach(b=>b.classList.remove('active'));
const b=document.getElementById('sb-'+idx); if(b)b.classList.add('active');
document.getElementById('tbl-wrap').innerHTML='<div class="preview-empty">Loading...</div>';
document.getElementById('sheet-preview').classList.add('show');
document.getElementById('preview-title').textContent=name;
document.getElementById('preview-count').textContent='';
if(pyBridge)pyBridge.read_sheet(filePath,name);
}
function renderSheetData(data){
const wrap=document.getElementById('tbl-wrap');
if(data.error){wrap.innerHTML='<div class="preview-empty">Error: '+escapeHtml(data.error)+'</div>';return;}
document.getElementById('preview-count').textContent=data.total+' row(s)';
if(!data.header.length){wrap.innerHTML='<div class="preview-empty">Sheet is empty</div>';return;}
const isLogin=(data.sheet||'').toLowerCase().includes('login');
let h='<table class="data-tbl"><thead><tr>';
data.header.forEach(x=>h+='<th>'+escapeHtml(x)+'</th>');
h+='</tr></thead><tbody>';
data.rows.forEach(row=>{
h+='<tr>';
row.forEach((cell,ci)=>{
const col=(data.header[ci]||'').toLowerCase();
if(isLogin && col.includes('password')) h+='<td class="mask">'+'•'.repeat(Math.min(String(cell).length,10))+'</td>';
else h+='<td>'+escapeHtml(cell)+'</td>';
});
h+='</tr>';
});
wrap.innerHTML=h+'</tbody></table>';
}
function removeFile(){
// A locked (or running) batch used to be a dead end. Now Clear file asks
// Python what applies — an operator raises a clear request for an admin to
// approve; an admin clears their own batch. Nothing is deleted either way.
if(running||resuming){ if(pyBridge) pyBridge.clear_batch_preview(); return; }
resetFileUi();
addLog('File cleared','info');
}
/* One modal for the whole clear flow. clearMode says what its confirm button
does: 'run' -> clear_batch_action (request / admin clear of own batch),
a batch id -> approve_batch_clear (dashboard), null -> info only. */
let clearMode=null;
function showClearModal(title, html, okLabel){
document.getElementById('discard-title').textContent=title;
document.getElementById('discard-msg').innerHTML=html;
const ok=document.getElementById('discard-ok');
ok.textContent=okLabel||''; ok.style.display=okLabel?'':'none';
document.getElementById('discard-cancel').textContent=okLabel?'Cancel':'OK';
document.getElementById('discard-mask').classList.add('show');
}
function hideDiscard(){ document.getElementById('discard-mask').classList.remove('show'); clearMode=null; }
function confirmDiscard(){
const m=clearMode; hideDiscard();
if(!pyBridge||!m) return;
if(m==='run') pyBridge.clear_batch_action(); else pyBridge.approve_batch_clear(m);
}
const CLEAR_IMPORTANT='<div class="clear-important"><b>IMPORTANT:</b> after sending the request, '+
'<b>close this app</b> so the run stops. Once an admin approves, log in again — the upload will be unlocked.</div>';
function onClearPreview(d){
if(!d) return;
const id=escH(d.batch_id);
if(!d.admin){
if(d.requested_by){
clearMode=null;
showClearModal('Clear request already sent',
'Sent '+escH(d.requested_at)+' — waiting for an admin to approve it.<br><br>'+
'<b>Close this app now</b> if the run is still going, and log in again after the admin approves.','');
} else {
clearMode='run';
showClearModal('Send a clear request?',
'Clearing batch #'+id+' needs admin approval. Nothing is deleted — rows already in the '+
'Report tab stay there.'+CLEAR_IMPORTANT,'Send request');
}
} else {
clearMode='run';
showClearModal('Clear batch #'+id+'?', d.report_rows>0
? 'This batch has <b>'+d.report_rows+' report row(s)</b>. You are an admin, so you can clear it. '+
'Posted work stays in the Report tab.'
: 'Clear this batch and start over? It has no report rows yet.','Clear batch');
}
}
function askApproveClear(id){
const b=(dashState||[]).find(x=>x.batch_id===id); if(!b) return;
clearMode=id;
showClearModal('Approve clear for batch #'+escH(id)+'?',
'Requested by <b>'+escH(b.clear_requested_by)+'</b>'+(b.clear_requested_at?' at '+escH(b.clear_requested_at):'')+
'. The batch is closed out and its owner can upload a new file. Nothing is deleted — its report rows stay.'+
'<br><br>Their app must be <b>closed</b>: if the batch logged activity in the last 2 minutes, this is refused.',
'Approve clear');
}
function onClearResult(d){ if(d){ clearMode=null; showClearModal(d.ok?'Done':'Not done', escH(d.msg), ''); } }
function onBatchCleared(){ resuming=false; resetFileUi(); }
function resetFileUi(){
fileReady=false; filePath='';
document.getElementById('dz').style.display='';
document.getElementById('file-row').classList.add('hidden');
document.getElementById('fi').value='';
document.getElementById('sheet-tabs').classList.add('hidden');
document.getElementById('sheet-tabs').innerHTML='';
document.getElementById('sheet-preview').classList.remove('show');
document.getElementById('clear-bar').classList.remove('show');
document.getElementById('tbl-wrap').innerHTML='';
updateRunBtn();
}
/* Persistent batch number in the Run tab. Empty id hides it — a stale batch
number is worse than none, so Python clears it explicitly at every site that
forgets/clears self.batch_id. */
function setBatchChip(id,live){
const c=document.getElementById('batch-chip'); if(!c) return;
const has=!!(id&&String(id).trim());
c.classList.toggle('hidden',!has);
c.classList.toggle('live',has&&!!live);
if(has) document.getElementById('batch-chip-num').textContent='#'+id;
}
/* ---- run ---- */
function updateRunBtn(){
const btn=document.getElementById('top-run-btn'),lbl=document.getElementById('top-run-lbl');
if(stopping){btn.className='top-run-btn going';lbl.textContent='Stopping...';}
else if(running){btn.className='top-run-btn going';lbl.textContent='Running...';}
else if(fileReady){btn.className='top-run-btn on';lbl.textContent=resuming?'Resume automation':'Trigger automation';}
else{btn.className='top-run-btn off';lbl.textContent='Upload a file to continue';}
}
function doRun(){
if(!fileReady||running||stopping)return;
running=true; currentStep=0;
document.getElementById('prog-wrap').classList.add('show');
document.getElementById('log').innerHTML='';
resetCheckpoints();
startRunTimer();
renderSteps(); updateRunBtn(); showRunControls(true);
if(pyBridge)pyBridge.start_automation(filePath);
else addLog('No Python bridge — UI preview only','warn');
}
/* ---- batch elapsed-time (live, in seconds) ---- */
let runTimer=null, runStart=0;
function fmtElapsed(sec){
if(sec<60) return sec+'s';
const m=Math.floor(sec/60), s=sec%60;
return m+'m '+s+'s ('+sec+'s)';
}
function fmtClock(sec){ // mm:ss (e.g. 00:15, 02:07)
const m=Math.floor(sec/60), s=sec%60;
return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');
}
function tickRunTimer(){
const secs=Math.round((Date.now()-runStart)/1000);
const el=document.getElementById('prog-time'); if(el) el.textContent='⏱ '+fmtElapsed(secs);
const ce=document.getElementById('cp-elapsed'); if(ce) ce.textContent='⏱ '+fmtClock(secs);
tickCpTimer(); // live timer on the active checkpoint box
}
/* ---- 7-box checkpoint tracker ----
Each box: a plain-English step name + its MAX time. The active box shows a
live timer counting toward its max; finished boxes turn green with a check;
the next process (new location/company) restarts the later boxes. Built for
non-technical users — no code, just "which step are we on and how long". */
const CP_DEFS=[
{n:1, name:'Sign in', max:300},
{n:2, name:'Open practice list', max:300},
{n:3, name:'Open practice', max:300},
{n:4, name:'Load ERAs', max:300},
{n:5, name:'Post payments', max:0},
{n:6, name:'Convert PDFs', max:0},
{n:7, name:'Finish up', max:300},
];
let cpActive=0, cpActiveStart=0;
function fmtMax(sec){ if(!sec) return 'auto-retry ×3'; return sec%60===0 ? 'max '+(sec/60)+' min' : 'max '+Math.round(sec/60)+' min'; }
function renderCpTrack(){
const box=document.getElementById('cp-track'); if(!box) return;
box.innerHTML=CP_DEFS.map(d=>
'<div class="cp-box pending" id="cpbox-'+d.n+'">'+
'<div class="cp-num">'+d.n+'</div>'+
'<div class="cp-name">'+d.name+'</div>'+
'<div class="cp-meta" id="cpmeta-'+d.n+'">'+fmtMax(d.max)+'</div>'+
'</div>').join('');
}
function resetCheckpoints(){
cpActive=0; cpActiveStart=0;
renderCpTrack();
const ce=document.getElementById('cp-elapsed'); if(ce) ce.textContent='';
}
function cpBox(n){ return document.getElementById('cpbox-'+n); }
function cpState(kind,n){
if(!n) return;
if(kind==='active'){
// boxes below n = done, n = active (restart its timer), above n = pending
CP_DEFS.forEach(d=>{
const b=cpBox(d.n); if(!b) return;
if(d.n<n){ b.className='cp-box done'; setCpMeta(d.n,'✓ done'); }
else if(d.n===n){ b.className='cp-box active'; }
else { b.className='cp-box pending'; setCpMeta(d.n,fmtMax(d.max)); }
});
cpActive=n; cpActiveStart=Date.now();
tickCpTimer();
} else if(kind==='done'){
const b=cpBox(n); if(b){ b.className='cp-box done'; setCpMeta(n,'✓ done'); }
if(cpActive===n){ cpActive=0; }
} else if(kind==='stuck'){
const b=cpBox(n); if(b){ b.className='cp-box stuck'; setCpMeta(n,'⚠ reopening Tebra'); }
cpActive=0;
}
}
function setCpMeta(n,txt){ const m=document.getElementById('cpmeta-'+n); if(m) m.textContent=txt; }
function tickCpTimer(){
if(!cpActive||!cpActiveStart) return;
const secs=Math.round((Date.now()-cpActiveStart)/1000);
const def=CP_DEFS[cpActive-1];
// No max (CP5): show elapsed only — recovery is auto-retry, not a clock.
if(def && !def.max){ setCpMeta(cpActive, fmtClock(secs)); return; }
setCpMeta(cpActive, fmtClock(secs)+' / '+fmtClock(def?def.max:0));
}
function startRunTimer(){
runStart=Date.now();
if(runTimer) clearInterval(runTimer);
tickRunTimer();
runTimer=setInterval(tickRunTimer,1000);
}
function stopRunTimer(prefix){
if(runTimer){ clearInterval(runTimer); runTimer=null; }
const el=document.getElementById('prog-time'); if(!el||!runStart) return;
el.textContent=(prefix||'⏱ ')+fmtElapsed(Math.round((Date.now()-runStart)/1000));
}
function setProgress(pct,label){
document.getElementById('prog-fill').style.width=pct+'%';
document.getElementById('prog-pct').textContent=pct+'%';
if(label)document.getElementById('prog-lbl').textContent=label;
}
function advanceStep(label){
const i=STEPS.findIndex(s=>label&&label.startsWith(s));
if(i>=0)currentStep=i; renderSteps();
}
function renderSteps(){
document.getElementById('steps').innerHTML=STEPS.map((s,i)=>{
const st=i<currentStep?'done':i===currentStep?'active':'pending';
const dot=st==='done'?'✓':(i+1);
return `<div class="step ${st}"><div class="step-dot">${dot}</div><div class="step-lbl">${s}</div></div>`;
}).join('');
}
function addLog(msg,type){
/* No timestamp — billing users read this log; exact times live in the
BatchLog DB table. */
const box=document.getElementById('log');
const d=document.createElement('div');
d.className='log-line '+(type||'');
d.textContent=msg;
box.appendChild(d); box.scrollTop=box.scrollHeight;
}
function updateStatus(state,msg){
document.getElementById('sys-dot').className='dot '+state;
document.getElementById('sys-txt').textContent=msg;
}
function onDone(){
running=false;
if(stopping){ stopping=false; refreshCheck=true; stopRunTimer(); softResetProgress(); updateRunBtn(); return; }
stopRunTimer('✓ Completed in ');
currentStep=STEPS.length-1;renderSteps();setProgress(100,'Completed');updateRunBtn();showRunControls(false);
}
function onError(msg){
running=false;
// A refresh-initiated stop lands here ('Stopped by user'): now that the
// worker has actually exited, complete the deferred reset. The backend's
// check_resume_state (sent right after this status) then decides the file.
if(stopping){
stopping=false; refreshCheck=true;
stopRunTimer();
softResetProgress(); updateRunBtn();
addLog('Run stopped'+(msg?': '+msg:'')+'.','warn');
return;
}
stopRunTimer('✕ Failed after ');
updateRunBtn();showRunControls(false);addLog('Error: '+msg,'error');
}
/* ---- report (paginated + edit/delete + downloads) ---- */
let reportState=null;
let frozenCols=[]; // [{key:'ACT'|'PDF'|<header idx>, left, width}]
let pendingDeleteId=null;
// header-name -> payload field for the row editor
const EDIT_COLS={'insurance name':'insurance','check#':'check_no','check date':'check_date',
'check amt':'check_amt','comments':'comments'};
function frozenBits(pos){
if(pos>=frozenCols.length) return {cls:'',sty:''};
const f=frozenCols[pos];
return {cls:' class="fz"',
sty:' style="left:'+f.left+'px;min-width:'+f.width+'px;width:'+f.width+'px;max-width:'+f.width+'px"'};
}
function catOf(status){
const s=String(status||'').trim().toLowerCase();
if(['processed successfully','posted','completed','success'].includes(s)) return 'success';
if(['error','failed'].includes(s)) return 'failed';
return 'attention';
}
function renderReport(data){
const wrap=document.getElementById('rtbl-wrap'), empty=document.getElementById('report-empty'), pager=document.getElementById('pager');
const search=document.getElementById('report-search');
if(data.empty){
reportState=null;
document.getElementById('rc-success').textContent='0';
document.getElementById('rc-attention').textContent='0';
document.getElementById('rc-failed').textContent='0';
document.getElementById('rt-file').textContent=data.msg||'No report loaded';
wrap.classList.add('hidden'); wrap.innerHTML=''; pager.classList.add('hidden');
if(search) search.classList.add('hidden');
empty.classList.remove('hidden');
empty.querySelector('.empty-sub').textContent=data.msg||'Run the automation first.';
return;
}
const hdr=data.header, sIdx=data.status_idx;
const pdfCol=!!data.has_pdf_col;
const hasIds=data.rows.length>0 && data.rows[0].id!==undefined;
// Frozen-left: Actions, File Status, Posted On, PDF. Then Check#, then the
// rest. 'ACT'/'PDF' are sentinels (not header indices) so they never enter
// the Excel download, which is built from header + numeric indices only.
let checkIdx=-1, postedIdx=-1;
for(let i=0;i<hdr.length;i++){
const h=String(hdr[i]).trim().toLowerCase();
if(h==='check#') checkIdx=i;
else if(h==='posted on') postedIdx=i;
}
const hasPosted=(postedIdx>=0 && postedIdx!==sIdx);
const order=[]; frozenCols=[]; let left=0;
function addFrozen(key,width){ order.push(key); frozenCols.push({key:key,left:left,width:width}); left+=width; }
if(hasIds) addFrozen('ACT',96);
addFrozen(sIdx,150);
if(hasPosted) addFrozen(postedIdx,160);
if(pdfCol) addFrozen('PDF',70);
if(checkIdx>=0 && checkIdx!==sIdx && checkIdx!==postedIdx) order.push(checkIdx);
for(let i=0;i<hdr.length;i++){ if(i!==sIdx && i!==checkIdx && i!==postedIdx) order.push(i); }
reportState={header:hdr, sIdx:sIdx, order:order, rows:data.rows, file:data.file,
page:0, perPage:10, editingId:null, filterCol:-2, filterVal:'', _view:data.rows};
// populate the search column dropdown (Any field + every column)
const sc=document.getElementById('search-col');
if(sc){
let o='<option value="-2">Any field</option>';
hdr.forEach(function(h,i){ o+='<option value="'+i+'">'+escapeHtml(h)+'</option>'; });
sc.innerHTML=o;
}
const sv=document.getElementById('search-val'); if(sv) sv.value='';
const si=document.getElementById('search-info'); if(si) si.textContent='';
if(search) search.classList.remove('hidden');
empty.classList.add('hidden'); wrap.classList.remove('hidden');
renderReportPage();
}
/* ---- report search / filter ---- */
function reportView(){
const st=reportState; if(!st) return [];
const col=st.filterCol, val=(st.filterVal||'').trim().toLowerCase();
if(!val) return st.rows;
return st.rows.filter(function(r){
if(col===-2){
return r.cells.some(function(c){return String(c==null?'':c).toLowerCase().indexOf(val)>=0;});
}
const c=(r.cells[col]!==undefined && r.cells[col]!=null)?String(r.cells[col]):'';
return c.toLowerCase().indexOf(val)>=0;
});
}
function applySearch(){
const st=reportState; if(!st) return;
const raw=document.getElementById('search-col').value;
const col=parseInt(raw,10);
st.filterCol=isNaN(col)?-2:col;
st.filterVal=document.getElementById('search-val').value||'';
st.editingId=null; st.page=0;
renderReportPage();
}
function clearSearch(){
const st=reportState; if(!st) return;
const sv=document.getElementById('search-val'); if(sv) sv.value='';
st.filterVal=''; st.page=0;
renderReportPage();
}
function recomputeCounts(rows){
const list=rows||reportState.rows;
const c={success:0,attention:0,failed:0};
list.forEach(r=>{ c[r.category]=(c[r.category]||0)+1; });
countUp(document.getElementById('rc-success'),c.success);
countUp(document.getElementById('rc-attention'),c.attention);
countUp(document.getElementById('rc-failed'),c.failed);
}
function renderReportPage(){
const st=reportState; if(!st) return;
const view=reportView(); st._view=view;
recomputeCounts(view);
const total=view.length, pages=Math.max(1, Math.ceil(total/st.perPage));
if(st.page>=pages) st.page=pages-1;
if(st.page<0) st.page=0;
const start=st.page*st.perPage, end=Math.min(start+st.perPage, total);
const filtered=!!(st.filterVal && st.filterVal.trim());
document.getElementById('rt-file').textContent=
st.file+' · '+total+(filtered?' match(es)':' row(s)');
const si=document.getElementById('search-info');
if(si) si.textContent=filtered?(total+' match(es)'):'';
let h='<table class="rtbl"><thead><tr>';
st.order.forEach((ci,pos)=>{
const fb=frozenBits(pos);
const label=(ci==='ACT')?'Actions':(ci==='PDF')?'PDF':(st.header[ci]!==undefined?st.header[ci]:'');
h+='<th'+fb.cls+fb.sty+'>'+escapeHtml(label)+'</th>';
});
h+='</tr></thead><tbody>';
for(let ri=start; ri<end; ri++){
const r=view[ri];
const editing=(r.id!==undefined && st.editingId!==null && st.editingId===r.id);
h+='<tr>';
st.order.forEach((ci,pos)=>{
const fb=frozenBits(pos);
if(ci==='ACT'){
h+='<td'+fb.cls+fb.sty+'>'+actionsCell(ri, r, editing)+'</td>';
} else if(ci==='PDF'){
const pv=r.pdf||'';
const pcls=(pv==='✔')?'ok':(pv==='✘')?'no':'muted';
h+='<td'+fb.cls+fb.sty+'><span class="pdf-mark '+pcls+'">'+pv+'</span></td>';
} else if(ci===st.sIdx){
h+='<td'+fb.cls+fb.sty+'>'+statusCell(ri, r, editing)+'</td>';
} else {
const val=r.cells[ci]!==undefined?r.cells[ci]:'';
const key=EDIT_COLS[String(st.header[ci]||'').trim().toLowerCase()];
if(editing && key){
h+='<td'+fb.cls+fb.sty+'><input class="ed-input" id="ed-'+key+'" value="'+escAttr(val)+'"></td>';
} else {
h+='<td'+fb.cls+fb.sty+'>'+escapeHtml(val)+'</td>';
}
}
});
h+='</tr>';
}
document.getElementById('rtbl-wrap').innerHTML=h+'</tbody></table>';
const pager=document.getElementById('pager');
if(total>st.perPage){
pager.classList.remove('hidden');
pager.innerHTML='<button class="btn" '+(st.page<=0?'disabled':'')+' onclick="pagePrev()">‹ Prev</button>'
+'<span class="pg-info">Page '+(st.page+1)+' / '+pages+' ('+(start+1)+'–'+end+' of '+total+')</span>'
+'<button class="btn" '+(st.page>=pages-1?'disabled':'')+' onclick="pageNext()">Next ›</button>';
} else { pager.classList.add('hidden'); pager.innerHTML=''; }
}
/* Actions cell: view mode = ✏️ + 🗑, edit mode = 💾 + ✖. Rows without a DB id
(shouldn't happen anymore) get no actions. */
function actionsCell(ri, r, editing){
if(r.id===undefined) return '—';
if(editing){
return '<button class="act-btn save" title="Save" onclick="saveEdit('+ri+')">💾</button>'
+ '<button class="act-btn cancel" title="Cancel" onclick="cancelEdit()">✖</button>';
}
// Only DUPLICATE rows can be deleted (they mirror the primary). A normal row
// is edited, never deleted — so it shows the Edit button only.
if(r.duplicate){
return '<button class="act-btn del" title="Delete this duplicate row" onclick="askDelete('+ri+')">🗑</button>';
}
return '<button class="act-btn" title="Edit row" onclick="startEdit('+ri+')">✏️</button>';
}
/* Status cell: dropdown is DISABLED until Edit is clicked. While editing,
Pending/Error may move to Completed only; Completed itself stays locked. */
function statusCell(ri, r, editing){
const val=r.cells[reportState.sIdx]!==undefined?r.cells[reportState.sIdx]:'';
// duplicate of an already-logged payment (crash+resume re-run) — show a grey
// "Duplicate" badge instead of the status; its real value stays in the tooltip.
if(r.duplicate){
return '<span class="pill dup" title="Duplicate of an earlier row ('+escAttr(val)+')">Duplicate</span>';
}
if(!editing){
return '<select class="status-sel '+r.category+'" disabled><option>'+escapeHtml(val)+'</option></select>';
}
if(String(val)==='Completed'){
return '<select id="ed-status" class="status-sel success" disabled title="Completed is locked"><option>Completed</option></select>';
}
let o='<option value="'+escAttr(val)+'" selected>'+escapeHtml(val)+'</option>'
+'<option value="Completed">Completed</option>';
return '<select id="ed-status" class="status-sel '+r.category+'">'+o+'</select>';
}
function startEdit(ri){
if(!reportState) return;
reportState.editingId=(reportState._view||reportState.rows)[ri].id;
renderReportPage();
}
function cancelEdit(){
if(!reportState) return;
reportState.editingId=null;
renderReportPage();
}
function saveEdit(ri){
const st=reportState; if(!st) return;
const r=(st._view||st.rows)[ri];
const idxOf=n=>{for(let i=0;i<st.header.length;i++){if(String(st.header[i]).trim().toLowerCase()===n)return i;}return -1;};
const gv=(id,hname)=>{const el=document.getElementById(id);
if(el) return el.value;
const i=idxOf(hname); return (i>=0 && r.cells[i]!==undefined)?r.cells[i]:'';};
const stEl=document.getElementById('ed-status');
const payload={
id:r.id,
insurance: gv('ed-insurance','insurance name'),
check_no: gv('ed-check_no','check#'),
check_date: gv('ed-check_date','check date'),
check_amt: gv('ed-check_amt','check amt'),
comments: gv('ed-comments','comments'),
file_status:(stEl && !stEl.disabled)?stEl.value:(r.cells[st.sIdx]||'')
};
st.editingId=null;
if(pyBridge) pyBridge.update_era_row(JSON.stringify(payload));
else addLog('No Python bridge — cannot save','warn');
}
/* Delete with confirm dialog */
function askDelete(ri){
const r=(reportState._view||reportState.rows)[ri];
pendingDeleteId=r.id;
const chkIdx=(function(){for(let i=0;i<reportState.header.length;i++){if(String(reportState.header[i]).trim().toLowerCase()==='check#')return i;}return -1;})();
const chk=(chkIdx>=0 && r.cells[chkIdx])?(' (Check# '+r.cells[chkIdx]+')'):'';
document.getElementById('del-msg').textContent='This duplicate row'+chk+' will be removed from the report. An admin can still see it (and who removed it) in the Dashboard.';
document.getElementById('del-mask').classList.add('show');
}
function hideDelete(){ pendingDeleteId=null; document.getElementById('del-mask').classList.remove('show'); }
function confirmDelete(){
const id=pendingDeleteId; hideDelete();
if(id!==null && id!==undefined && pyBridge) pyBridge.delete_era_row(id);
}
/* Date-range download: toggle the From/To row, then download the span. */
function toggleDateRange(){
const el=document.getElementById('date-range');
const btn=document.getElementById('bydate-btn');
const showing=el.classList.toggle('hidden'); // hidden after toggle => now closed
if(btn) btn.classList.toggle('dark', !showing); // highlight while open
if(!showing){
// just opened: default both dates to today if empty
const t=new Date().toISOString().slice(0,10);
const f=document.getElementById('rpt-from'), tt=document.getElementById('rpt-to');
if(f && !f.value) f.value=t;
if(tt && !tt.value) tt.value=t;
}
}
function downloadByRange(){
const f=document.getElementById('rpt-from').value;
const t=document.getElementById('rpt-to').value;
if(!f || !t){ addLog('Pick both From and To dates.','warn'); return; }
if(pyBridge) pyBridge.download_report_by_range(f, t);
}
function pagePrev(){ if(reportState && reportState.page>0){reportState.page--; renderReportPage();} }
function pageNext(){ if(!reportState) return; const n=(reportState._view||reportState.rows).length; const pages=Math.ceil(n/reportState.perPage); if(reportState.page<pages-1){reportState.page++; renderReportPage();} }
function downloadReport(){
if(!reportState){ addLog('No report to download','warn'); return; }
const payload={header:reportState.header, rows:reportState.rows.map(r=>r.cells), file:reportState.file};
if(pyBridge) pyBridge.download_report(JSON.stringify(payload));
}
function escapeHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return escapeHtml(s).replace(/"/g,'&quot;');}
renderSteps();
renderCpTrack();