| 36877 |
vikas |
1 |
<!DOCTYPE html>
|
|
|
2 |
<html lang="en">
|
|
|
3 |
<head>
|
|
|
4 |
<meta charset="utf-8">
|
|
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
6 |
<title>Beat Journey</title>
|
|
|
7 |
<style>
|
|
|
8 |
:root{
|
|
|
9 |
--bg:#eef1f7; --card:#ffffff; --ink:#1d2433; --muted:#69748c; --soft:#97a1b5;
|
|
|
10 |
--line:#e6e9f1; --line2:#eef1f6;
|
|
|
11 |
--brand:#3b5bdb; --brand2:#5c7cfa; --teal:#0ca678; --cyan:#1098ad;
|
|
|
12 |
--amber:#f59f00; --orange:#e8590c; --red:#e03131; --violet:#7048e8; --pink:#d6336c;
|
|
|
13 |
--good:#2f9e44; --warn:#f08c00; --bad:#e03131;
|
|
|
14 |
}
|
|
|
15 |
*{box-sizing:border-box}
|
|
|
16 |
html,body{margin:0;background:var(--bg);color:var(--ink);
|
|
|
17 |
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;font-size:14px}
|
|
|
18 |
.wrap{max-width:1320px;margin:0 auto;padding:20px 24px 90px}
|
|
|
19 |
a{color:var(--brand)}
|
|
|
20 |
header.top{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;margin-bottom:16px}
|
|
|
21 |
.brand .crumb{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
22 |
.brand h1{margin:3px 0 0;font-size:23px;font-weight:740;letter-spacing:-.01em}
|
|
|
23 |
.brand h1 .live{font-size:11px;font-weight:700;color:var(--teal);background:#e6fcf5;border:1px solid #c3fae8;border-radius:999px;padding:3px 9px;vertical-align:middle;margin-left:10px}
|
|
|
24 |
.brand h1 .live i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--teal);margin-right:5px;animation:pulse 1.6s infinite}
|
|
|
25 |
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
|
26 |
.hd-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
|
|
27 |
.ctl{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;font-size:13px;color:var(--ink);font-weight:600}
|
|
|
28 |
.ctl .lbl{color:var(--soft);font-weight:500;margin-right:5px}
|
|
|
29 |
select.ctl,input.ctl{appearance:none;cursor:pointer;height:36px}
|
|
|
30 |
input.ctl{cursor:auto}
|
|
|
31 |
.btn-r{background:var(--brand);border:0;color:#fff;font-weight:650;padding:9px 15px;border-radius:9px;cursor:pointer;font-size:13px;height:36px}
|
|
|
32 |
.lenses{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:20px}
|
|
|
33 |
.lens{padding:11px 4px;margin-right:22px;font-size:14px;font-weight:650;color:var(--muted);cursor:pointer;border:0;background:transparent;border-bottom:2.5px solid transparent;margin-bottom:-1px}
|
|
|
34 |
.lens.active{color:var(--brand);border-bottom-color:var(--brand)}
|
|
|
35 |
.lens .b{display:inline-block;background:#eef1fb;color:var(--brand);font-size:11px;font-weight:700;border-radius:999px;padding:1px 7px;margin-left:6px}
|
|
|
36 |
.kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px}
|
|
|
37 |
.kpi{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 15px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
|
|
|
38 |
.kpi .top{display:flex;align-items:center;gap:8px}
|
|
|
39 |
.kpi .ic{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
40 |
.kpi .label{font-size:11.5px;color:var(--muted);font-weight:600;line-height:1.2}
|
|
|
41 |
.kpi .val{font-size:26px;font-weight:760;margin-top:9px;letter-spacing:-.02em;line-height:1}
|
|
|
42 |
.kpi .val small{font-size:13px;color:var(--soft);font-weight:600}
|
|
|
43 |
.kpi .foot{font-size:11.5px;color:var(--soft);margin-top:7px}
|
|
|
44 |
.grid{display:grid;gap:16px}
|
|
|
45 |
.g2{grid-template-columns:1fr 1fr}
|
|
|
46 |
.g3{grid-template-columns:1.55fr 1fr}
|
|
|
47 |
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:17px 19px;margin-bottom:16px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
|
|
|
48 |
.card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}
|
|
|
49 |
.card .desc{color:var(--muted);font-size:12.5px;margin:0 0 14px}
|
|
|
50 |
.sectionlabel{font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);font-weight:700;margin:24px 2px 12px}
|
|
|
51 |
.funnel{display:flex;gap:9px;align-items:stretch}
|
|
|
52 |
.fstep{flex:1;border:1px solid var(--line);border-radius:11px;padding:13px 12px;position:relative;text-align:center;background:#fbfcfe}
|
|
|
53 |
.fstep .fn{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
|
|
|
54 |
.fstep .fv{font-size:26px;font-weight:770;margin-top:6px}
|
|
|
55 |
.fstep .fbar{height:5px;border-radius:3px;margin-top:10px;background:linear-gradient(90deg,var(--brand),var(--brand2))}
|
|
|
56 |
.fstep .drop{font-size:11px;color:var(--red);margin-top:7px;font-weight:600}
|
|
|
57 |
.fstep .keep{font-size:11px;color:var(--good);margin-top:7px;font-weight:600}
|
|
|
58 |
.fstep .arrow{position:absolute;right:-12px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:15px}
|
|
|
59 |
.leak{display:flex;gap:10px;margin-top:12px}
|
|
|
60 |
.leakbox{flex:1;border:1px solid var(--line2);border-radius:10px;padding:10px 12px;background:#f8f9fc}
|
|
|
61 |
.leakbox .lt{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
|
|
62 |
.leakbox .lv{font-size:20px;font-weight:760;margin-top:4px}
|
|
|
63 |
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
|
64 |
th,td{text-align:left;padding:9px 9px;border-bottom:1px solid var(--line2)}
|
|
|
65 |
th{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700;white-space:nowrap;cursor:pointer}
|
|
|
66 |
th.num,td.num{text-align:right}
|
|
|
67 |
tbody tr:hover{background:#f7f9fd}
|
|
|
68 |
tr.clickable{cursor:pointer}
|
|
|
69 |
.lvl{display:inline-block;font-size:10.5px;font-weight:700;padding:2px 6px;border-radius:5px;background:#eef1fb;color:var(--brand)}
|
|
|
70 |
.pill-state{display:inline-flex;align-items:center;gap:6px;font-weight:600;font-size:12.5px;padding:3px 9px;border-radius:999px}
|
|
|
71 |
.ps-store{background:#e7f5ff;color:#1971c2}
|
|
|
72 |
.ps-travel{background:#e6fcf5;color:#0ca678}
|
|
|
73 |
.ps-done{background:#ebfbee;color:#2f9e44}
|
|
|
74 |
.ps-idle{background:#fff4e6;color:#e8590c}
|
|
|
75 |
.ps-no{background:#fff0f0;color:#e03131}
|
|
|
76 |
.meter{display:inline-block;width:54px;height:7px;border-radius:4px;background:#eef1f6;vertical-align:middle;margin-left:7px;overflow:hidden}
|
|
|
77 |
.meter > i{display:block;height:100%;border-radius:4px}
|
|
|
78 |
.warnflag{color:var(--orange);font-weight:700}
|
|
|
79 |
.badflag{color:var(--red);font-weight:700}
|
|
|
80 |
.rowwarn{background:#fff9f0}
|
|
|
81 |
.rowbad{background:#fff5f5}
|
|
|
82 |
.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}
|
|
|
83 |
.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
84 |
.att .ab{flex:1}
|
|
|
85 |
.att .atitle{font-size:13px;font-weight:650}
|
|
|
86 |
.att .adet{font-size:12px;color:var(--muted);margin-top:2px}
|
|
|
87 |
.att .acount{font-size:18px;font-weight:780}
|
| 36882 |
vikas |
88 |
.att.clickable{cursor:pointer;transition:border-color .12s,box-shadow .12s}
|
|
|
89 |
.att.clickable:hover{border-color:#c7d0e8;box-shadow:0 2px 8px rgba(20,30,60,.08)}
|
|
|
90 |
.att .chev{color:#c2cadb;font-size:13px;margin-left:8px}
|
| 36877 |
vikas |
91 |
.barrow{display:grid;grid-template-columns:60px 1fr 90px;align-items:center;gap:11px;margin:8px 0}
|
|
|
92 |
.barrow .name{font-size:13px;font-weight:600}
|
|
|
93 |
.track{background:#f1f3f9;border-radius:7px;height:20px;position:relative;overflow:hidden}
|
|
|
94 |
.fill{height:100%;border-radius:7px;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;font-size:10.5px;color:#fff;font-weight:650;min-width:2px}
|
|
|
95 |
.barrow .meta{font-size:11.5px;color:var(--muted);text-align:right}
|
|
|
96 |
.donutwrap{display:flex;align-items:center;gap:20px}
|
|
|
97 |
.donut{width:138px;height:138px;border-radius:50%;flex:none;position:relative;
|
|
|
98 |
-webkit-mask:radial-gradient(circle 42px at center,transparent 98%,#000 100%);mask:radial-gradient(circle 42px at center,transparent 98%,#000 100%)}
|
|
|
99 |
.donut .center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;-webkit-mask:none;mask:none}
|
|
|
100 |
.legend{display:flex;flex-direction:column;gap:9px;font-size:12.5px}
|
|
|
101 |
.legend .it{display:flex;align-items:flex-start;gap:9px}
|
|
|
102 |
.dot{width:11px;height:11px;border-radius:3px;flex:none;margin-top:3px}
|
|
|
103 |
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}
|
|
|
104 |
.tile{background:#f8f9fc;border:1px solid var(--line2);border-radius:11px;padding:12px}
|
|
|
105 |
.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
|
|
106 |
.tile .v{font-size:21px;font-weight:760;margin-top:5px}
|
|
|
107 |
.tile .v small{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
108 |
.util{display:flex;height:30px;border-radius:8px;overflow:hidden}
|
|
|
109 |
.util div{display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:650;color:#fff}
|
|
|
110 |
.utlegend{display:flex;gap:18px;margin-top:12px;font-size:12px;color:var(--muted);flex-wrap:wrap}
|
|
|
111 |
.utlegend span{display:inline-flex;align-items:center;gap:7px}
|
|
|
112 |
.callout{background:linear-gradient(180deg,#fbfcff,#f4f7ff);border:1px solid #dfe6fb;border-radius:14px;padding:18px 20px;margin-top:6px}
|
|
|
113 |
.callout h2{margin:0 0 3px;font-size:16px}
|
|
|
114 |
.callout .desc{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
|
|
115 |
.fix{display:flex;gap:13px;padding:13px 0;border-bottom:1px solid #e6ebf8}
|
|
|
116 |
.fix:last-child{border-bottom:0}
|
|
|
117 |
.fix .sev{width:6px;border-radius:4px;flex:none}
|
|
|
118 |
.fix .fb{flex:1}
|
|
|
119 |
.fix .ft{font-size:14px;font-weight:700}
|
|
|
120 |
.fix .fd{font-size:13px;color:#3a4459;margin-top:4px;line-height:1.5}
|
|
|
121 |
.fix .fd b{color:var(--ink)}
|
|
|
122 |
.fix .act{font-size:12.5px;color:var(--good);margin-top:6px;font-weight:650}
|
|
|
123 |
.hide{display:none}
|
|
|
124 |
.loading{padding:70px;text-align:center;color:var(--muted)}
|
|
|
125 |
.spinner{width:30px;height:30px;border:3px solid #e3e8f5;border-top-color:var(--brand);border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 12px}
|
|
|
126 |
@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
127 |
.modal-back{position:fixed;inset:0;background:rgba(15,22,40,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
|
|
|
128 |
.modal-back.show{display:flex}
|
| 36882 |
vikas |
129 |
.modal{background:#fff;border-radius:14px;max-width:780px;width:100%;padding:20px 22px;box-shadow:0 20px 60px rgba(10,20,50,.3);position:relative}
|
|
|
130 |
.modal h3{margin:0 0 2px;font-size:17px;text-align:center}
|
|
|
131 |
.modal > .desc{text-align:center}
|
|
|
132 |
.modal .x{position:absolute;top:14px;right:16px;cursor:pointer;color:var(--soft);font-size:22px;line-height:1;border:0;background:none}
|
|
|
133 |
/* journey totals bar */
|
|
|
134 |
.jstats{display:flex;gap:9px;margin:12px 0 2px;flex-wrap:wrap}
|
|
|
135 |
.js{flex:1;min-width:104px;background:#f8f9fc;border:1px solid var(--line2);border-radius:10px;padding:9px 10px;text-align:center}
|
|
|
136 |
.js .jv{font-size:15.5px;font-weight:760;color:var(--ink);font-variant-numeric:tabular-nums}
|
|
|
137 |
.js .jl{font-size:10px;color:var(--soft);text-transform:uppercase;letter-spacing:.04em;margin-top:3px;font-weight:600}
|
| 36877 |
vikas |
138 |
.btn-ok{background:var(--good);border:0;color:#fff;font-weight:650;padding:6px 12px;border-radius:7px;cursor:pointer;font-size:12px}
|
| 36882 |
vikas |
139 |
/* journey timeline (exec drilldown) */
|
|
|
140 |
.tl{position:relative;margin:14px 2px 4px;padding:4px 0}
|
|
|
141 |
.tl::before{content:"";position:absolute;left:50%;top:6px;bottom:6px;width:2px;background:#e9edf5;transform:translateX(-50%)}
|
|
|
142 |
.tlgap{position:relative;display:flex;justify-content:center;margin:-3px 0}
|
|
|
143 |
.tlgap .gappill{background:#fff;border:1px solid var(--line);border-radius:999px;padding:3px 11px;font-size:11px;font-weight:600;color:var(--muted);z-index:2;box-shadow:0 1px 2px rgba(20,30,60,.05);font-variant-numeric:tabular-nums}
|
|
|
144 |
.tlrow{position:relative;display:flex;margin:14px 0}
|
|
|
145 |
.tlrow.left{justify-content:flex-start}
|
|
|
146 |
.tlrow.right{justify-content:flex-end}
|
|
|
147 |
.tlrow .node{position:absolute;left:50%;top:8px;transform:translateX(-50%);width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;z-index:2;box-shadow:0 0 0 4px #fff}
|
|
|
148 |
.tlcard{width:calc(50% - 26px);background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 2px 10px rgba(20,30,60,.07);display:flex;overflow:hidden}
|
|
|
149 |
.tlrow.left .tlcard{flex-direction:row-reverse;text-align:right}
|
|
|
150 |
.tlrow.right .tlcard{flex-direction:row;text-align:left}
|
|
|
151 |
.tlcard .tm{flex:none;width:62px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;font-weight:750;font-size:13px;padding:6px 4px;line-height:1.05}
|
|
|
152 |
.tlcard .tm small{font-weight:600;font-size:8.5px;opacity:.9;text-transform:uppercase;letter-spacing:.04em;margin-top:3px}
|
|
|
153 |
.tlcard .bd{padding:8px 11px;flex:1;min-width:0}
|
|
|
154 |
.tlcard .bd .ti{font-size:12.5px;font-weight:700;color:var(--ink);line-height:1.25;word-break:break-word}
|
|
|
155 |
.tlcard .bd .stt{display:inline-block;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:5px;margin-top:5px;color:#fff}
|
|
|
156 |
.tlcard .bd .mt{font-size:11px;color:var(--muted);margin-top:6px}
|
|
|
157 |
.tlextra{display:flex;align-items:center;gap:7px;margin-top:8px;flex-wrap:wrap}
|
|
|
158 |
.tlrow.left .tlextra{justify-content:flex-end}
|
|
|
159 |
.tlrow.right .tlextra{justify-content:flex-start}
|
|
|
160 |
.tlthumb{width:46px;height:46px;border-radius:8px;object-fit:cover;border:1px solid var(--line);cursor:pointer;background:#f1f3f9}
|
|
|
161 |
.tlph{width:46px;height:46px;border-radius:8px;border:1px dashed #cfd6e6;background:#f4f6fb;display:flex;align-items:center;justify-content:center;color:#b3bcce;font-size:18px}
|
|
|
162 |
.tlchip{font-size:10.5px;font-weight:600;color:var(--muted);background:#f1f3f9;border-radius:6px;padding:2px 7px;white-space:nowrap}
|
| 36877 |
vikas |
163 |
@media (max-width:1000px){.kpis{grid-template-columns:repeat(3,1fr)}.g2,.g3{grid-template-columns:1fr}.funnel{flex-wrap:wrap}.fstep{min-width:46%}.fstep .arrow{display:none}}
|
| 36882 |
vikas |
164 |
@media (max-width:560px){.tl::before{left:20px}.tlrow.left,.tlrow.right{justify-content:flex-end}.tlrow .node{left:20px}.tlcard{width:calc(100% - 44px)}.tlrow.left .tlcard{flex-direction:row;text-align:left}}
|
| 36877 |
vikas |
165 |
</style>
|
|
|
166 |
</head>
|
|
|
167 |
<body>
|
|
|
168 |
<div class="wrap">
|
|
|
169 |
<header class="top">
|
|
|
170 |
<div class="brand">
|
|
|
171 |
<div class="crumb"><a href="${rc.contextPath}/dashboard">← Dashboard</a> · Field Sales · Beat / Monthly PJP</div>
|
|
|
172 |
<h1>Beat Journey <span class="live"><i></i>Live</span></h1>
|
|
|
173 |
</div>
|
|
|
174 |
<div class="hd-right">
|
|
|
175 |
<select class="ctl" id="bj-level">
|
|
|
176 |
<option value="">All levels below me</option>
|
|
|
177 |
#foreach($lvl in $escalationTypes)
|
|
|
178 |
<option value="$lvl">$lvl</option>
|
|
|
179 |
#end
|
|
|
180 |
</select>
|
|
|
181 |
<input class="ctl" type="date" id="bj-date" value="$today">
|
|
|
182 |
<span id="bj-period-range" class="hide">
|
|
|
183 |
<input class="ctl" type="date" id="bj-start" value="$monthStart">
|
|
|
184 |
<input class="ctl" type="date" id="bj-end" value="$today">
|
|
|
185 |
</span>
|
|
|
186 |
<button class="btn-r" id="bj-apply">Apply</button>
|
|
|
187 |
</div>
|
|
|
188 |
</header>
|
|
|
189 |
|
|
|
190 |
<div class="lenses">
|
|
|
191 |
<button class="lens active" data-lens="today" id="lens-today">Today (Live)</button>
|
|
|
192 |
<button class="lens" data-lens="period" id="lens-period">Period (Analytics)</button>
|
|
|
193 |
<button class="lens" data-lens="approval" id="lens-approval">Location Approval</button>
|
|
|
194 |
</div>
|
|
|
195 |
|
|
|
196 |
<div id="bj-content"><div class="loading"><div class="spinner"></div>Loading…</div></div>
|
|
|
197 |
</div>
|
|
|
198 |
|
|
|
199 |
<div class="modal-back" id="bj-modal-back">
|
|
|
200 |
<div class="modal" id="bj-modal"></div>
|
|
|
201 |
</div>
|
|
|
202 |
|
|
|
203 |
<script>
|
|
|
204 |
var CTX = '${rc.contextPath}';
|
|
|
205 |
var BASE = CTX + '/beat-journey';
|
|
|
206 |
var LENS = 'today';
|
|
|
207 |
|
|
|
208 |
function el(id){ return document.getElementById(id); }
|
|
|
209 |
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
|
|
210 |
function num(n){ return (n==null?0:n).toLocaleString(); }
|
|
|
211 |
function adhColor(p){ if(p>=80)return '#2f9e44'; if(p>=60)return '#f08c00'; if(p>=40)return '#e8590c'; return '#e03131'; }
|
|
|
212 |
function pct(a,b){ return b? Math.round(100*a/b):0; }
|
|
|
213 |
function qs(){
|
|
|
214 |
var lvl = el('bj-level').value;
|
|
|
215 |
var s = '';
|
|
|
216 |
if(lvl) s += '&escalationType=' + encodeURIComponent(lvl);
|
|
|
217 |
return s;
|
|
|
218 |
}
|
|
|
219 |
function spinner(){ el('bj-content').innerHTML = '<div class="loading"><div class="spinner"></div>Loading…</div>'; }
|
|
|
220 |
function fail(e){ el('bj-content').innerHTML = '<div class="loading">Could not load data ('+esc(e&&e.message||e)+'). Are you signed in?</div>'; }
|
|
|
221 |
|
|
|
222 |
function setLens(name){
|
|
|
223 |
LENS = name;
|
|
|
224 |
var tabs = document.querySelectorAll('.lens');
|
|
|
225 |
for(var i=0;i<tabs.length;i++){ tabs[i].classList.toggle('active', tabs[i].getAttribute('data-lens')===name); }
|
|
|
226 |
el('bj-date').classList.toggle('hide', name!=='today');
|
|
|
227 |
el('bj-period-range').classList.toggle('hide', name==='today');
|
|
|
228 |
load();
|
|
|
229 |
}
|
|
|
230 |
function load(){
|
|
|
231 |
if(LENS==='today') return loadToday();
|
|
|
232 |
if(LENS==='period') return loadPeriod();
|
|
|
233 |
return loadApproval();
|
|
|
234 |
}
|
|
|
235 |
|
|
|
236 |
/* ============================ TODAY ============================ */
|
|
|
237 |
function loadToday(){
|
|
|
238 |
spinner();
|
|
|
239 |
fetch(BASE + '/today?date=' + encodeURIComponent(el('bj-date').value) + qs(), {headers:{'Accept':'application/json'}})
|
|
|
240 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
241 |
.then(renderToday).catch(fail);
|
|
|
242 |
}
|
|
|
243 |
function kpi(ic, bg, fg, label, val, foot, valColor){
|
|
|
244 |
return '<div class="kpi"><div class="top"><div class="ic" style="background:'+bg+';color:'+fg+'">'+ic+'</div>'+
|
|
|
245 |
'<div class="label">'+label+'</div></div><div class="val"'+(valColor?' style="color:'+valColor+'"':'')+'>'+val+'</div>'+
|
|
|
246 |
'<div class="foot">'+foot+'</div></div>';
|
|
|
247 |
}
|
|
|
248 |
function fstep(name, val, w, sub, drop, last){
|
|
|
249 |
return '<div class="fstep"><div class="fn">'+name+'</div><div class="fv">'+num(val)+'</div>'+
|
|
|
250 |
'<div class="fbar" style="width:'+w+'%"></div><div class="'+(drop?'drop':'keep')+'">'+sub+'</div>'+
|
|
|
251 |
(last?'':'<span class="arrow">➤</span>')+'</div>';
|
|
|
252 |
}
|
|
|
253 |
function leakbox(t, v, color){ return '<div class="leakbox"><div class="lt">'+t+'</div><div class="lv"'+(color?' style="color:'+color+'"':'')+'>'+num(v)+'</div></div>'; }
|
|
|
254 |
|
|
|
255 |
function renderToday(d){
|
|
|
256 |
var k=d.kpis, f=d.funnel, s=d.stops, a=d.attention;
|
|
|
257 |
var h='';
|
|
|
258 |
h+='<div class="kpis">';
|
|
|
259 |
h+=kpi('👥','#eef1fb','#3b5bdb','Executives scheduled',num(k.scheduled),k.notStarted+' not started yet');
|
|
|
260 |
h+=kpi('⏱','#ebfbee','#2f9e44','Punched in',num(k.punchedIn)+' <small>/'+k.scheduled+'</small>',k.notStarted+' pending');
|
|
|
261 |
h+=kpi('📍','#e7f5ff','#1098ad','On route now',num(k.onRouteNow),'live journeys');
|
|
|
262 |
h+=kpi('✓','#eef1fb','#3b5bdb','Visits done',num(k.visitsDone)+' <small>/'+k.visitsPlanned+'</small>',k.inProgress+' in progress');
|
|
|
263 |
h+=kpi('↻','#fff4e6','#e8590c','Deferred today',num(k.deferred),'shop closed / unavailable');
|
|
|
264 |
h+=kpi('★','#fff0f6','#d6336c','Leads on route',num(k.leads),'new shops logged');
|
|
|
265 |
h+='</div>';
|
|
|
266 |
|
|
|
267 |
h+='<div class="sectionlabel">Today\'s Journey Funnel</div><div class="card"><div class="funnel">';
|
|
|
268 |
var sc=f.scheduled||1;
|
|
|
269 |
h+=fstep('Scheduled executives',f.scheduled,100,'start of day',false,false);
|
|
|
270 |
h+=fstep('Punched in',f.punchedIn,pct(f.punchedIn,sc),'−'+(f.scheduled-f.punchedIn)+' never started',true,false);
|
|
|
271 |
h+=fstep('Made a check-in',f.madeCheckin,pct(f.madeCheckin,sc),'−'+(f.punchedIn-f.madeCheckin)+' punched, idle',true,false);
|
|
|
272 |
h+=fstep('Completed all visits',f.completedAll,pct(f.completedAll,sc),(f.madeCheckin-f.completedAll)+' still mid-journey',true,false);
|
|
|
273 |
h+=fstep('Punched out',f.punchedOut,pct(f.punchedOut,sc),'journey ended',false,true);
|
|
|
274 |
h+='</div><div class="leak">';
|
|
|
275 |
h+=leakbox('Stops planned',s.planned,null);
|
|
|
276 |
h+=leakbox('Checked out (done)',s.done,'#2f9e44');
|
|
|
277 |
h+=leakbox('In progress now',s.inProgress,'#1098ad');
|
|
|
278 |
h+=leakbox('Deferred',s.deferred,'#e8590c');
|
|
|
279 |
h+=leakbox('Untouched',s.untouched,'#e03131');
|
|
|
280 |
h+='</div></div>';
|
|
|
281 |
|
|
|
282 |
h+='<div class="grid g3">';
|
|
|
283 |
h+='<div class="card"><h2>Live executive board</h2><p class="desc">Each executive\'s journey right now. Click a row for the day\'s visit detail.</p>'+boardTable(d.board, d.date)+'</div>';
|
|
|
284 |
h+='<div class="card"><h2>Needs attention now</h2><p class="desc">Things a manager should clear today.</p>'+attentionList(a)+'</div>';
|
|
|
285 |
h+='</div>';
|
|
|
286 |
el('bj-content').innerHTML=h;
|
|
|
287 |
}
|
|
|
288 |
function stateClass(st){ return st==='IN_STORE'?'ps-store':st==='TRAVELLING'?'ps-travel':st==='DONE'?'ps-done':st==='NOT_STARTED'?'ps-no':'ps-idle'; }
|
|
|
289 |
function boardTable(rows, date){
|
|
|
290 |
if(!rows||!rows.length) return '<p class="desc">No executives scheduled or active for this date.</p>';
|
|
|
291 |
var h='<table><thead><tr><th>Executive / Beat</th><th>Agenda</th><th>Punch-in</th><th>State now</th><th class="num">Visits</th><th class="num">Last ping</th><th class="num">Batt</th><th class="num">Loc-off</th></tr></thead><tbody>';
|
|
|
292 |
rows.forEach(function(r){
|
|
|
293 |
var stale = r.lastPingMin!=null && r.lastPingMin>=40;
|
|
|
294 |
var idle = r.state==='TRAVELLING' && stale;
|
|
|
295 |
var rowcls = r.state==='NOT_STARTED' ? 'rowbad' : (idle?'rowwarn':'');
|
| 36882 |
vikas |
296 |
var ping = r.lastPingMin==null ? '-' : (r.lastPingMin+' min');
|
| 36877 |
vikas |
297 |
h+='<tr class="clickable '+rowcls+'" onclick="openExec('+r.userId+',\''+esc(r.name)+'\',\''+date+'\')">'+
|
| 36882 |
vikas |
298 |
'<td><b>'+esc(r.name)+'</b><br><span style="color:#97a1b5;font-size:11px">'+esc(r.beatName||'-')+'</span></td>'+
|
| 36877 |
vikas |
299 |
'<td>'+(r.agendaFilled?'<span style="color:#2f9e44">✓</span>':'<span class="badflag">✗</span>')+'</td>'+
|
| 36882 |
vikas |
300 |
'<td>'+(r.punchInTime?esc(r.punchInTime):'<span class="badflag">-</span>')+'</td>'+
|
| 36877 |
vikas |
301 |
'<td><span class="pill-state '+stateClass(r.state)+'">'+esc(r.stateLabel)+'</span></td>'+
|
|
|
302 |
'<td class="num">'+r.visitsDone+'/'+r.visitsTotal+'</td>'+
|
|
|
303 |
'<td class="num'+(stale?' warnflag':'')+'">'+ping+'</td>'+
|
| 36882 |
vikas |
304 |
'<td class="num'+(r.battery!=null&&r.battery<25?' warnflag':'')+'">'+(r.battery==null?'-':r.battery+'%')+'</td>'+
|
| 36877 |
vikas |
305 |
'<td class="num'+(r.locOff>0?' warnflag':'')+'">'+r.locOff+'</td></tr>';
|
|
|
306 |
});
|
|
|
307 |
return h+'</tbody></table>';
|
|
|
308 |
}
|
|
|
309 |
function names(list,n){ if(!list||!list.length) return 'None'; var s=list.slice(0,n).join(', '); if(list.length>n) s+=' +'+(list.length-n); return s; }
|
| 36882 |
vikas |
310 |
var ATT_META = {
|
|
|
311 |
notPunchedIn: {bg:'#fff0f0', fg:'#e03131', ic:'⚠', title:'Not punched in', sub:'executives with a beat, not started', cls:'badflag'},
|
|
|
312 |
idleStale: {bg:'#fff4e6', fg:'#e8590c', ic:'⏱', title:'Idle / stale GPS > 40 min', sub:'no recent GPS ping', cls:'warnflag'},
|
|
|
313 |
geofence: {bg:'#fff0f0', fg:'#e03131', ic:'📍', title:'Check-in outside store geofence', sub:'>50 m from partner\'s saved location', cls:'badflag'},
|
|
|
314 |
locationOff: {bg:'#fff4e6', fg:'#e8590c', ic:'📴', title:'Location turned off mid-journey', sub:'GPS toggled off', cls:'warnflag'},
|
|
|
315 |
deferredAwaiting:{bg:'#eef1f6', fg:'#69748c', ic:'↻', title:'Deferred, awaiting re-action', sub:'shops deferred today', cls:''}
|
|
|
316 |
};
|
| 36877 |
vikas |
317 |
function attentionList(a){
|
| 36882 |
vikas |
318 |
window._att = a;
|
|
|
319 |
var order=['notPunchedIn','idleStale','geofence','locationOff','deferredAwaiting'];
|
| 36877 |
vikas |
320 |
var h='';
|
| 36882 |
vikas |
321 |
order.forEach(function(k){
|
|
|
322 |
var m=ATT_META[k], list=a[k]||[], n=list.length;
|
|
|
323 |
var detail = n ? names(list.map(function(x){return x.name;}),3) : m.sub;
|
|
|
324 |
var cls = n ? ' att clickable' : ' att';
|
|
|
325 |
var click = n ? ' onclick="openAttention(\''+k+'\')"' : '';
|
|
|
326 |
h+='<div class="'+cls.trim()+'"'+click+'><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.ic+'</div>'+
|
|
|
327 |
'<div class="ab"><div class="atitle">'+m.title+'</div><div class="adet">'+detail+'</div></div>'+
|
|
|
328 |
'<div class="acount '+(n?m.cls:'')+'">'+n+'</div>'+(n?'<span class="chev">▸</span>':'')+'</div>';
|
|
|
329 |
});
|
| 36877 |
vikas |
330 |
return h;
|
|
|
331 |
}
|
| 36882 |
vikas |
332 |
function openAttention(key){
|
|
|
333 |
var a=window._att||{}, list=a[key]||[], m=ATT_META[key]||{title:'Detail'};
|
|
|
334 |
var mb=el('bj-modal-back'), md=el('bj-modal');
|
|
|
335 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+m.title+'</h3>'+
|
|
|
336 |
'<p class="desc">'+list.length+' '+(list.length===1?'item':'items')+'</p>';
|
|
|
337 |
if(!list.length){ h+='<p class="desc">Nothing here.</p>'; }
|
|
|
338 |
else{
|
|
|
339 |
h+='<table><tbody>';
|
|
|
340 |
list.forEach(function(x){
|
|
|
341 |
h+='<tr><td style="font-weight:600;white-space:nowrap">'+esc(x.name)+'</td>'+
|
|
|
342 |
'<td style="color:#69748c">'+esc(x.detail||'')+'</td></tr>';
|
|
|
343 |
});
|
|
|
344 |
h+='</tbody></table>';
|
|
|
345 |
}
|
|
|
346 |
md.innerHTML=h; mb.classList.add('show');
|
|
|
347 |
}
|
| 36877 |
vikas |
348 |
|
|
|
349 |
/* ============================ PERIOD ============================ */
|
|
|
350 |
function loadPeriod(){
|
|
|
351 |
spinner();
|
|
|
352 |
var u = BASE + '/data?startDate=' + encodeURIComponent(el('bj-start').value) + '&endDate=' + encodeURIComponent(el('bj-end').value) + qs();
|
|
|
353 |
fetch(u, {headers:{'Accept':'application/json'}})
|
|
|
354 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
355 |
.then(renderPeriod).catch(fail);
|
|
|
356 |
}
|
|
|
357 |
function tile(t,v){ return '<div class="tile"><div class="t">'+t+'</div><div class="v">'+v+'</div></div>'; }
|
|
|
358 |
function renderPeriod(d){
|
|
|
359 |
var k=d.kpis, f=d.funnel, df=d.deferral, u=d.utilization, t=d.travel, o=d.outcomes;
|
|
|
360 |
var neverTouched = Math.max((f.plannedStops||0)-(f.checkedIn||0)-(df.total||0),0);
|
|
|
361 |
var h='';
|
|
|
362 |
h+='<div class="kpis">';
|
|
|
363 |
h+=kpi('⚖','#fff4e6','#f08c00','Adherence',k.adherencePct+'<small>%</small>',num(k.doneStops)+' of '+num(k.plannedStops)+' stops done',adhColor(k.adherencePct));
|
|
|
364 |
h+=kpi('🗺','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad');
|
|
|
365 |
h+=kpi('🚶','#ebfbee','#2f9e44','Visits / active day',k.avgVisitsPerActiveDay,'across '+num(k.journeys)+' journeys');
|
|
|
366 |
h+=kpi('💬','#eef1fb','#3b5bdb','Discussion / store',k.avgDiscussionMin+' <small>min</small>','target 30–45 min', k.avgDiscussionMin>0&&k.avgDiscussionMin<30?'#e8590c':null);
|
|
|
367 |
h+=kpi('↻','#fff0f0','#e03131','Auto "missed"',num(k.autoMissed),k.autoMissedSystemPct+'% system-flagged','#e03131');
|
|
|
368 |
h+=kpi('★','#fff0f6','#d6336c','Leads on route',num(k.leadsCreated),'this period');
|
|
|
369 |
h+='</div>';
|
|
|
370 |
|
|
|
371 |
h+='<div class="sectionlabel">Aggregate Journey Funnel</div><div class="card"><div class="funnel">';
|
|
|
372 |
var pl=f.plannedStops||1;
|
|
|
373 |
h+=fstep('Planned stops',f.plannedStops,100,'scheduled',false,false);
|
|
|
374 |
h+=fstep('Days punched in',f.punchedIn,pct(f.punchedIn,pl),'−'+f.dropPunch+'% no punch-in',true,false);
|
|
|
375 |
h+=fstep('Checked in',f.checkedIn,pct(f.checkedIn,pl),'−'+f.dropCheckin+'% reached, skipped',true,false);
|
|
|
376 |
h+=fstep('Checked out (done)',f.checkedOut,pct(f.checkedOut,pl),'−'+f.dropCheckout+'% no checkout',false,true);
|
|
|
377 |
h+='</div><div class="leak">';
|
|
|
378 |
h+=leakbox('Deferred — genuine',df.human,'#2f9e44');
|
|
|
379 |
h+=leakbox('Deferred — system flag',df.systemGenerated,'#f08c00');
|
|
|
380 |
h+=leakbox('Heads acted on',df.actioned,null);
|
|
|
381 |
h+=leakbox('Never touched',neverTouched,'#e03131');
|
|
|
382 |
h+='</div></div>';
|
|
|
383 |
|
|
|
384 |
h+='<div class="sectionlabel">Adherence & Coverage</div><div class="grid g3">';
|
|
|
385 |
h+='<div class="card"><h2>Per-executive scorecard <span style="color:#97a1b5;font-weight:500;font-size:12px">· '+d.scorecard.length+' executives</span></h2><p class="desc">The coaching list — click a header to sort. Reds are where to intervene.</p>'+scorecardTable(d.scorecard)+'</div>';
|
|
|
386 |
h+='<div class="card"><h2>Coverage by sales level</h2><p class="desc">Filled = executives with a plan; light = total at that level.</p>'+coverageBars(d.coverageByLevel)+utilBlock(u)+'</div>';
|
|
|
387 |
h+='</div>';
|
|
|
388 |
|
|
|
389 |
h+='<div class="sectionlabel">Deferrals, Travel & Outcomes</div><div class="grid g2">';
|
|
|
390 |
h+='<div class="card"><h2>Deferral nature</h2><p class="desc">Deliberate, or just auto-flagged missed beats?</p>'+deferDonut(df)+'</div>';
|
|
|
391 |
h+='<div class="card"><h2>Travel & route quality</h2><p class="desc">Straight-line distance per leg between consecutive stops.</p>'+travelBlock(t)+'</div>';
|
|
|
392 |
h+='</div>';
|
|
|
393 |
|
|
|
394 |
h+='<div class="sectionlabel">Outcomes & Visit Quality</div><div class="card"><p class="desc">Captured on the check-out form — the substance of each store conversation.</p>'+
|
|
|
395 |
'<div class="tiles" style="grid-template-columns:repeat(4,1fr)">'+
|
|
|
396 |
tile('Partner remark filled', o.remarkFilledPct+'<small>%</small>')+
|
|
|
397 |
tile('Avg partner rating', (o.avgRating||0)+'<small>/5</small>')+
|
|
|
398 |
tile('Audit checklist done', o.auditDonePct+'<small>%</small>')+
|
|
|
399 |
tile('Next-visit follow-up set', o.nextVisitPct+'<small>%</small>')+
|
|
|
400 |
'</div></div>';
|
|
|
401 |
|
|
|
402 |
h+='<div class="sectionlabel">What to fix</div>'+findingsBlock(d.findings);
|
|
|
403 |
h+=sourcesCard();
|
|
|
404 |
el('bj-content').innerHTML=h;
|
|
|
405 |
window._scorecard = d.scorecard;
|
|
|
406 |
}
|
|
|
407 |
var sortKey=null, sortAsc=false;
|
|
|
408 |
function sortCard(key){
|
|
|
409 |
if(sortKey===key) sortAsc=!sortAsc; else { sortKey=key; sortAsc=false; }
|
|
|
410 |
window._scorecard.sort(function(a,b){ var x=a[key],y=b[key];
|
|
|
411 |
if(typeof x==='string') return sortAsc? (''+x).localeCompare(y):(''+y).localeCompare(x);
|
|
|
412 |
return sortAsc? x-y : y-x; });
|
|
|
413 |
document.getElementById('scTbl').outerHTML = scorecardTable(window._scorecard);
|
|
|
414 |
}
|
|
|
415 |
function scorecardTable(rows){
|
|
|
416 |
if(!rows||!rows.length) return '<p class="desc">No beat owners in scope for this period.</p>';
|
|
|
417 |
var h='<table id="scTbl"><thead><tr>'+
|
|
|
418 |
'<th onclick="sortCard(\'name\')">Executive</th>'+
|
|
|
419 |
'<th class="num" onclick="sortCard(\'planned\')">Plan</th>'+
|
|
|
420 |
'<th class="num" onclick="sortCard(\'done\')">Done</th>'+
|
|
|
421 |
'<th class="num" onclick="sortCard(\'adherencePct\')">Adher.</th>'+
|
|
|
422 |
'<th class="num" onclick="sortCard(\'discussionMin\')">Disc.</th>'+
|
|
|
423 |
'<th class="num" onclick="sortCard(\'workingHrs\')">Hrs</th>'+
|
|
|
424 |
'<th class="num" onclick="sortCard(\'leads\')">Leads</th>'+
|
|
|
425 |
'<th class="num" onclick="sortCard(\'geoFlags\')">Geo⚠</th></tr></thead><tbody>';
|
|
|
426 |
rows.forEach(function(r){
|
|
|
427 |
var c=adhColor(r.adherencePct);
|
|
|
428 |
h+='<tr><td>'+esc(r.name)+' <span class="lvl">'+esc(r.level)+'</span></td>'+
|
|
|
429 |
'<td class="num">'+r.planned+'</td><td class="num">'+r.done+'</td>'+
|
|
|
430 |
'<td class="num"><b style="color:'+c+'">'+r.adherencePct+'%</b><span class="meter"><i style="width:'+Math.min(100,r.adherencePct)+'%;background:'+c+'"></i></span></td>'+
|
| 36882 |
vikas |
431 |
'<td class="num'+(r.discussionMin>0&&r.discussionMin<10?' warnflag':'')+'">'+(r.discussionMin||'-')+(r.discussionMin?'m':'')+'</td>'+
|
| 36877 |
vikas |
432 |
'<td class="num">'+r.workingHrs+'</td><td class="num">'+r.leads+'</td>'+
|
|
|
433 |
'<td class="num'+(r.geoFlags>0?' badflag':'')+'">'+r.geoFlags+'</td></tr>';
|
|
|
434 |
});
|
|
|
435 |
return h+'</tbody></table>';
|
|
|
436 |
}
|
|
|
437 |
function coverageBars(rows){
|
|
|
438 |
if(!rows||!rows.length) return '<p class="desc">No coverage data.</p>';
|
|
|
439 |
var max=0; rows.forEach(function(r){ if(r.totalUsers>max)max=r.totalUsers; }); if(!max)max=1;
|
|
|
440 |
var h=''; rows.forEach(function(r){
|
|
|
441 |
var w=pct(r.withPjp,max), wt=pct(r.totalUsers,max);
|
|
|
442 |
h+='<div class="barrow"><div class="name">'+esc(r.level)+'</div><div class="track">'+
|
|
|
443 |
'<div style="position:absolute;inset:0;width:'+wt+'%;background:#e3e8f5;border-radius:7px"></div>'+
|
|
|
444 |
'<div class="fill" style="width:'+w+'%;position:relative;background:linear-gradient(90deg,#1098ad,#22b8cf)">'+(r.withPjp||'')+'</div></div>'+
|
|
|
445 |
'<div class="meta">'+r.withPjp+'/'+r.totalUsers+' · '+r.coveragePct+'%</div></div>';
|
|
|
446 |
});
|
|
|
447 |
return h;
|
|
|
448 |
}
|
|
|
449 |
function utilBlock(u){
|
|
|
450 |
return '<h2 style="margin-top:18px">Working-hour utilization</h2><p class="desc">Where the average working day goes.</p>'+
|
|
|
451 |
'<div class="util">'+
|
|
|
452 |
'<div style="width:'+u.inStorePct+'%;background:#2f9e44">In-store '+u.inStorePct+'%</div>'+
|
|
|
453 |
'<div style="width:'+u.travelPct+'%;background:#3b5bdb">Travel '+u.travelPct+'%</div>'+
|
|
|
454 |
'<div style="width:'+u.idlePct+'%;background:#e03131">Idle '+u.idlePct+'%</div></div>'+
|
|
|
455 |
'<div class="utlegend"><span><span class="dot" style="background:#2f9e44;margin:0"></span>Selling '+u.inStoreHrs+' h</span>'+
|
|
|
456 |
'<span><span class="dot" style="background:#3b5bdb;margin:0"></span>Travel '+u.travelHrs+' h</span>'+
|
|
|
457 |
'<span><span class="dot" style="background:#e03131;margin:0"></span>Idle '+u.idleHrs+' h</span></div>';
|
|
|
458 |
}
|
|
|
459 |
function deferDonut(df){
|
|
|
460 |
var sysPct=df.systemPct||0;
|
|
|
461 |
var g='conic-gradient(#f59f00 0 '+sysPct+'%, #2f9e44 '+sysPct+'% 100%)';
|
|
|
462 |
return '<div class="donutwrap"><div class="donut" style="background:'+g+'"><div class="center">'+
|
|
|
463 |
'<div style="font-size:24px;font-weight:780">'+num(df.total)+'</div><div style="font-size:11px;color:#97a1b5">deferrals</div></div></div>'+
|
|
|
464 |
'<div class="legend">'+
|
|
|
465 |
'<div class="it"><span class="dot" style="background:#f59f00"></span><div><b>'+df.systemGenerated+'</b> system auto-flagged ('+sysPct+'%)<br><span style="color:#97a1b5;font-size:11.5px">"beat missed", "no punch-in"</span></div></div>'+
|
|
|
466 |
'<div class="it"><span class="dot" style="background:#2f9e44"></span><div><b>'+df.human+'</b> genuine reason ('+df.humanPct+'%)</div></div>'+
|
|
|
467 |
'<div class="it"><span class="dot" style="background:#3b5bdb"></span><div><b>'+df.actioned+'</b> acted on by a head'+(df.avgDaysToAction?'<br><span style="color:#97a1b5;font-size:11.5px">avg '+df.avgDaysToAction+' days to action</span>':'')+'</div></div>'+
|
|
|
468 |
'</div></div><p class="desc" style="margin-top:13px">"System" vs "genuine" is read from the deferral reason; there is no separate flag.</p>';
|
|
|
469 |
}
|
|
|
470 |
function travelBlock(t){
|
|
|
471 |
var rows=t.legBuckets||[], max=0; rows.forEach(function(r){ if(r.count>max)max=r.count; }); if(!max)max=1;
|
|
|
472 |
var colors={'0-2 km':'#2f9e44','2-5 km':'#2f9e44','5-15 km':'#82c91e','15-50 km':'#f59f00','50 km+':'#e03131'};
|
|
|
473 |
var h=''; rows.forEach(function(r){
|
|
|
474 |
h+='<div class="barrow"><div class="name">'+esc(r.bucket)+'</div><div class="track"><div class="fill" style="width:'+pct(r.count,max)+'%;background:'+(colors[r.bucket]||'#3b5bdb')+'">'+r.count+'</div></div><div class="meta"></div></div>';
|
|
|
475 |
});
|
|
|
476 |
h+='<div class="tiles" style="margin-top:14px">'+
|
|
|
477 |
tile('Avg leg', t.avgLegKm+'<small> km</small>')+
|
|
|
478 |
tile('Legs > 15 km', t.pctOver15+'<small>%</small>')+
|
|
|
479 |
tile('Actual / estimate', (t.actualVsEstimateRatio||0)+'<small>×</small>')+'</div>';
|
|
|
480 |
return h;
|
|
|
481 |
}
|
|
|
482 |
function findingsBlock(items){
|
|
|
483 |
if(!items||!items.length) return '<div class="callout"><h2>Nothing flagged</h2><p class="desc">No issues crossed the alert thresholds for this period.</p></div>';
|
|
|
484 |
var sev={high:'#e03131',med:'#f59f00',low:'#2f9e44'};
|
|
|
485 |
var h='<div class="callout"><h2>Recommended management actions</h2><p class="desc">Generated from this period\'s data.</p>';
|
|
|
486 |
items.forEach(function(x){
|
|
|
487 |
h+='<div class="fix"><div class="sev" style="background:'+(sev[x.severity]||'#3b5bdb')+'"></div><div class="fb">'+
|
|
|
488 |
'<div class="ft">'+esc(x.title)+'</div><div class="fd">'+esc(x.detail)+'</div>'+
|
|
|
489 |
'<div class="act">→ '+esc(x.action)+'</div></div></div>';
|
|
|
490 |
});
|
|
|
491 |
return h+'</div>';
|
|
|
492 |
}
|
|
|
493 |
function sourcesCard(){
|
|
|
494 |
return '<div class="sectionlabel">Where the numbers come from</div><div class="card">'+
|
|
|
495 |
'<p class="desc">Nothing new needs to be captured — everything here is built from what the field app already records on each journey.</p>'+
|
|
|
496 |
'<ul style="margin:0;padding-left:18px;line-height:1.95;font-size:13px;color:#3a4459">'+
|
|
|
497 |
'<li>The <b>journey timeline</b> (punch-in, check-ins/outs, deferrals, punch-out) comes from what the executive taps in the app.</li>'+
|
|
|
498 |
'<li><b>Adherence</b> compares the partners planned on each beat against the stores actually visited.</li>'+
|
|
|
499 |
'<li><b>Time, travel and distance</b> use the in-store and travel durations logged per visit.</li>'+
|
|
|
500 |
'<li><b>Geofence / fake-visit checks</b> compare the check-in GPS against the partner\'s saved shop location (flagged beyond ~50 m).</li>'+
|
|
|
501 |
'<li><b>Visit outcomes</b> (remarks, rating, audit, next visit) come from the check-out form.</li>'+
|
|
|
502 |
'<li><b>Coverage and the manager view</b> follow the existing L1–L5 sales hierarchy, so each manager sees only their team.</li>'+
|
|
|
503 |
'</ul></div>';
|
|
|
504 |
}
|
|
|
505 |
|
|
|
506 |
/* ============================ EXEC DRILLDOWN ============================ */
|
|
|
507 |
function openExec(userId, name, date){
|
|
|
508 |
if(!userId){ return; }
|
|
|
509 |
var mb=el('bj-modal-back'), m=el('bj-modal');
|
|
|
510 |
m.innerHTML='<button class="x" onclick="closeModal()">×</button><h3>'+esc(name)+'</h3><p class="desc">Journey detail · '+esc(date)+'</p><div class="loading"><div class="spinner"></div>Loading…</div>';
|
|
|
511 |
mb.classList.add('show');
|
| 36882 |
vikas |
512 |
fetch(CTX+'/beat-journey/journey-detail?userId='+userId+'&date='+encodeURIComponent(date),{headers:{'Accept':'application/json'}})
|
| 36877 |
vikas |
513 |
.then(function(r){ return r.json(); }).then(function(d){
|
| 36882 |
vikas |
514 |
window._mediaUrl = d.mediaUrl || '';
|
|
|
515 |
var all=(d.visits||[]).slice();
|
|
|
516 |
// Sequence like the field report: Punch in -> visited stops (by check-in
|
|
|
517 |
// time) -> PENDING/not-visited -> Punch out. The attendance row carries
|
|
|
518 |
// both punch-in & punch-out times; split it into the two bookends.
|
|
|
519 |
var att=null, visited=[], pending=[];
|
|
|
520 |
all.forEach(function(x){
|
|
|
521 |
var tt=(x.taskType||'').toLowerCase(), mt=(x.markType||'').toUpperCase();
|
|
|
522 |
if(tt==='attendance' || mt.indexOf('PUNCH')>=0){ if(!att) att=x; }
|
|
|
523 |
else if(x.checkInTime){ visited.push(x); }
|
|
|
524 |
else { pending.push(x); }
|
|
|
525 |
});
|
|
|
526 |
visited.sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); });
|
|
|
527 |
|
|
|
528 |
// totals across the day's stops (attendance row excluded from leg sums)
|
|
|
529 |
var distKm=0, inStoreSec=0, transitSec=0, leadsN=0;
|
|
|
530 |
all.forEach(function(x){
|
|
|
531 |
var tt=(x.taskType||'').toLowerCase();
|
|
|
532 |
if(tt==='attendance') return;
|
|
|
533 |
if(x.totalDistance) distKm += (parseFloat(x.totalDistance)||0);
|
|
|
534 |
inStoreSec += toSec(x.timeSpent);
|
|
|
535 |
transitSec += toSec(x.transitTime);
|
|
|
536 |
if(tt==='lead') leadsN++;
|
|
|
537 |
});
|
|
|
538 |
var workSec = (att && att.checkInTime && att.checkOutTime) ? (toSec(att.checkOutTime)-toSec(att.checkInTime)) : (att?toSec(att.timeSpent):0);
|
|
|
539 |
if(workSec<0) workSec=0;
|
|
|
540 |
|
|
|
541 |
var seq=[];
|
|
|
542 |
if(att && att.checkInTime) seq.push(punchEntry('in', att.checkInTime));
|
|
|
543 |
visited.forEach(function(x){ seq.push(x); });
|
|
|
544 |
pending.forEach(function(x){ seq.push(x); });
|
|
|
545 |
if(att && att.checkOutTime) seq.push(punchEntry('out', att.checkOutTime));
|
|
|
546 |
|
|
|
547 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+esc(name)+'</h3>'+
|
|
|
548 |
'<p class="desc">Journey detail · '+esc(date)+' · battery '+(d.batteryLevel==null?'-':d.batteryLevel+'%')+' · loc-off '+(d.locationToggleCount||0)+'</p>';
|
|
|
549 |
h+='<div class="jstats">'+
|
|
|
550 |
jstat(fmtSec(workSec),'Working hours')+
|
|
|
551 |
jstat(fmtSec(inStoreSec),'In-store time')+
|
|
|
552 |
jstat(fmtSec(transitSec),'Travel time')+
|
|
|
553 |
jstat(distKm.toFixed(2)+' km','Distance Covered')+
|
|
|
554 |
jstat(visited.length+(leadsN?' · '+leadsN+'L':''),'Stores visited')+
|
|
|
555 |
'</div>';
|
|
|
556 |
if(!seq.length){ h+='<p class="desc">No tracking events for this day.</p>'; }
|
| 36877 |
vikas |
557 |
else{
|
| 36882 |
vikas |
558 |
h+='<div class="tl">';
|
|
|
559 |
var ri=0;
|
|
|
560 |
seq.forEach(function(x){
|
|
|
561 |
// travel connector before each visited stop: time taken + distance to reach it
|
|
|
562 |
if(!x._punch && x.checkInTime){
|
|
|
563 |
var tr=(x.transitTime && x.transitTime!=='00:00:00') ? x.transitTime : null;
|
|
|
564 |
var km=(x.totalDistance && parseFloat(x.totalDistance)>0) ? (parseFloat(x.totalDistance).toFixed(2)+' km') : null;
|
|
|
565 |
if(tr || km){
|
|
|
566 |
var parts=[]; if(tr) parts.push('🚗 '+esc(tr)); if(km) parts.push(esc(km));
|
|
|
567 |
h+='<div class="tlgap"><span class="gappill">'+parts.join(' · ')+'</span></div>';
|
|
|
568 |
}
|
|
|
569 |
}
|
|
|
570 |
h+=tlRow(x, ri%2===0?'left':'right'); ri++;
|
| 36877 |
vikas |
571 |
});
|
| 36882 |
vikas |
572 |
h+='</div>';
|
| 36877 |
vikas |
573 |
}
|
|
|
574 |
m.innerHTML=h;
|
|
|
575 |
}).catch(function(){ m.innerHTML='<button class="x" onclick="closeModal()">×</button><p class="desc">Could not load detail.</p>'; });
|
|
|
576 |
}
|
| 36882 |
vikas |
577 |
function tlSecs(t){ if(!t) return 1e9; var p=t.split(':'); return (+p[0])*3600+(+(p[1]||0))*60+(+(p[2]||0)); }
|
|
|
578 |
function toSec(t){ if(!t||t==='00:00:00') return 0; var p=t.split(':'); return (+p[0])*3600+(+(p[1]||0))*60+(+(p[2]||0)); }
|
|
|
579 |
function fmtSec(s){ s=Math.max(0,Math.round(s)); var h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60; function z(n){return (n<10?'0':'')+n;} return z(h)+':'+z(m)+':'+z(ss); }
|
|
|
580 |
function jstat(v,l){ return '<div class="js"><div class="jv">'+v+'</div><div class="jl">'+l+'</div></div>'; }
|
|
|
581 |
function hhmm(t){ return t? t.slice(0,5):''; }
|
|
|
582 |
function tlThumb(att){
|
|
|
583 |
if(att && window._mediaUrl) return '<img class="tlthumb" src="'+esc(window._mediaUrl+att)+'" onclick="window.open(this.src,\'_blank\')" onerror="tlImgErr(this)">';
|
|
|
584 |
return '<div class="tlph" title="No photo">📷</div>';
|
|
|
585 |
}
|
|
|
586 |
function tlImgErr(im){ var d=document.createElement('div'); d.className='tlph'; d.title='Photo unavailable'; d.innerHTML='📷'; if(im.parentNode) im.parentNode.replaceChild(d, im); }
|
|
|
587 |
function punchEntry(kind, time){ return {_punch:kind, checkInTime:time, markType:(kind==='in'?'PUNCHIN':'PUNCHOUT'), taskType:'attendance'}; }
|
|
|
588 |
function tlStyle(x){
|
|
|
589 |
var mt=(x.markType||'').toUpperCase(), tt=(x.taskType||'').toLowerCase();
|
|
|
590 |
if(x._punch || tt==='attendance' || mt.indexOf('PUNCH')>=0) return {c:'#7048e8', l:(x._punch==='out'?'Punch out':'Punch in'), ic:'⚑'};
|
|
|
591 |
if(mt.indexOf('CHECKOUT')>=0) return {c:'#2f9e44', l:'Visited', ic:'✓'};
|
|
|
592 |
if(mt==='CHECKIN') return {c:'#1098ad', l:'In store', ic:'📍'};
|
|
|
593 |
if(mt==='DEFERRED') return {c:'#e8590c', l:'Deferred', ic:'↻'};
|
|
|
594 |
if(mt==='PENDING' || !x.checkInTime) return {c:'#97a1b5', l:'Pending', ic:'○'};
|
|
|
595 |
return {c:'#3b5bdb', l:(mt||'-'), ic:'●'};
|
|
|
596 |
}
|
|
|
597 |
function tlTitle(x){
|
|
|
598 |
if(x._punch) return x._punch==='in' ? 'Journey start (Punch in)' : 'Journey end (Punch out)';
|
|
|
599 |
var n=x.taskName||'';
|
|
|
600 |
var pipe=n.lastIndexOf('|'); if(pipe>=0) n=n.slice(pipe+1); // drop leading agenda tags
|
|
|
601 |
n=n.replace(/\s*VISIT\b.*$/i,'').replace(/\s*visit\s*$/i,'').replace(/\bnull\b/gi,'').replace(/\s{2,}/g,' ').trim();
|
|
|
602 |
return n || '(stop)';
|
|
|
603 |
}
|
|
|
604 |
function tlRow(x, side){
|
|
|
605 |
var s=tlStyle(x);
|
|
|
606 |
var tt=(x.taskType||'').toLowerCase();
|
|
|
607 |
var timeTop = x.checkInTime ? hhmm(x.checkInTime) : '-';
|
|
|
608 |
var tlabel = x._punch ? (x._punch==='in'?'punch in':'punch out') : (x.checkInTime?'check-in':'pending');
|
|
|
609 |
var meta;
|
|
|
610 |
if(x._punch){
|
|
|
611 |
meta = (x._punch==='in'?'Punch in':'Punch out') + ' · ' + hhmm(x.checkInTime);
|
|
|
612 |
} else {
|
|
|
613 |
var io = (x.checkInTime?hhmm(x.checkInTime):'-') + ' → ' + (x.checkOutTime?hhmm(x.checkOutTime):'-');
|
|
|
614 |
var dur = (x.timeSpent && x.timeSpent!=='00:00:00') ? ' · '+esc(x.timeSpent) : '';
|
|
|
615 |
var typeLabel = tt==='lead' ? 'Lead' : (tt==='office-visit'?'Office':'Partner');
|
|
|
616 |
meta = x.checkInTime ? (typeLabel+' · '+io+dur) : (typeLabel+' · not visited');
|
|
|
617 |
}
|
|
|
618 |
// image (or placeholder) + battery + location-toggle on real stops
|
|
|
619 |
var extra='';
|
|
|
620 |
if(!x._punch){
|
|
|
621 |
var chips='';
|
|
|
622 |
if(x.battery!=null) chips += '<span class="tlchip">🔋 '+x.battery+'%</span>';
|
|
|
623 |
if(x.locOff!=null) chips += '<span class="tlchip">📴 '+x.locOff+'</span>';
|
|
|
624 |
extra = '<div class="tlextra">'+tlThumb(x.attachment)+chips+'</div>';
|
|
|
625 |
}
|
|
|
626 |
return '<div class="tlrow '+side+'">'+
|
|
|
627 |
'<div class="node" style="background:'+s.c+'">'+s.ic+'</div>'+
|
|
|
628 |
'<div class="tlcard">'+
|
|
|
629 |
'<div class="tm" style="background:'+s.c+'">'+timeTop+'<small>'+tlabel+'</small></div>'+
|
|
|
630 |
'<div class="bd"><div class="ti">'+esc(tlTitle(x))+'</div>'+
|
|
|
631 |
'<span class="stt" style="background:'+s.c+'">'+s.l+'</span>'+
|
|
|
632 |
'<div class="mt">'+meta+'</div>'+extra+'</div>'+
|
|
|
633 |
'</div></div>';
|
|
|
634 |
}
|
| 36877 |
vikas |
635 |
function closeModal(){ el('bj-modal-back').classList.remove('show'); }
|
|
|
636 |
|
|
|
637 |
/* ============================ APPROVAL ============================ */
|
|
|
638 |
function loadApproval(){
|
|
|
639 |
spinner();
|
|
|
640 |
var from=el('bj-start').value, to=el('bj-end').value;
|
|
|
641 |
var u=CTX+'/beat-report/approval/data?fromDate='+encodeURIComponent(from)+'&toDate='+encodeURIComponent(to)+qs();
|
|
|
642 |
fetch(u,{headers:{'Accept':'application/json'}}).then(function(r){ return r.json(); }).then(function(d){
|
|
|
643 |
var rows=d.rows||[];
|
|
|
644 |
var h='<div class="card"><h2>Location Approval <span style="color:#97a1b5;font-weight:500;font-size:12px">· '+rows.length+' off-site check-ins</span></h2>'+
|
|
|
645 |
'<p class="desc">Check-ins recorded >50 m from the partner\'s saved location, across your downline. Approving updates the partner\'s coordinates.</p>';
|
|
|
646 |
if(!rows.length){ h+='<p class="desc">Nothing pending in this window.</p></div>'; el('bj-content').innerHTML=h; return; }
|
|
|
647 |
h+='<table><thead><tr><th>Executive</th><th>Store</th><th>Date</th><th>Check-in</th><th class="num">Distance</th><th class="num">Action</th></tr></thead><tbody>';
|
|
|
648 |
rows.forEach(function(r){
|
|
|
649 |
h+='<tr id="appr-'+r.trackingId+'"><td>'+esc(r.username)+'</td><td>'+esc(r.taskName)+'</td><td>'+esc(r.taskDate)+'</td><td>'+esc(r.checkInTime)+'</td>'+
|
|
|
650 |
'<td class="num '+(r.distanceM>200?'badflag':'warnflag')+'">'+r.distanceM+' m</td>'+
|
|
|
651 |
'<td class="num"><button class="btn-ok" onclick="approve('+r.trackingId+')">Approve</button></td></tr>';
|
|
|
652 |
});
|
|
|
653 |
h+='</tbody></table></div>';
|
|
|
654 |
el('bj-content').innerHTML=h;
|
|
|
655 |
}).catch(fail);
|
|
|
656 |
}
|
|
|
657 |
function approve(trackingId){
|
|
|
658 |
fetch(CTX+'/beat-report/approve-checkin',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'trackingId='+trackingId})
|
|
|
659 |
.then(function(r){ return r.json(); }).then(function(){ var row=el('appr-'+trackingId); if(row) row.parentNode.removeChild(row); }).catch(function(){ alert('Approve failed'); });
|
|
|
660 |
}
|
|
|
661 |
|
|
|
662 |
/* ============================ INIT ============================ */
|
|
|
663 |
document.querySelectorAll('.lens').forEach(function(b){ b.addEventListener('click', function(){ setLens(b.getAttribute('data-lens')); }); });
|
|
|
664 |
el('bj-apply').addEventListener('click', load);
|
|
|
665 |
el('bj-modal-back').addEventListener('click', function(e){ if(e.target===el('bj-modal-back')) closeModal(); });
|
|
|
666 |
load();
|
|
|
667 |
</script>
|
|
|
668 |
</body>
|
|
|
669 |
</html>
|