2105 lines
113 KiB
HTML
2105 lines
113 KiB
HTML
<!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 · 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">⬇ 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">×</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 & 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">×</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 (') so names with quotes can't break out
|
||
const attr=v=>JSON.stringify(v).replace(/'/g,''').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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
function escAttr(s){return escapeHtml(s).replace(/"/g,'"');}
|
||
renderSteps();
|
||
renderCpTrack();
|