| 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}
|
| 36917 |
vikas |
36 |
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
|
| 36929 |
vikas |
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);display:flex;flex-direction:column}
|
| 36905 |
vikas |
38 |
.kpi.clickable{cursor:pointer;transition:border-color .12s,box-shadow .12s,transform .12s}
|
|
|
39 |
.kpi.clickable:hover{border-color:#c7d0e8;box-shadow:0 3px 10px rgba(20,30,60,.09);transform:translateY(-1px)}
|
| 36877 |
vikas |
40 |
.kpi .top{display:flex;align-items:center;gap:8px}
|
|
|
41 |
.kpi .ic{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
42 |
.kpi .label{font-size:11.5px;color:var(--muted);font-weight:600;line-height:1.2}
|
| 36929 |
vikas |
43 |
.kpi .val{font-size:26px;font-weight:760;margin-top:auto;padding-top:9px;letter-spacing:-.02em;line-height:1}
|
| 36877 |
vikas |
44 |
.kpi .val small{font-size:13px;color:var(--soft);font-weight:600}
|
|
|
45 |
.kpi .foot{font-size:11.5px;color:var(--soft);margin-top:7px}
|
| 36929 |
vikas |
46 |
.kpi .brow{display:flex;align-items:flex-start;text-align:center}
|
|
|
47 |
.kpi .brow.blabs{margin-top:11px}
|
|
|
48 |
.kpi .brow.bvals{margin-top:auto;padding-top:4px}
|
|
|
49 |
.kpi .blab{flex:1;font-size:11px;color:var(--soft);font-weight:600;line-height:1.25}
|
|
|
50 |
.kpi .bval{flex:1;font-size:24px;font-weight:760;letter-spacing:-.02em;line-height:1}
|
|
|
51 |
.kpi .bval small{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
52 |
.kpi .bsep{color:var(--line);font-weight:600;align-self:center}
|
| 36877 |
vikas |
53 |
.grid{display:grid;gap:16px}
|
|
|
54 |
.g2{grid-template-columns:1fr 1fr}
|
|
|
55 |
.g3{grid-template-columns:1.55fr 1fr}
|
|
|
56 |
.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)}
|
|
|
57 |
.card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}
|
|
|
58 |
.card .desc{color:var(--muted);font-size:12.5px;margin:0 0 14px}
|
|
|
59 |
.sectionlabel{font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);font-weight:700;margin:24px 2px 12px}
|
|
|
60 |
.funnel{display:flex;gap:9px;align-items:stretch}
|
|
|
61 |
.fstep{flex:1;border:1px solid var(--line);border-radius:11px;padding:13px 12px;position:relative;text-align:center;background:#fbfcfe}
|
| 36905 |
vikas |
62 |
.fstep.clickable{cursor:pointer;transition:border-color .12s,box-shadow .12s,transform .12s}
|
|
|
63 |
.fstep.clickable:hover{border-color:#c7d0e8;box-shadow:0 3px 10px rgba(20,30,60,.09);transform:translateY(-1px)}
|
| 36916 |
vikas |
64 |
.fstep.active{border-color:var(--brand);background:#eef1fb;box-shadow:0 0 0 1px var(--brand) inset}
|
|
|
65 |
.boardfilter{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin:0 0 10px;padding:7px 11px;background:#eef1fb;border:1px solid #d7deef;border-radius:8px}
|
|
|
66 |
.boardfilter b{color:#28304a}
|
|
|
67 |
.boardfilter .clearf{margin-left:auto;border:1px solid #c7d0e8;background:#fff;color:#3b5bdb;border-radius:7px;padding:3px 10px;font-size:12px;font-weight:600;cursor:pointer}
|
|
|
68 |
.boardfilter .clearf:hover{background:#f1f4ff}
|
| 36877 |
vikas |
69 |
.fstep .fn{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
|
|
|
70 |
.fstep .fv{font-size:26px;font-weight:770;margin-top:6px}
|
|
|
71 |
.fstep .fbar{height:5px;border-radius:3px;margin-top:10px;background:linear-gradient(90deg,var(--brand),var(--brand2))}
|
|
|
72 |
.fstep .drop{font-size:11px;color:var(--red);margin-top:7px;font-weight:600}
|
|
|
73 |
.fstep .keep{font-size:11px;color:var(--good);margin-top:7px;font-weight:600}
|
|
|
74 |
.fstep .arrow{position:absolute;right:-12px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:15px}
|
| 36929 |
vikas |
75 |
.fstep .ssplit{display:flex;align-items:flex-start;margin-top:11px}
|
|
|
76 |
.fstep .scol{flex:1}
|
|
|
77 |
.fstep .scol .sl{font-size:11px;color:var(--soft);font-weight:600;line-height:1.25}
|
|
|
78 |
.fstep .scol .sv{font-size:23px;font-weight:760;letter-spacing:-.02em;line-height:1;margin-top:7px;color:#1b2436}
|
|
|
79 |
.fstep .scol .sv .u{font-size:11px;color:var(--soft);font-weight:600;margin-left:2px}
|
|
|
80 |
.fstep .scol .sv.good{color:#2f9e44}
|
|
|
81 |
.fstep .scol .sv.bad{color:#e8590c}
|
| 36877 |
vikas |
82 |
.leak{display:flex;gap:10px;margin-top:12px}
|
|
|
83 |
.leakbox{flex:1;border:1px solid var(--line2);border-radius:10px;padding:10px 12px;background:#f8f9fc}
|
|
|
84 |
.leakbox .lt{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
|
|
85 |
.leakbox .lv{font-size:20px;font-weight:760;margin-top:4px}
|
|
|
86 |
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
|
87 |
th,td{text-align:left;padding:9px 9px;border-bottom:1px solid var(--line2)}
|
|
|
88 |
th{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700;white-space:nowrap;cursor:pointer}
|
|
|
89 |
th.num,td.num{text-align:right}
|
|
|
90 |
tbody tr:hover{background:#f7f9fd}
|
|
|
91 |
tr.clickable{cursor:pointer}
|
|
|
92 |
.lvl{display:inline-block;font-size:10.5px;font-weight:700;padding:2px 6px;border-radius:5px;background:#eef1fb;color:var(--brand)}
|
|
|
93 |
.pill-state{display:inline-flex;align-items:center;gap:6px;font-weight:600;font-size:12.5px;padding:3px 9px;border-radius:999px}
|
|
|
94 |
.ps-store{background:#e7f5ff;color:#1971c2}
|
|
|
95 |
.ps-travel{background:#e6fcf5;color:#0ca678}
|
|
|
96 |
.ps-done{background:#ebfbee;color:#2f9e44}
|
|
|
97 |
.ps-idle{background:#fff4e6;color:#e8590c}
|
|
|
98 |
.ps-no{background:#fff0f0;color:#e03131}
|
|
|
99 |
.meter{display:inline-block;width:54px;height:7px;border-radius:4px;background:#eef1f6;vertical-align:middle;margin-left:7px;overflow:hidden}
|
|
|
100 |
.meter > i{display:block;height:100%;border-radius:4px}
|
|
|
101 |
.warnflag{color:var(--orange);font-weight:700}
|
|
|
102 |
.badflag{color:var(--red);font-weight:700}
|
| 37060 |
vikas |
103 |
.leavebtn{border:1px solid #ffd8a8;background:#fff4e6;color:#e8590c;font-weight:650;font-size:11px;padding:2px 9px;border-radius:6px;cursor:pointer;white-space:nowrap}
|
|
|
104 |
.leavebtn:hover{background:#ffe8cc}
|
|
|
105 |
.leavebtn:disabled{opacity:.6;cursor:default}
|
| 36877 |
vikas |
106 |
.rowwarn{background:#fff9f0}
|
|
|
107 |
.rowbad{background:#fff5f5}
|
| 36897 |
vikas |
108 |
.bstat{display:inline-block;font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:999px;white-space:nowrap}
|
|
|
109 |
.bstat.ok{background:#ebfbee;color:#2f9e44}
|
|
|
110 |
.bstat.warn{background:#fff4e6;color:#e8590c}
|
|
|
111 |
.bstat.no{background:#fff0f0;color:#e03131}
|
|
|
112 |
.bdot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
|
|
113 |
.ptime{display:inline-block;background:#ebfbee;color:#2f9e44;font-weight:700;font-size:11px;padding:2px 7px;border-radius:6px;font-variant-numeric:tabular-nums}
|
| 36913 |
vikas |
114 |
.potime{display:inline-block;background:#ebfbee;color: #D60303A8;font-weight:700;font-size:11px;padding:2px 7px;border-radius:6px;font-variant-numeric:tabular-nums}
|
| 36897 |
vikas |
115 |
td.cnt{font-weight:700;font-variant-numeric:tabular-nums}
|
|
|
116 |
.cgreen{color:#2f9e44}.camber{color:#f08c00}.cred{color:#e03131}.cmuted{color:#69748c}
|
| 36917 |
vikas |
117 |
tr.boardtot td{background:#f4f6fb;border-bottom:2px solid #cdd3df;font-weight:700}
|
| 36897 |
vikas |
118 |
.actbtn{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:7px;border:0;cursor:pointer;color:#fff;font-size:12px;margin-left:3px}
|
|
|
119 |
.actbtn.map{background:#12b886}.actbtn.view{background:#3b5bdb}
|
|
|
120 |
.actbtn.edit{background:#f08c00}.actbtn.add{background:#2f9e44;font-size:15px}
|
|
|
121 |
.actbtn:active{transform:translateY(1px)}
|
| 36877 |
vikas |
122 |
.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}
|
|
|
123 |
.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
124 |
.att .ab{flex:1}
|
|
|
125 |
.att .atitle{font-size:13px;font-weight:650}
|
|
|
126 |
.att .adet{font-size:12px;color:var(--muted);margin-top:2px}
|
|
|
127 |
.att .acount{font-size:18px;font-weight:780}
|
| 36882 |
vikas |
128 |
.att.clickable{cursor:pointer;transition:border-color .12s,box-shadow .12s}
|
|
|
129 |
.att.clickable:hover{border-color:#c7d0e8;box-shadow:0 2px 8px rgba(20,30,60,.08)}
|
|
|
130 |
.att .chev{color:#c2cadb;font-size:13px;margin-left:8px}
|
| 36941 |
vikas |
131 |
.attrow{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
|
|
|
132 |
.attrow .att{margin-bottom:0}
|
|
|
133 |
@media (max-width:1000px){.attrow{grid-template-columns:repeat(2,1fr)}}
|
| 36877 |
vikas |
134 |
.barrow{display:grid;grid-template-columns:60px 1fr 90px;align-items:center;gap:11px;margin:8px 0}
|
|
|
135 |
.barrow .name{font-size:13px;font-weight:600}
|
|
|
136 |
.track{background:#f1f3f9;border-radius:7px;height:20px;position:relative;overflow:hidden}
|
|
|
137 |
.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}
|
|
|
138 |
.barrow .meta{font-size:11.5px;color:var(--muted);text-align:right}
|
|
|
139 |
.donutwrap{display:flex;align-items:center;gap:20px}
|
|
|
140 |
.donut{width:138px;height:138px;border-radius:50%;flex:none;position:relative;
|
|
|
141 |
-webkit-mask:radial-gradient(circle 42px at center,transparent 98%,#000 100%);mask:radial-gradient(circle 42px at center,transparent 98%,#000 100%)}
|
|
|
142 |
.donut .center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;-webkit-mask:none;mask:none}
|
|
|
143 |
.legend{display:flex;flex-direction:column;gap:9px;font-size:12.5px}
|
|
|
144 |
.legend .it{display:flex;align-items:flex-start;gap:9px}
|
| 36913 |
vikas |
145 |
.legend .it.clickable{cursor:pointer;border-radius:8px;padding:4px 6px;margin:-4px -6px;transition:background .12s}
|
|
|
146 |
.legend .it.clickable:hover{background:#f1f3f9}
|
|
|
147 |
.barrow.clickable{cursor:pointer}
|
|
|
148 |
.barrow.clickable:hover .track{outline:2px solid #c7d0e8;outline-offset:1px;border-radius:7px}
|
|
|
149 |
.donut.clickable{cursor:pointer}
|
| 36877 |
vikas |
150 |
.dot{width:11px;height:11px;border-radius:3px;flex:none;margin-top:3px}
|
|
|
151 |
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}
|
|
|
152 |
.tile{background:#f8f9fc;border:1px solid var(--line2);border-radius:11px;padding:12px}
|
|
|
153 |
.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
|
|
154 |
.tile .v{font-size:21px;font-weight:760;margin-top:5px}
|
|
|
155 |
.tile .v small{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
156 |
.util{display:flex;height:30px;border-radius:8px;overflow:hidden}
|
|
|
157 |
.util div{display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:650;color:#fff}
|
|
|
158 |
.utlegend{display:flex;gap:18px;margin-top:12px;font-size:12px;color:var(--muted);flex-wrap:wrap}
|
|
|
159 |
.utlegend span{display:inline-flex;align-items:center;gap:7px}
|
|
|
160 |
.callout{background:linear-gradient(180deg,#fbfcff,#f4f7ff);border:1px solid #dfe6fb;border-radius:14px;padding:18px 20px;margin-top:6px}
|
|
|
161 |
.callout h2{margin:0 0 3px;font-size:16px}
|
|
|
162 |
.callout .desc{color:var(--muted);font-size:12.5px;margin-bottom:14px}
|
|
|
163 |
.fix{display:flex;gap:13px;padding:13px 0;border-bottom:1px solid #e6ebf8}
|
|
|
164 |
.fix:last-child{border-bottom:0}
|
|
|
165 |
.fix .sev{width:6px;border-radius:4px;flex:none}
|
|
|
166 |
.fix .fb{flex:1}
|
|
|
167 |
.fix .ft{font-size:14px;font-weight:700}
|
|
|
168 |
.fix .fd{font-size:13px;color:#3a4459;margin-top:4px;line-height:1.5}
|
|
|
169 |
.fix .fd b{color:var(--ink)}
|
|
|
170 |
.fix .act{font-size:12.5px;color:var(--good);margin-top:6px;font-weight:650}
|
|
|
171 |
.hide{display:none}
|
|
|
172 |
.loading{padding:70px;text-align:center;color:var(--muted)}
|
|
|
173 |
.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}
|
|
|
174 |
@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
175 |
.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}
|
| 36903 |
vikas |
176 |
/* image lightbox — stacks above the journey modal */
|
|
|
177 |
.img-back{position:fixed;inset:0;background:rgba(8,12,24,.82);display:none;align-items:center;justify-content:center;z-index:60;padding:30px}
|
|
|
178 |
.img-back.show{display:flex}
|
|
|
179 |
.img-back img{max-width:95%;max-height:92vh;border-radius:10px;box-shadow:0 16px 60px rgba(0,0,0,.5);background:#fff}
|
|
|
180 |
.img-x{position:absolute;top:18px;right:22px;width:40px;height:40px;border-radius:50%;border:0;background:rgba(255,255,255,.18);color:#fff;font-size:26px;line-height:1;cursor:pointer}
|
|
|
181 |
.img-x:hover{background:rgba(255,255,255,.3)}
|
| 36877 |
vikas |
182 |
.modal-back.show{display:flex}
|
| 36882 |
vikas |
183 |
.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}
|
|
|
184 |
.modal h3{margin:0 0 2px;font-size:17px;text-align:center}
|
|
|
185 |
.modal > .desc{text-align:center}
|
| 37039 |
vikas |
186 |
.modal .x,.rh-panel .x,.od-panel .x{position:absolute;top:14px;right:16px;cursor:pointer;color:var(--soft);font-size:22px;line-height:1;border:0;background:none}
|
| 36882 |
vikas |
187 |
/* journey totals bar */
|
|
|
188 |
.jstats{display:flex;gap:9px;margin:12px 0 2px;flex-wrap:wrap}
|
|
|
189 |
.js{flex:1;min-width:104px;background:#f8f9fc;border:1px solid var(--line2);border-radius:10px;padding:9px 10px;text-align:center}
|
|
|
190 |
.js .jv{font-size:15.5px;font-weight:760;color:var(--ink);font-variant-numeric:tabular-nums}
|
|
|
191 |
.js .jl{font-size:10px;color:var(--soft);text-transform:uppercase;letter-spacing:.04em;margin-top:3px;font-weight:600}
|
| 36877 |
vikas |
192 |
.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 |
193 |
/* journey timeline (exec drilldown) */
|
|
|
194 |
.tl{position:relative;margin:14px 2px 4px;padding:4px 0}
|
|
|
195 |
.tl::before{content:"";position:absolute;left:50%;top:6px;bottom:6px;width:2px;background:#e9edf5;transform:translateX(-50%)}
|
|
|
196 |
.tlgap{position:relative;display:flex;justify-content:center;margin:-3px 0}
|
|
|
197 |
.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}
|
| 36955 |
vikas |
198 |
.tlgap .gappill.overrun{border-color:var(--red);color:var(--red)}
|
| 36903 |
vikas |
199 |
.tlrow{position:relative;display:flex;justify-content:space-between;align-items:flex-start;margin:14px 0}
|
| 36882 |
vikas |
200 |
.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}
|
|
|
201 |
.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}
|
|
|
202 |
.tlrow.left .tlcard{flex-direction:row-reverse;text-align:right}
|
|
|
203 |
.tlrow.right .tlcard{flex-direction:row;text-align:left}
|
| 36903 |
vikas |
204 |
/* remark on the opposite half, hugging the centre line */
|
|
|
205 |
.tlremark{width:calc(50% - 26px);display:flex;align-items:flex-start;padding-top:6px}
|
|
|
206 |
.tlrow.right .tlremark{justify-content:flex-end}
|
|
|
207 |
.tlrow.left .tlremark{justify-content:flex-start}
|
|
|
208 |
.tlremark .rk{max-width:100%;background:#fbfcfe;border:1px solid var(--line2);border-left:3px solid #c7d0e8;border-radius:8px;padding:7px 10px;font-size:11.5px;color:#4a5468;line-height:1.45;word-break:break-word}
|
|
|
209 |
.tlrow.right .tlremark .rk{text-align:right}
|
|
|
210 |
.tlrow.left .tlremark .rk{text-align:left}
|
|
|
211 |
.tlremark .rkl{font-size:9px;text-transform:uppercase;letter-spacing:.05em;color:var(--soft);font-weight:700;margin-bottom:3px}
|
| 37038 |
vikas |
212 |
.tlremark .rkhist{display:inline-block;margin-top:6px;background:none;border:0;padding:0;color:var(--brand);font-size:10.5px;font-weight:700;cursor:pointer}
|
|
|
213 |
.tlremark .rkhist:hover{text-decoration:underline}
|
|
|
214 |
/* past-discussions drawer */
|
|
|
215 |
.rh-back{position:fixed;inset:0;background:rgba(15,22,40,.42);z-index:1200;display:none}
|
|
|
216 |
.rh-back.show{display:block}
|
|
|
217 |
.rh-panel{position:absolute;top:0;right:0;height:100%;width:min(400px,92vw);background:#fff;box-shadow:-8px 0 30px rgba(20,30,60,.18);display:flex;flex-direction:column;padding:18px 18px 14px;transform:translateX(100%);transition:transform .22s ease}
|
|
|
218 |
.rh-back.show .rh-panel{transform:translateX(0)}
|
|
|
219 |
.rh-panel h3{margin:0 0 2px;font-size:15px;color:var(--ink)}
|
|
|
220 |
.rh-panel .desc{margin:0;font-size:12px;color:var(--soft)}
|
|
|
221 |
.rh-list{flex:1;overflow-y:auto;margin-top:12px;display:flex;flex-direction:column;gap:9px}
|
|
|
222 |
.rhitem{background:#fbfcfe;border:1px solid var(--line2);border-left:3px solid #c7d0e8;border-radius:9px;padding:9px 11px}
|
|
|
223 |
.rhtop{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
|
|
224 |
.rhdate{font-size:11px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}
|
|
|
225 |
.rhb{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:5px;white-space:nowrap}
|
|
|
226 |
.rhb.ok{background:#e7f7ec;color:#1a7a3c}
|
|
|
227 |
.rhb.def{background:#fff4e6;color:#e8590c}
|
|
|
228 |
.rhexec{font-size:10px;color:var(--soft);font-weight:600;margin:3px 0 4px}
|
|
|
229 |
.rhtext{font-size:12px;color:#4a5468;line-height:1.45;word-break:break-word;white-space:pre-wrap}
|
|
|
230 |
.rhloading,.rhempty{color:var(--soft);font-size:12px;text-align:center;padding:24px 8px}
|
|
|
231 |
.rh-more{margin-top:10px;background:var(--brand);color:#fff;border:0;border-radius:8px;padding:9px;font-weight:650;font-size:12.5px;cursor:pointer;display:none}
|
|
|
232 |
.rh-more:disabled{opacity:.6;cursor:default}
|
| 37044 |
vikas |
233 |
/* orders drawer (stacks above the remark drawer) */
|
| 37039 |
vikas |
234 |
.od-back{position:fixed;inset:0;background:rgba(15,22,40,.42);z-index:1300;display:none}
|
|
|
235 |
.od-back.show{display:block}
|
|
|
236 |
.od-panel{position:absolute;top:0;right:0;height:100%;width:min(440px,94vw);background:#fff;box-shadow:-8px 0 30px rgba(20,30,60,.18);display:flex;flex-direction:column;padding:18px 18px 14px;transform:translateX(100%);transition:transform .22s ease}
|
|
|
237 |
.od-back.show .od-panel{transform:translateX(0)}
|
|
|
238 |
.od-panel h3{margin:0 0 2px;font-size:15px;color:var(--ink)}
|
|
|
239 |
.od-panel .desc{margin:0;font-size:12px;color:var(--soft)}
|
|
|
240 |
.od-body{flex:1;overflow-y:auto;margin-top:12px}
|
| 37044 |
vikas |
241 |
.odorder{border:1px solid var(--line);border-radius:11px;padding:11px;margin-bottom:12px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
|
|
|
242 |
.odorder .odsum{margin-bottom:8px}
|
| 37039 |
vikas |
243 |
.odsum{display:flex;flex-wrap:wrap;gap:8px 18px;padding:10px 12px;background:#fbfcfe;border:1px solid var(--line2);border-radius:9px;margin-bottom:12px}
|
|
|
244 |
.odsum .k{font-size:9px;text-transform:uppercase;letter-spacing:.05em;color:var(--soft);font-weight:700}
|
|
|
245 |
.odsum .v{font-size:13px;font-weight:700;color:var(--ink);font-variant-numeric:tabular-nums}
|
|
|
246 |
.odtable{width:100%;border-collapse:collapse;font-size:11.5px}
|
|
|
247 |
.odtable th{text-align:left;color:var(--soft);font-weight:700;font-size:9px;text-transform:uppercase;letter-spacing:.03em;padding:5px 6px;border-bottom:1px solid var(--line2)}
|
|
|
248 |
.odtable td{padding:6px;border-bottom:1px solid #eef1f7;color:#4a5468;vertical-align:top}
|
|
|
249 |
.odtable td.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
|
250 |
.odloading,.odempty{color:var(--soft);font-size:12px;text-align:center;padding:24px 8px}
|
| 36882 |
vikas |
251 |
.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}
|
|
|
252 |
.tlcard .tm small{font-weight:600;font-size:8.5px;opacity:.9;text-transform:uppercase;letter-spacing:.04em;margin-top:3px}
|
|
|
253 |
.tlcard .bd{padding:8px 11px;flex:1;min-width:0}
|
|
|
254 |
.tlcard .bd .ti{font-size:12.5px;font-weight:700;color:var(--ink);line-height:1.25;word-break:break-word}
|
|
|
255 |
.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}
|
| 36941 |
vikas |
256 |
.tlcard .bd .stt.unplanned{background:#fff4e6;color:#e8590c;border:1px solid #ffd8a8;margin-left:5px}
|
| 36882 |
vikas |
257 |
.tlcard .bd .mt{font-size:11px;color:var(--muted);margin-top:6px}
|
| 37044 |
vikas |
258 |
.tlcard .bd .tlags{margin-top:6px}
|
|
|
259 |
.tlag{display:inline-block;font-size:9.5px;font-weight:700;color:#5646c9;background:#eeeafc;border:1px solid #ddd5f7;border-radius:5px;padding:1px 6px;margin:2px 4px 0 0}
|
|
|
260 |
.tlrow.left .tlcard .bd .tlag{margin:2px 0 0 4px}
|
| 36882 |
vikas |
261 |
.tlextra{display:flex;align-items:center;gap:7px;margin-top:8px;flex-wrap:wrap}
|
|
|
262 |
.tlrow.left .tlextra{justify-content:flex-end}
|
|
|
263 |
.tlrow.right .tlextra{justify-content:flex-start}
|
|
|
264 |
.tlthumb{width:46px;height:46px;border-radius:8px;object-fit:cover;border:1px solid var(--line);cursor:pointer;background:#f1f3f9}
|
|
|
265 |
.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}
|
|
|
266 |
.tlchip{font-size:10.5px;font-weight:600;color:var(--muted);background:#f1f3f9;border-radius:6px;padding:2px 7px;white-space:nowrap}
|
| 37039 |
vikas |
267 |
.tlbadge{font-size:10.5px;font-weight:700;border:0;border-radius:6px;padding:2px 8px;white-space:nowrap;cursor:pointer;line-height:1.5}
|
|
|
268 |
.tlbadge.ord{background:#e7f0ff;color:#1c56c9}
|
|
|
269 |
.tlbadge.ord:hover{background:#d4e4ff}
|
|
|
270 |
.tlbadge.sch{background:#fff4e6;color:#c25709;cursor:default}
|
| 36897 |
vikas |
271 |
.exectabs{display:flex;gap:6px;justify-content:center;margin:12px 0 4px}
|
|
|
272 |
.exectab{padding:6px 14px;border-radius:8px;font-size:12.5px;font-weight:650;color:var(--muted);cursor:pointer;border:1px solid var(--line);background:#fff}
|
|
|
273 |
.exectab.active{background:var(--brand);color:#fff;border-color:var(--brand)}
|
|
|
274 |
#bjMap{width:100%;height:460px;border-radius:12px;border:1px solid var(--line);margin-top:10px;background:#eef1f7;overflow:hidden}
|
| 36903 |
vikas |
275 |
.bjlegend{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:12px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#f8f9fc;font-size:12.5px;min-height:20px}
|
|
|
276 |
.bjlegend .lg{font-weight:700}
|
|
|
277 |
.bjlegend .sw{display:inline-block;width:24px;vertical-align:middle;margin-right:7px}
|
|
|
278 |
.bjlegend .ln{border-top:4px solid #e03131}
|
|
|
279 |
.bjlegend .dt{border-top:4px dotted #2f9e44}
|
| 36897 |
vikas |
280 |
.valwrap{display:flex;justify-content:center;margin:14px 0 6px}
|
|
|
281 |
.valimg{max-width:100%;max-height:440px;border-radius:12px;border:1px solid var(--line);cursor:zoom-in}
|
|
|
282 |
.valph{width:100%;padding:48px;text-align:center;color:var(--soft);border:1px dashed #cfd6e6;border-radius:12px;background:#f4f6fb;font-size:14px}
|
|
|
283 |
.valactions{display:flex;justify-content:center;gap:10px;margin-top:8px}
|
| 36877 |
vikas |
284 |
@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}}
|
| 36903 |
vikas |
285 |
@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}.tlremark{display:none}}
|
| 36877 |
vikas |
286 |
</style>
|
|
|
287 |
</head>
|
|
|
288 |
<body>
|
|
|
289 |
<div class="wrap">
|
|
|
290 |
<header class="top">
|
|
|
291 |
<div class="brand">
|
|
|
292 |
<div class="crumb"><a href="${rc.contextPath}/dashboard">← Dashboard</a> · Field Sales · Beat / Monthly PJP</div>
|
|
|
293 |
<h1>Beat Journey <span class="live"><i></i>Live</span></h1>
|
|
|
294 |
</div>
|
|
|
295 |
<div class="hd-right">
|
|
|
296 |
<select class="ctl" id="bj-level">
|
|
|
297 |
<option value="">All levels below me</option>
|
|
|
298 |
#foreach($lvl in $escalationTypes)
|
|
|
299 |
<option value="$lvl">$lvl</option>
|
|
|
300 |
#end
|
|
|
301 |
</select>
|
| 36897 |
vikas |
302 |
#if($topLevel)
|
|
|
303 |
<select class="ctl hide" id="bj-user">
|
|
|
304 |
<option value="">All users at level</option>
|
|
|
305 |
</select>
|
|
|
306 |
#end
|
| 36877 |
vikas |
307 |
<input class="ctl" type="date" id="bj-date" value="$today">
|
|
|
308 |
<span id="bj-period-range" class="hide">
|
|
|
309 |
<input class="ctl" type="date" id="bj-start" value="$monthStart">
|
|
|
310 |
<input class="ctl" type="date" id="bj-end" value="$today">
|
|
|
311 |
</span>
|
|
|
312 |
<button class="btn-r" id="bj-apply">Apply</button>
|
| 36903 |
vikas |
313 |
<button class="btn-r" id="bj-export" style="background:#2f9e44" title="Download an Excel report for the selected date / range">⬇ Export Excel</button>
|
| 36877 |
vikas |
314 |
</div>
|
|
|
315 |
</header>
|
|
|
316 |
|
|
|
317 |
<div class="lenses">
|
|
|
318 |
<button class="lens active" data-lens="today" id="lens-today">Today (Live)</button>
|
|
|
319 |
<button class="lens" data-lens="period" id="lens-period">Period (Analytics)</button>
|
|
|
320 |
</div>
|
|
|
321 |
|
|
|
322 |
<div id="bj-content"><div class="loading"><div class="spinner"></div>Loading…</div></div>
|
|
|
323 |
</div>
|
|
|
324 |
|
|
|
325 |
<div class="modal-back" id="bj-modal-back">
|
|
|
326 |
<div class="modal" id="bj-modal"></div>
|
|
|
327 |
</div>
|
|
|
328 |
|
| 36903 |
vikas |
329 |
<div class="img-back" id="bj-img-back">
|
|
|
330 |
<button class="img-x" id="bj-img-x" title="Close">×</button>
|
|
|
331 |
<img id="bj-img" alt="Attachment">
|
|
|
332 |
</div>
|
|
|
333 |
|
| 37038 |
vikas |
334 |
<!-- past-discussions drawer — slides in from the right, above the journey modal -->
|
|
|
335 |
<div class="rh-back" id="bj-rh-back" onclick="if(event.target===this)closeRemarkHistory()">
|
|
|
336 |
<div class="rh-panel" id="bj-rh-panel">
|
|
|
337 |
<button class="x" onclick="closeRemarkHistory()">×</button>
|
|
|
338 |
<h3 id="bj-rh-title"></h3>
|
|
|
339 |
<p class="desc" id="bj-rh-sub"></p>
|
|
|
340 |
<div class="rh-list" id="bj-rh-list"></div>
|
|
|
341 |
<button class="rh-more" id="bj-rh-more" onclick="bjLoadRemarkHistory()">Load more</button>
|
|
|
342 |
</div>
|
|
|
343 |
</div>
|
|
|
344 |
|
| 37044 |
vikas |
345 |
<!-- orders drawer — slides in from the right, above the remark drawer -->
|
| 37039 |
vikas |
346 |
<div class="od-back" id="bj-od-back" onclick="if(event.target===this)closeOrderDetail()">
|
|
|
347 |
<div class="od-panel" id="bj-od-panel">
|
|
|
348 |
<button class="x" onclick="closeOrderDetail()">×</button>
|
|
|
349 |
<h3 id="bj-od-title"></h3>
|
|
|
350 |
<p class="desc" id="bj-od-sub"></p>
|
|
|
351 |
<div class="od-body" id="bj-od-body"></div>
|
|
|
352 |
</div>
|
|
|
353 |
</div>
|
|
|
354 |
|
| 36877 |
vikas |
355 |
<script>
|
|
|
356 |
var CTX = '${rc.contextPath}';
|
|
|
357 |
var BASE = CTX + '/beat-journey';
|
|
|
358 |
var LENS = 'today';
|
| 36897 |
vikas |
359 |
var TODAY = '$today';
|
| 36962 |
vikas |
360 |
var CAN_EDIT_BEAT = $canEditBeat;
|
| 36877 |
vikas |
361 |
|
|
|
362 |
function el(id){ return document.getElementById(id); }
|
| 36897 |
vikas |
363 |
// The first lens reads "Today (Live)" for today's date, otherwise it shows the
|
|
|
364 |
// selected date (the tab is still the live/single-day view, just for that day).
|
|
|
365 |
function fmtDateLabel(d){
|
|
|
366 |
var p=(d||'').split('-'); if(p.length!==3) return d;
|
|
|
367 |
var mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
368 |
return parseInt(p[2],10)+' '+(mo[parseInt(p[1],10)-1]||p[1])+' '+p[0];
|
|
|
369 |
}
|
|
|
370 |
function updateTodayLabel(){
|
|
|
371 |
var b=el('bj-date'), lbl=el('lens-today'); if(!lbl) return;
|
|
|
372 |
var d=b?b.value:'';
|
|
|
373 |
lbl.innerHTML = (!d || d===TODAY) ? 'Today (Live)' : esc(fmtDateLabel(d));
|
|
|
374 |
}
|
| 36877 |
vikas |
375 |
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
|
|
376 |
function num(n){ return (n==null?0:n).toLocaleString(); }
|
|
|
377 |
function adhColor(p){ if(p>=80)return '#2f9e44'; if(p>=60)return '#f08c00'; if(p>=40)return '#e8590c'; return '#e03131'; }
|
|
|
378 |
function pct(a,b){ return b? Math.round(100*a/b):0; }
|
|
|
379 |
function qs(){
|
|
|
380 |
var lvl = el('bj-level').value;
|
|
|
381 |
var s = '';
|
|
|
382 |
if(lvl) s += '&escalationType=' + encodeURIComponent(lvl);
|
| 36897 |
vikas |
383 |
var usr = el('bj-user'); // only present for L5/Final
|
|
|
384 |
if(usr && usr.value) s += '&teamOfAuthUserId=' + encodeURIComponent(usr.value);
|
| 36877 |
vikas |
385 |
return s;
|
|
|
386 |
}
|
| 36897 |
vikas |
387 |
// L5/Final: when a level is picked, list its users so a specific manager's team
|
|
|
388 |
// can be selected. Selecting a user scopes the whole view to that user's team.
|
|
|
389 |
function onLevelChange(){
|
|
|
390 |
var usr = el('bj-user');
|
|
|
391 |
if(!usr){ load(); return; } // not top-level: just reload on level change
|
|
|
392 |
var lvl = el('bj-level').value;
|
|
|
393 |
usr.innerHTML = '<option value="">All users at level</option>';
|
|
|
394 |
if(!lvl){ usr.classList.add('hide'); load(); return; }
|
|
|
395 |
fetch(BASE + '/users?escalationType=' + encodeURIComponent(lvl), {headers:{'Accept':'application/json'}})
|
|
|
396 |
.then(function(r){ return r.json(); })
|
|
|
397 |
.then(function(d){
|
|
|
398 |
var us = d.users || [];
|
|
|
399 |
var h = '<option value="">All ' + esc(lvl) + ' users</option>';
|
|
|
400 |
us.forEach(function(u){ h += '<option value="' + u.id + '">' + esc(u.name) + '</option>'; });
|
|
|
401 |
usr.innerHTML = h;
|
|
|
402 |
usr.classList.toggle('hide', us.length === 0);
|
|
|
403 |
load();
|
|
|
404 |
})
|
|
|
405 |
.catch(function(){ usr.classList.add('hide'); load(); });
|
|
|
406 |
}
|
| 36877 |
vikas |
407 |
function spinner(){ el('bj-content').innerHTML = '<div class="loading"><div class="spinner"></div>Loading…</div>'; }
|
|
|
408 |
function fail(e){ el('bj-content').innerHTML = '<div class="loading">Could not load data ('+esc(e&&e.message||e)+'). Are you signed in?</div>'; }
|
|
|
409 |
|
|
|
410 |
function setLens(name){
|
|
|
411 |
LENS = name;
|
|
|
412 |
var tabs = document.querySelectorAll('.lens');
|
|
|
413 |
for(var i=0;i<tabs.length;i++){ tabs[i].classList.toggle('active', tabs[i].getAttribute('data-lens')===name); }
|
|
|
414 |
el('bj-date').classList.toggle('hide', name!=='today');
|
|
|
415 |
el('bj-period-range').classList.toggle('hide', name==='today');
|
|
|
416 |
load();
|
|
|
417 |
}
|
|
|
418 |
function load(){
|
|
|
419 |
if(LENS==='today') return loadToday();
|
|
|
420 |
if(LENS==='period') return loadPeriod();
|
|
|
421 |
return loadApproval();
|
|
|
422 |
}
|
|
|
423 |
|
|
|
424 |
/* ============================ TODAY ============================ */
|
|
|
425 |
function loadToday(){
|
| 36897 |
vikas |
426 |
updateTodayLabel();
|
| 36877 |
vikas |
427 |
spinner();
|
|
|
428 |
fetch(BASE + '/today?date=' + encodeURIComponent(el('bj-date').value) + qs(), {headers:{'Accept':'application/json'}})
|
|
|
429 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
430 |
.then(renderToday).catch(fail);
|
|
|
431 |
}
|
| 36905 |
vikas |
432 |
function kpi(ic, bg, fg, label, val, foot, valColor, click){
|
|
|
433 |
return '<div class="kpi'+(click?' clickable':'')+'"'+(click?' onclick="'+click+'" title="Click for details"':'')+'><div class="top"><div class="ic" style="background:'+bg+';color:'+fg+'">'+ic+'</div>'+
|
| 36929 |
vikas |
434 |
'<div class="label">'+label+'</div></div>'+
|
|
|
435 |
'<div class="foot">'+foot+'</div>'+
|
|
|
436 |
'<div class="val"'+(valColor?' style="color:'+valColor+'"':'')+'>'+val+'</div></div>';
|
| 36877 |
vikas |
437 |
}
|
| 36929 |
vikas |
438 |
// Multi-column breakdown card: centered title, a row of sub-labels, the number under each.
|
|
|
439 |
// cols = [[label, value], ...]; unit (optional) is appended after each number, e.g. 'min'.
|
|
|
440 |
function kpiCols(ic, bg, fg, title, cols, fg2, click, unit){
|
|
|
441 |
var lab='', val='', u=unit?' <small>'+unit+'</small>':'';
|
|
|
442 |
for(var i=0;i<cols.length;i++){
|
|
|
443 |
var sep=i?'<span class="bsep">·</span>':'';
|
|
|
444 |
lab+=sep+'<div class="blab">'+cols[i][0]+'</div>';
|
|
|
445 |
val+=(i?'<span class="bsep"> </span>':'')+'<div class="bval"'+(fg2?' style="color:'+fg2+'"':'')+'>'+num(cols[i][1])+u+'</div>';
|
|
|
446 |
}
|
|
|
447 |
return '<div class="kpi kpicols'+(click?' clickable':'')+'"'+(click?' onclick="'+click+'" title="Click for details"':'')+'>'+
|
|
|
448 |
'<div class="top"><div class="ic" style="background:'+bg+';color:'+fg+'">'+ic+'</div><div class="label">'+title+'</div></div>'+
|
|
|
449 |
'<div class="brow blabs">'+lab+'</div>'+
|
|
|
450 |
'<div class="brow bvals">'+val+'</div></div>';
|
|
|
451 |
}
|
|
|
452 |
// Period journey summary as a row of cards (old funnel layout). Each card is a
|
| 36941 |
vikas |
453 |
// group (Total Visits / Avg Time Spent / Idle-Break / Deferred / Completed),
|
|
|
454 |
// planned and self-assigned merged into one value. col=[[label,value,colorClass],...].
|
| 36929 |
vikas |
455 |
function summaryCard(title, cols, last){
|
|
|
456 |
var body='';
|
|
|
457 |
for(var i=0;i<cols.length;i++){
|
|
|
458 |
body+='<div class="scol"><div class="sl">'+cols[i][0]+'</div>'+
|
|
|
459 |
'<div class="sv'+(cols[i][2]?' '+cols[i][2]:'')+'">'+cols[i][1]+'</div></div>';
|
|
|
460 |
}
|
|
|
461 |
return '<div class="fstep"><div class="fn">'+title+'</div><div class="ssplit">'+body+'</div>'+
|
|
|
462 |
(last?'':'<span class="arrow">➤</span>')+'</div>';
|
|
|
463 |
}
|
|
|
464 |
function summaryCards(s){
|
|
|
465 |
if(!s) return '';
|
|
|
466 |
var mn=function(v){return num(v)+'<span class="u">min</span>';};
|
| 36941 |
vikas |
467 |
// planned + self-assigned merged into single totals
|
|
|
468 |
var totalVisits=(s.plannedVisits||0)+(s.selfVisits||0);
|
|
|
469 |
var completed=(s.completedPlanned||0)+(s.completedSelf||0);
|
|
|
470 |
var deferred=(s.deferredPlanned||0)+(s.deferredSelf||0);
|
|
|
471 |
return summaryCard('Total Visits',[['Visits',num(totalVisits),'']])+
|
|
|
472 |
summaryCard('Average Time Spent',[['Per visit',mn(s.avgTimeAllMin),'']])+
|
| 36929 |
vikas |
473 |
summaryCard('Idle / Break',[['Total Average',mn(s.idleAvgMin),'']])+
|
| 36941 |
vikas |
474 |
summaryCard('Completed Visits',[['Completed',num(completed),'good']])+
|
|
|
475 |
summaryCard('Deferred',[['Deferred',num(deferred),deferred>0?'bad':'']],true);
|
| 36929 |
vikas |
476 |
}
|
| 36905 |
vikas |
477 |
function fstep(name, val, w, sub, drop, last, key){
|
| 36916 |
vikas |
478 |
return '<div class="fstep'+(key?' clickable" data-fkey="'+key+'" onclick="funnelClick(\''+key+'\')" title="Click to filter the board to these executives':'')+'"><div class="fn">'+name+'</div><div class="fv">'+num(val)+'</div>'+
|
| 36913 |
vikas |
479 |
'<div class="fbar" style="width:'+Math.min(w,100)+'%"></div><div class="'+(drop?'drop':'keep')+'">'+sub+'</div>'+
|
| 36877 |
vikas |
480 |
(last?'':'<span class="arrow">➤</span>')+'</div>';
|
|
|
481 |
}
|
| 36913 |
vikas |
482 |
// Funnel drop sub-text: positive = fell out (red); negative = above planned
|
|
|
483 |
// (leads push visits past planned stops) shown green; zero = on plan.
|
|
|
484 |
function fdrop(p, badLabel){
|
|
|
485 |
if(p>0) return '−'+p+'% '+badLabel;
|
|
|
486 |
if(p<0) return '+'+(-p)+'% above planned';
|
|
|
487 |
return 'on plan';
|
|
|
488 |
}
|
| 36877 |
vikas |
489 |
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>'; }
|
|
|
490 |
|
|
|
491 |
function renderToday(d){
|
|
|
492 |
var k=d.kpis, f=d.funnel, s=d.stops, a=d.attention;
|
| 36897 |
vikas |
493 |
window._canApprove = !!d.canApprove;
|
| 37060 |
vikas |
494 |
window._canMarkLeave = !!d.canMarkLeave;
|
| 36897 |
vikas |
495 |
window._mediaUrl = d.mediaUrl || window._mediaUrl || '';
|
| 36905 |
vikas |
496 |
window._board = d.board || []; // funnel drill-down filters this
|
|
|
497 |
window._boardDate = d.date || '';
|
|
|
498 |
window._leadsList = d.leadsList || []; // leads drill-down
|
| 36917 |
vikas |
499 |
window._execList = d.execList || []; // main-card drill-downs
|
| 36877 |
vikas |
500 |
var h='';
|
|
|
501 |
h+='<div class="kpis">';
|
| 36917 |
vikas |
502 |
h+=kpi('👥','#eef1fb','#3b5bdb','Total Executives',num(k.totalExecs),'in your team',null,"openCards('total')");
|
|
|
503 |
h+=kpi('📅','#ebfbee','#2f9e44','Scheduled PJP',num(k.scheduledPjp)+' <small>/'+k.totalExecs+'</small>',k.unscheduled+' unscheduled',null,"openCards('scheduled')");
|
|
|
504 |
h+=kpi('✎','#fff4e6','#e8590c','Agenda Filled',num(k.agendaFilled)+' <small>/'+k.scheduledPjp+'</small>',(k.scheduledPjp-k.agendaFilled)+' pending',null,"openCards('agenda')");
|
|
|
505 |
h+=kpi('❌','#fff0f6','#d6336c','Unscheduled',num(k.unscheduled),'no PJP today',null,"openCards('unscheduled')");
|
| 36877 |
vikas |
506 |
h+='</div>';
|
|
|
507 |
|
| 36941 |
vikas |
508 |
h+='<div class="card"><h2>Needs attention now</h2><p class="desc">Things a manager should clear today.</p>'+attentionList(a)+'</div>';
|
|
|
509 |
|
| 36877 |
vikas |
510 |
h+='<div class="sectionlabel">Today\'s Journey Funnel</div><div class="card"><div class="funnel">';
|
| 36917 |
vikas |
511 |
var sp=f.scheduledPjp||1, pin=f.punchedIn||1;
|
|
|
512 |
h+=fstep('Punched In',f.punchedIn,pct(f.punchedIn,sp),f.punchedIn+' of '+f.scheduledPjp+' scheduled',false,false,'punchedIn');
|
|
|
513 |
h+=fstep('Traveling',f.travelling,pct(f.travelling,pin),'on the road',false,false,'travelling');
|
|
|
514 |
h+=fstep('In Store',f.inStore,pct(f.inStore,pin),'at a stop',false,false,'inStore');
|
| 37020 |
vikas |
515 |
h+=fstep('Idle / Break',f.idle,pct(f.idle,pin),'< 1 km in 15 min',true,false,'idle');
|
| 36917 |
vikas |
516 |
h+=fstep('Punched Out',f.punchedOut,pct(f.punchedOut,pin),'journey ended',false,true,'punchedOut');
|
| 36877 |
vikas |
517 |
h+='</div></div>';
|
|
|
518 |
|
| 36916 |
vikas |
519 |
window._boardFilter = null; // funnel-card filter (null = all rows)
|
|
|
520 |
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><div id="bj-board">'+boardCard()+'</div></div>';
|
| 36877 |
vikas |
521 |
el('bj-content').innerHTML=h;
|
|
|
522 |
}
|
|
|
523 |
function stateClass(st){ return st==='IN_STORE'?'ps-store':st==='TRAVELLING'?'ps-travel':st==='DONE'?'ps-done':st==='NOT_STARTED'?'ps-no':'ps-idle'; }
|
| 36897 |
vikas |
524 |
function js(s){ return String(s==null?'':s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
|
|
|
525 |
// Open the Beat Planner (map-first planner window) for this executive/beat/day,
|
|
|
526 |
// in a new tab. Edit pre-selects the beat; add opens it to assign/add a visit.
|
|
|
527 |
function openPlanner(authUserId, beatId, date, mode){
|
|
|
528 |
var u = CTX + '/beatPlanWindow?autoUserId=' + encodeURIComponent(authUserId) + '&editDate=' + encodeURIComponent(date);
|
|
|
529 |
if(beatId && beatId>0) u += '&editBeatId=' + encodeURIComponent(beatId);
|
|
|
530 |
if(mode==='add') u += '&assign=1';
|
|
|
531 |
window.open(u, '_blank');
|
|
|
532 |
}
|
| 36916 |
vikas |
533 |
// Funnel-card click: period cards (p_*) open their drill-down popup; today's
|
|
|
534 |
// funnel cards filter the Live executive board in place. Clicking the active
|
|
|
535 |
// card again clears the filter.
|
|
|
536 |
function funnelClick(key){
|
|
|
537 |
if(key && key.indexOf('p_')===0) return openFunnel(key);
|
|
|
538 |
setBoardFilter(window._boardFilter===key ? null : key);
|
|
|
539 |
}
|
|
|
540 |
// Apply / clear the funnel filter and re-render just the board card. Also marks
|
|
|
541 |
// the active funnel card so it's clear which cohort the board is showing.
|
|
|
542 |
function setBoardFilter(key){
|
|
|
543 |
window._boardFilter = key || null;
|
|
|
544 |
var c=el('bj-board'); if(c) c.innerHTML=boardCard();
|
|
|
545 |
document.querySelectorAll('.fstep[data-fkey]').forEach(function(s){
|
|
|
546 |
s.classList.toggle('active', !!key && s.getAttribute('data-fkey')===key);
|
|
|
547 |
});
|
|
|
548 |
}
|
|
|
549 |
// Board card content: an optional "filtered by …" bar plus the table, showing
|
|
|
550 |
// only the rows that reached the selected funnel stage (Scheduled = all rows).
|
|
|
551 |
function boardCard(){
|
|
|
552 |
var rows=window._board||[], date=window._boardDate||'', key=window._boardFilter;
|
|
|
553 |
var view=rows, m=key?FUNNEL_META[key]:null;
|
|
|
554 |
if(m) view=rows.filter(m.f);
|
|
|
555 |
var bar='';
|
|
|
556 |
if(m){
|
|
|
557 |
bar='<div class="boardfilter">Showing <b>'+esc(m.title)+'</b> · '+view.length+
|
|
|
558 |
' of '+rows.length+'<button class="clearf" onclick="setBoardFilter(null)">× Clear filter</button></div>';
|
|
|
559 |
}
|
|
|
560 |
return bar+boardTable(view, date);
|
|
|
561 |
}
|
| 36941 |
vikas |
562 |
// Merge planned partner visits and self-assigned leads into one set of tallies.
|
|
|
563 |
// Total reconciles with the parts: Total = Completed + Pending + Deferred, where
|
|
|
564 |
// Total is the plan (or activity total if it ran past the plan).
|
|
|
565 |
function boardCounts(r){
|
|
|
566 |
var completed=r.completedVisits||0, deferred=r.deferred||0;
|
|
|
567 |
var total=Math.max(r.planned||0, completed+deferred);
|
|
|
568 |
return {total:total, completed:completed, deferred:deferred, pending:total-completed-deferred};
|
|
|
569 |
}
|
| 37020 |
vikas |
570 |
// seconds -> compact "1h 20m" / "45m" for the board's time columns.
|
|
|
571 |
function bhm(sec){ sec=Math.max(0,Math.round(sec||0)); var h=Math.floor(sec/3600), m=Math.round((sec%3600)/60); return h?(h+'h '+m+'m'):(m+'m'); }
|
| 36877 |
vikas |
572 |
function boardTable(rows, date){
|
|
|
573 |
if(!rows||!rows.length) return '<p class="desc">No executives scheduled or active for this date.</p>';
|
| 36897 |
vikas |
574 |
var h='<div style="overflow-x:auto"><table><thead><tr>'+
|
| 36916 |
vikas |
575 |
'<th>Agenda</th><th>Executive</th><th>Punch In/Out</th>'+
|
| 36941 |
vikas |
576 |
'<th class="num" title="Planned partner visits + self-assigned leads">Total</th>'+
|
|
|
577 |
'<th class="num">Completed</th><th class="num">Pending</th><th class="num">Deferred</th>'+
|
| 37020 |
vikas |
578 |
'<th class="num" title="Transit time between stops so far today">Transit</th>'+
|
|
|
579 |
'<th class="num" title="Time spent inside stores so far today">In-store</th>'+
|
| 36941 |
vikas |
580 |
'<th>State</th><th>Action</th>'+
|
| 36897 |
vikas |
581 |
'</tr></thead><tbody>';
|
| 36917 |
vikas |
582 |
// column totals, computed up front so the Total row can lead the table
|
| 37020 |
vikas |
583 |
var t={total:0,completed:0,pending:0,deferred:0,transit:0,inStore:0};
|
| 36877 |
vikas |
584 |
rows.forEach(function(r){
|
| 36941 |
vikas |
585 |
var c=boardCounts(r);
|
|
|
586 |
t.total+=c.total; t.completed+=c.completed; t.pending+=c.pending; t.deferred+=c.deferred;
|
| 37020 |
vikas |
587 |
t.transit+=(r.transitSecs||0); t.inStore+=(r.inStoreSecs||0);
|
| 36917 |
vikas |
588 |
});
|
|
|
589 |
h+='<tr class="boardtot">'+
|
|
|
590 |
'<td></td><td><b>Total</b><br><span style="color:#97a1b5;font-size:11px">'+rows.length+' executives</span></td><td></td>'+
|
| 36941 |
vikas |
591 |
'<td class="num cnt cmuted">'+t.total+'</td>'+
|
|
|
592 |
'<td class="num cnt cgreen">'+t.completed+'</td>'+
|
|
|
593 |
'<td class="num cnt">'+t.pending+'</td>'+
|
| 37020 |
vikas |
594 |
'<td class="num cnt camber">'+t.deferred+'</td>'+
|
|
|
595 |
'<td class="num cnt cmuted">'+bhm(t.transit)+'</td>'+
|
|
|
596 |
'<td class="num cnt cmuted">'+bhm(t.inStore)+'</td><td></td><td></td>'+
|
| 36917 |
vikas |
597 |
'</tr>';
|
|
|
598 |
rows.forEach(function(r){
|
| 36897 |
vikas |
599 |
var rowcls = r.state==='NOT_STARTED' ? 'rowbad' : (r.idleAway?'rowwarn':'');
|
|
|
600 |
var A = r.userId+",'"+js(r.name)+"','"+date+"'"; // openExec args
|
|
|
601 |
var go = 'onclick="openExec('+A+')"';
|
| 36941 |
vikas |
602 |
var c=boardCounts(r);
|
| 36897 |
vikas |
603 |
h+='<tr class="clickable '+rowcls+'" '+go+'>'+
|
|
|
604 |
'<td style="text-align:center">'+(r.agendaFilled?'<span style="color:#2f9e44;font-size:15px">✓</span>':'<span class="badflag" style="font-size:15px">✗</span>')+'</td>'+
|
|
|
605 |
'<td><b>'+esc(r.name)+'</b><br>'+esc(r.beatName||'-')+'</td>'+
|
|
|
606 |
'<td style="white-space:nowrap">'+(r.punchInTime?'<span class="ptime">'+esc(r.punchInTime)+'</span>':'<span class="badflag">-</span>')+
|
|
|
607 |
' <span style="color:#97a1b5;font-size:11px">/ '+(r.punchOutTime?esc(r.punchOutTime):'--')+'</span></td>'+
|
| 36941 |
vikas |
608 |
'<td class="num cnt cmuted">'+c.total+'</td>'+
|
|
|
609 |
'<td class="num cnt cgreen">'+c.completed+'</td>'+
|
|
|
610 |
'<td class="num cnt">'+c.pending+'</td>'+
|
|
|
611 |
'<td class="num cnt camber">'+c.deferred+'</td>'+
|
| 37020 |
vikas |
612 |
'<td class="num cnt cmuted">'+(r.transitSecs?bhm(r.transitSecs):'<span style="color:#c5cce0">—</span>')+'</td>'+
|
|
|
613 |
'<td class="num cnt cmuted">'+(r.inStoreSecs?bhm(r.inStoreSecs):'<span style="color:#c5cce0">—</span>')+'</td>'+
|
| 36905 |
vikas |
614 |
'<td><span class="pill-state '+stateClass(r.state)+'">'+esc(r.stateLabel)+'</span></td>'+
|
| 36897 |
vikas |
615 |
'<td style="white-space:nowrap" onclick="event.stopPropagation()">'+
|
|
|
616 |
'<button class="actbtn map" title="Live location" onclick="openExec('+A+',\'map\')">📍</button>'+
|
|
|
617 |
'<button class="actbtn view" title="Journey detail" onclick="openExec('+A+')">👁</button>'+
|
| 36962 |
vikas |
618 |
(CAN_EDIT_BEAT
|
|
|
619 |
? '<button class="actbtn edit" title="Edit beat in Beat Planner" onclick="openPlanner('+r.authUserId+','+(r.beatId||0)+',\''+date+'\',\'edit\')">✎</button>'+
|
|
|
620 |
'<button class="actbtn add" title="Assign / add visit in Beat Planner" onclick="openPlanner('+r.authUserId+','+(r.beatId||0)+',\''+date+'\',\'add\')">+</button>'
|
|
|
621 |
: '')+
|
| 36897 |
vikas |
622 |
'</td></tr>';
|
| 36877 |
vikas |
623 |
});
|
| 36917 |
vikas |
624 |
return h+'</tbody></table></div>';
|
| 36877 |
vikas |
625 |
}
|
| 36905 |
vikas |
626 |
// Funnel drill-down: each stage shows the executives who reached it AND the
|
|
|
627 |
// drop-off cohort that fell out at this step (the "−N …" sub-line). Filters
|
|
|
628 |
// mirror the backend funnel conditions exactly (rows carry the per-stage flags).
|
|
|
629 |
var FUNNEL_META = {
|
| 36917 |
vikas |
630 |
punchedIn: {title:'Punched In', desc:'Started the day (punch-in recorded)', f:function(r){ return r.punchedIn; }},
|
| 37020 |
vikas |
631 |
travelling: {title:'Traveling', desc:'On the road between stops', f:function(r){ return r.state==='TRAVELLING'; }},
|
| 36917 |
vikas |
632 |
inStore: {title:'In Store', desc:'Currently checked in at a stop', f:function(r){ return r.state==='IN_STORE'; }},
|
| 37020 |
vikas |
633 |
idle: {title:'Idle / Break', desc:'Punched in but < 1 km covered in the last 15 min', f:function(r){ return r.state==='IDLE'; }},
|
| 36917 |
vikas |
634 |
punchedOut: {title:'Punched Out', desc:'Ended the day (punch-out recorded)', f:function(r){ return r.punchedOut; }}
|
| 36905 |
vikas |
635 |
};
|
|
|
636 |
function funnelRows(list, date){
|
|
|
637 |
if(!list.length) return '<p class="desc">None.</p>';
|
|
|
638 |
var h='<table><thead><tr><th>Executive / Beat</th><th>Punch In/Out</th><th class="num">Completed</th><th class="num">Planned</th><th>State</th></tr></thead><tbody>';
|
|
|
639 |
list.slice().sort(function(a,b){ return (''+a.name).localeCompare(b.name); }).forEach(function(r){
|
|
|
640 |
var A=r.userId+",'"+js(r.name)+"','"+date+"'";
|
|
|
641 |
h+='<tr class="clickable" onclick="openExec('+A+')">'+
|
|
|
642 |
'<td><b>'+esc(r.name)+'</b><br><span style="color:#97a1b5;font-size:11px">'+esc(r.beatName||'-')+'</span></td>'+
|
|
|
643 |
'<td style="white-space:nowrap">'+(r.punchInTime?'<span class="ptime">'+esc(r.punchInTime)+'</span>':'<span class="badflag">-</span>')+
|
|
|
644 |
' <span style="color:#97a1b5;font-size:11px">/ '+(r.punchOutTime?esc(r.punchOutTime):'--')+'</span></td>'+
|
|
|
645 |
'<td class="num cnt cgreen">'+r.completedVisits+'</td>'+
|
|
|
646 |
'<td class="num">'+r.planned+'</td>'+
|
|
|
647 |
'<td><span class="pill-state '+stateClass(r.state)+'">'+esc(r.stateLabel)+'</span></td></tr>';
|
|
|
648 |
});
|
|
|
649 |
return h+'</tbody></table>';
|
|
|
650 |
}
|
|
|
651 |
function openFunnel(key){
|
| 36913 |
vikas |
652 |
if(key && key.indexOf('p_')===0) return openPFunnel(key); // period funnel
|
| 36905 |
vikas |
653 |
var m=FUNNEL_META[key]; if(!m) return;
|
|
|
654 |
var board=window._board||[], date=window._boardDate||'';
|
|
|
655 |
var reached=board.filter(m.f);
|
|
|
656 |
var dropped=m.drop ? board.filter(m.drop) : [];
|
|
|
657 |
var mb=el('bj-modal-back'), md=el('bj-modal');
|
|
|
658 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+esc(m.title)+'</h3>'+
|
|
|
659 |
'<p class="desc">'+reached.length+' '+(reached.length===1?'executive':'executives')+' · '+esc(m.desc)+'</p>'+
|
|
|
660 |
funnelRows(reached, date);
|
|
|
661 |
if(m.drop){
|
|
|
662 |
h+='<div class="sectionlabel" style="margin-top:18px;color:#e8590c">'+esc(m.dropLabel)+' · '+dropped.length+'</div>'+
|
|
|
663 |
funnelRows(dropped, date);
|
|
|
664 |
}
|
|
|
665 |
h+='<p class="desc" style="margin-top:10px">Click a row to open that executive\'s journey detail.</p>';
|
|
|
666 |
md.innerHTML=h; mb.classList.add('show');
|
|
|
667 |
}
|
| 36917 |
vikas |
668 |
// Main-card drill-downs: roster of executives behind each top KPI card.
|
|
|
669 |
// 'drop' (optional) adds a second, highlighted section for the complementary
|
|
|
670 |
// cohort — e.g. who's scheduled but hasn't filled the agenda.
|
|
|
671 |
var CARD_META = {
|
|
|
672 |
total: {title:'Total Executives', desc:'Everyone in your team', f:function(r){ return true; }},
|
|
|
673 |
scheduled: {title:'Scheduled PJP', desc:'Executives with a beat today', f:function(r){ return r.hasBeat; }},
|
|
|
674 |
agenda: {title:'Agenda Filled', desc:'Beats with the agenda filled', f:function(r){ return r.agendaFilled; },
|
|
|
675 |
dropLabel:'Agenda not filled (scheduled)', drop:function(r){ return r.hasBeat && !r.agendaFilled; }},
|
| 37060 |
vikas |
676 |
unscheduled: {title:'Unscheduled', desc:'No PJP scheduled today', f:function(r){ return !r.hasBeat && !r.onLeave; }}
|
| 36917 |
vikas |
677 |
};
|
|
|
678 |
// Renders an executive roster table (used by the card drill-downs).
|
|
|
679 |
function execRows(list, date){
|
|
|
680 |
if(!list.length) return '<p class="desc">None.</p>';
|
|
|
681 |
var h='<table><thead><tr><th>Executive</th><th>Beat</th><th>Agenda</th><th>Status</th></tr></thead><tbody>';
|
|
|
682 |
list.forEach(function(r){
|
|
|
683 |
var A=r.userId+",'"+js(r.name)+"','"+date+"'";
|
|
|
684 |
var go = r.userId ? ' class="clickable" onclick="openExec('+A+')"' : '';
|
|
|
685 |
var agenda = !r.hasBeat ? '<span style="color:#97a1b5">—</span>'
|
|
|
686 |
: (r.agendaFilled ? '<span style="color:#2f9e44">✓</span>' : '<span class="badflag">✗</span>');
|
| 37060 |
vikas |
687 |
// Unscheduled execs can be marked on leave (manager / super-user only; server-enforced).
|
|
|
688 |
var leaveBtn = (window._canMarkLeave && r.authUserId && !r.hasBeat && !r.onLeave)
|
|
|
689 |
? ' <button class="leavebtn" onclick="event.stopPropagation();markLeave('+r.authUserId+",'"+js(r.name)+"',this)\">Mark leave</button>" : '';
|
| 36917 |
vikas |
690 |
h+='<tr'+go+'>'+
|
|
|
691 |
'<td><b>'+esc(r.name)+'</b></td>'+
|
|
|
692 |
'<td>'+esc(r.beatName||'-')+'</td>'+
|
|
|
693 |
'<td style="text-align:center">'+agenda+'</td>'+
|
| 37060 |
vikas |
694 |
'<td>'+esc(r.statusLabel||'')+leaveBtn+'</td></tr>';
|
| 36917 |
vikas |
695 |
});
|
|
|
696 |
return h+'</tbody></table>';
|
|
|
697 |
}
|
|
|
698 |
function openCards(key){
|
|
|
699 |
var m=CARD_META[key]; if(!m) return;
|
|
|
700 |
var all=(window._execList||[]), date=window._boardDate||'';
|
|
|
701 |
var list=all.filter(m.f);
|
|
|
702 |
var dropped=m.drop ? all.filter(m.drop) : [];
|
|
|
703 |
var mb=el('bj-modal-back'), md=el('bj-modal');
|
|
|
704 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+esc(m.title)+'</h3>'+
|
|
|
705 |
'<p class="desc">'+list.length+' '+(list.length===1?'executive':'executives')+' · '+esc(m.desc)+'</p>'+
|
|
|
706 |
execRows(list, date);
|
|
|
707 |
if(m.drop){
|
|
|
708 |
h+='<div class="sectionlabel" style="margin-top:18px;color:#e8590c">'+esc(m.dropLabel)+' · '+dropped.length+'</div>'+
|
|
|
709 |
execRows(dropped, date);
|
|
|
710 |
}
|
|
|
711 |
h+='<p class="desc" style="margin-top:10px">Click a row to open that executive\'s journey detail.</p>';
|
|
|
712 |
md.innerHTML=h; mb.classList.add('show');
|
|
|
713 |
}
|
| 36905 |
vikas |
714 |
// Leads drill-down: lists every lead visit logged today (matches the KPI count).
|
|
|
715 |
function openLeads(){
|
|
|
716 |
var list=window._leadsList||[], date=window._boardDate||'';
|
|
|
717 |
var mb=el('bj-modal-back'), md=el('bj-modal');
|
|
|
718 |
var h='<button class="x" onclick="closeModal()">×</button><h3>Leads on route</h3>'+
|
|
|
719 |
'<p class="desc">'+list.length+' lead '+(list.length===1?'visit':'visits')+' logged'+(date?' on '+esc(date):'')+'</p>';
|
|
|
720 |
if(!list.length){ h+='<p class="desc">No leads logged for this date.</p>'; }
|
|
|
721 |
else {
|
|
|
722 |
h+='<table><thead><tr><th>Executive</th><th>Lead</th><th>Check-in</th><th>Status</th></tr></thead><tbody>';
|
|
|
723 |
list.slice().sort(function(a,b){ return (''+a.exec).localeCompare(b.exec); }).forEach(function(r){
|
|
|
724 |
var A=r.userId+",'"+js(r.exec)+"','"+date+"'";
|
|
|
725 |
var go = r.userId ? ' class="clickable" onclick="openExec('+A+')"' : '';
|
|
|
726 |
h+='<tr'+go+'>'+
|
|
|
727 |
'<td><b>'+esc(r.exec)+'</b></td>'+
|
|
|
728 |
'<td>'+esc(r.lead||'-')+'</td>'+
|
|
|
729 |
'<td style="white-space:nowrap">'+(r.checkInTime?'<span class="ptime">'+esc(hhmm(r.checkInTime))+'</span>':'<span class="badflag">-</span>')+'</td>'+
|
|
|
730 |
'<td>'+esc(r.status||'')+'</td></tr>';
|
|
|
731 |
});
|
|
|
732 |
h+='</tbody></table><p class="desc" style="margin-top:10px">Click a row to open that executive\'s journey detail.</p>';
|
|
|
733 |
}
|
|
|
734 |
md.innerHTML=h; mb.classList.add('show');
|
|
|
735 |
}
|
| 36913 |
vikas |
736 |
/* ===================== PERIOD (ANALYTICS) DRILL-DOWNS ===================== */
|
|
|
737 |
function pModal(html){ el('bj-modal').innerHTML=html; el('bj-modal-back').classList.add('show'); }
|
|
|
738 |
function pRange(){ return (el('bj-start')?el('bj-start').value:'')+' to '+(el('bj-end')?el('bj-end').value:''); }
|
|
|
739 |
|
|
|
740 |
// Scorecard list popup (used by KPI cards + funnel stages). Rows drill to the
|
|
|
741 |
// executive's day-by-day. sortKey sorts descending.
|
|
|
742 |
function openPScore(sortKey, title, desc){
|
|
|
743 |
var rows=(((window._period||{}).scorecard)||[]).slice();
|
|
|
744 |
if(sortKey) rows.sort(function(a,b){ return (b[sortKey]||0)-(a[sortKey]||0); });
|
|
|
745 |
pModal('<button class="x" onclick="closeModal()">×</button><h3>'+esc(title)+'</h3>'+
|
|
|
746 |
'<p class="desc">'+esc(desc)+' · '+esc(pRange())+' · click a row for the day-by-day</p>'+pScoreTable(rows));
|
|
|
747 |
}
|
|
|
748 |
function pScoreTable(rows){
|
|
|
749 |
if(!rows.length) return '<p class="desc">No executives in scope.</p>';
|
| 37020 |
vikas |
750 |
var h='<table><thead><tr><th>Executive</th><th class="num">Plan</th><th class="num">Done</th><th class="num">Adher.</th>'+
|
|
|
751 |
'<th class="num" title="Avg lead visits per active (punched-in) day">Lead/day</th>'+
|
|
|
752 |
'<th class="num" title="Avg franchisee visits per active (punched-in) day">Fr/day</th>'+
|
| 37028 |
vikas |
753 |
'<th class="num">Disc.</th><th class="num">Hrs</th><th class="num">Leads</th></tr></thead><tbody>';
|
| 36913 |
vikas |
754 |
rows.forEach(function(r){
|
|
|
755 |
var c=adhColor(r.adherencePct);
|
|
|
756 |
h+='<tr class="clickable" onclick="openExecPeriod('+r.authUserId+',\''+js(r.name)+'\')">'+
|
| 37020 |
vikas |
757 |
'<td><span class="lvl">'+esc(r.level)+'</span> <b>'+esc(r.name)+'</b></td>'+
|
| 36913 |
vikas |
758 |
'<td class="num">'+r.planned+'</td><td class="num">'+r.done+'</td>'+
|
|
|
759 |
'<td class="num"><b style="color:'+c+'">'+r.adherencePct+'%</b></td>'+
|
| 37020 |
vikas |
760 |
'<td class="num">'+(r.avgLeadVisitsPerDay||'-')+'</td>'+
|
|
|
761 |
'<td class="num">'+(r.avgFranchiseeVisitsPerDay||'-')+'</td>'+
|
| 36913 |
vikas |
762 |
'<td class="num'+(r.discussionMin>0&&r.discussionMin<10?' warnflag':'')+'">'+(r.discussionMin||'-')+(r.discussionMin?'m':'')+'</td>'+
|
| 37028 |
vikas |
763 |
'<td class="num">'+r.workingHrs+'</td><td class="num">'+r.leads+'</td></tr>';
|
| 36913 |
vikas |
764 |
});
|
|
|
765 |
return h+'</tbody></table>';
|
|
|
766 |
}
|
|
|
767 |
// Funnel stage → the executives behind it (the scorecard, focused).
|
|
|
768 |
function openPFunnel(key){
|
|
|
769 |
var map={ p_planned:['planned','Planned stops','Planned partner + lead stops per executive'],
|
|
|
770 |
p_punchedin:['done','Planned on punch-in days','Executives, planned vs done'],
|
|
|
771 |
p_checkedin:['done','Checked in','Visits checked in per executive'],
|
|
|
772 |
p_checkedout:['done','Checked out (done)','Visits completed per executive'] };
|
|
|
773 |
var m=map[key]; if(m) openPScore(m[0],m[1],m[2]);
|
|
|
774 |
}
|
|
|
775 |
// Executive day-by-day (scorecard row drill). Fetches /exec-period; each day
|
|
|
776 |
// opens the journey detail / map modal.
|
|
|
777 |
function openExecPeriod(authUserId, name){
|
|
|
778 |
pModal('<button class="x" onclick="closeModal()">×</button><h3>'+esc(name)+'</h3>'+
|
|
|
779 |
'<p class="desc">Day-by-day · '+esc(pRange())+'</p><div class="loading"><div class="spinner"></div>Loading…</div>');
|
|
|
780 |
fetch(BASE+'/exec-period?authUserId='+authUserId+'&startDate='+encodeURIComponent(el('bj-start').value)+'&endDate='+encodeURIComponent(el('bj-end').value),{headers:{'Accept':'application/json'}})
|
|
|
781 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
782 |
.then(function(d){
|
|
|
783 |
var rows=d.rows||[], uid=d.userId, nm=d.name||name;
|
|
|
784 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+esc(nm)+'</h3>'+
|
|
|
785 |
'<p class="desc">'+rows.length+' day'+(rows.length===1?'':'s')+' · '+esc(pRange())+(uid?' · click a day for the journey detail':'')+'</p>';
|
|
|
786 |
if(!rows.length){ h+='<p class="desc">No scheduled or active days in this range.</p>'; }
|
|
|
787 |
else {
|
|
|
788 |
h+='<table><thead><tr><th>Date</th><th>Punch In/Out</th><th class="num">Planned</th><th class="num">Completed</th><th class="num">Deferred</th><th class="num">Total</th></tr></thead><tbody>';
|
|
|
789 |
rows.forEach(function(r){
|
|
|
790 |
var go = uid ? ' class="clickable" onclick="openExec('+uid+',\''+js(nm)+'\',\''+r.date+'\')"' : '';
|
|
|
791 |
h+='<tr'+go+'><td>'+esc(r.date)+'</td>'+
|
|
|
792 |
'<td style="white-space:nowrap">'+(r.punchIn?'<span class="ptime">'+esc(hhmm(r.punchIn))+'</span>':'<span class="badflag">-</span>')+
|
|
|
793 |
' <span style="color:#97a1b5;font-size:11px">/ '+(r.punchOut?esc(hhmm(r.punchOut)):'--')+'</span></td>'+
|
|
|
794 |
'<td class="num">'+r.planned+'</td><td class="num cnt cgreen">'+r.done+'</td>'+
|
|
|
795 |
'<td class="num cnt cred">'+r.deferred+'</td><td class="num cnt cmuted">'+r.total+'</td></tr>';
|
|
|
796 |
});
|
|
|
797 |
h+='</tbody></table>';
|
|
|
798 |
}
|
|
|
799 |
el('bj-modal').innerHTML=h;
|
|
|
800 |
})
|
|
|
801 |
.catch(function(){ el('bj-modal').innerHTML='<button class="x" onclick="closeModal()">×</button><p class="desc">Could not load day-by-day.</p>'; });
|
|
|
802 |
}
|
|
|
803 |
// PJP coverage drill — execs with / without a plan (optionally one level).
|
|
|
804 |
function openCoverage(level){
|
|
|
805 |
var all=((window._period||{}).coverageDetail)||[];
|
|
|
806 |
var list=level ? all.filter(function(r){ return r.level===level; }) : all;
|
|
|
807 |
var without=list.filter(function(r){ return !r.hasPlan; }), withP=list.filter(function(r){ return r.hasPlan; });
|
|
|
808 |
function tbl(rs){
|
|
|
809 |
if(!rs.length) return '<p class="desc">None.</p>';
|
|
|
810 |
var h='<table><tbody>';
|
|
|
811 |
rs.forEach(function(r){ h+='<tr class="clickable" onclick="openExecPeriod('+r.authUserId+',\''+js(r.name)+'\')"><td style="font-weight:600">'+esc(r.name)+'</td><td><span class="lvl">'+esc(r.level)+'</span></td></tr>'; });
|
|
|
812 |
return h+'</tbody></table>';
|
|
|
813 |
}
|
|
|
814 |
pModal('<button class="x" onclick="closeModal()">×</button><h3>PJP coverage'+(level?' · '+esc(level):'')+'</h3>'+
|
|
|
815 |
'<p class="desc">'+withP.length+' of '+list.length+' have a monthly plan · '+esc(pRange())+'</p>'+
|
|
|
816 |
'<div class="sectionlabel" style="color:#e03131">No plan · '+without.length+'</div>'+tbl(without)+
|
|
|
817 |
'<div class="sectionlabel" style="color:#2f9e44;margin-top:16px">Has a plan · '+withP.length+'</div>'+tbl(withP));
|
|
|
818 |
}
|
|
|
819 |
// Deferrals drill — all, or just system / genuine (from the donut segments).
|
|
|
820 |
function openDeferrals(filter){
|
|
|
821 |
var all=((window._period||{}).deferralsList)||[];
|
|
|
822 |
var list = filter==='system' ? all.filter(function(r){ return r.system; })
|
|
|
823 |
: filter==='genuine' ? all.filter(function(r){ return !r.system; }) : all;
|
|
|
824 |
var title = filter==='system' ? 'Deferrals — system-flagged' : filter==='genuine' ? 'Deferrals — genuine reason' : 'Deferrals';
|
|
|
825 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+esc(title)+'</h3>'+
|
|
|
826 |
'<p class="desc">'+list.length+' deferral'+(list.length===1?'':'s')+' · '+esc(pRange())+'</p>';
|
|
|
827 |
if(!list.length){ h+='<p class="desc">None in this range.</p>'; }
|
|
|
828 |
else {
|
|
|
829 |
h+='<table><thead><tr><th>Executive</th><th>Partner</th><th>Reason</th><th>Date</th><th>Status</th></tr></thead><tbody>';
|
|
|
830 |
list.forEach(function(r){
|
|
|
831 |
h+='<tr><td style="font-weight:600;white-space:nowrap">'+esc(r.exec)+'</td>'+
|
|
|
832 |
'<td>'+esc(r.partner||'-')+'</td>'+
|
|
|
833 |
'<td style="color:'+(r.system?'#f08c00':'#2f9e44')+'">'+esc(r.reason||'-')+'</td>'+
|
|
|
834 |
'<td style="white-space:nowrap">'+esc(r.date||'')+'</td>'+
|
|
|
835 |
'<td>'+esc(r.status||'')+'</td></tr>';
|
|
|
836 |
});
|
|
|
837 |
h+='</tbody></table>';
|
|
|
838 |
}
|
|
|
839 |
pModal(h);
|
|
|
840 |
}
|
| 36929 |
vikas |
841 |
// Auto "missed" drill — the three execution-gap causes behind the card, plus
|
|
|
842 |
// the underlying deferral records (system-flagged "beat missed" / "no punch-in").
|
|
|
843 |
function openMissed(){
|
|
|
844 |
var k=(window._period||{}).kpis||{};
|
|
|
845 |
var rows=[
|
|
|
846 |
['Unscheduled', k.autoMissedUnscheduled||0, 'Executives (sales roles) with no PJP scheduled in this range'],
|
|
|
847 |
['Unfilled agenda', k.autoMissedUnfilledAgenda||0, 'Scheduled beat-days where the agenda was never filled'],
|
|
|
848 |
['Missed punch', k.autoMissedMissedPunchin||0, 'Scheduled beat-days the owner never punched in on']
|
|
|
849 |
];
|
|
|
850 |
var h='<button class="x" onclick="closeModal()">×</button><h3>Auto “missed”</h3>'+
|
|
|
851 |
'<p class="desc">Why planned execution broke down · '+esc(pRange())+'</p>'+
|
|
|
852 |
'<table><thead><tr><th>Cause</th><th class="num">Count</th><th>What it means</th></tr></thead><tbody>';
|
|
|
853 |
rows.forEach(function(r){
|
|
|
854 |
h+='<tr><td style="font-weight:600;white-space:nowrap">'+esc(r[0])+'</td>'+
|
|
|
855 |
'<td class="num cnt cred">'+num(r[1])+'</td>'+
|
|
|
856 |
'<td style="color:#69748c">'+esc(r[2])+'</td></tr>';
|
|
|
857 |
});
|
|
|
858 |
h+='<tr class="boardtot"><td><b>Total</b></td><td class="num cnt"><b>'+num(k.autoMissed||0)+'</b></td><td></td></tr>';
|
|
|
859 |
h+='</tbody></table>'+
|
|
|
860 |
'<p class="desc" style="margin-top:12px">See also the <a href="javascript:void(0)" onclick="openDeferrals()" style="color:#3b5bdb">deferral records</a> for the partner-level “beat missed” / “no punch-in” entries.</p>';
|
|
|
861 |
pModal(h);
|
|
|
862 |
}
|
| 36913 |
vikas |
863 |
// Leads drill (period) — every lead created in the range.
|
|
|
864 |
function openPLeads(){
|
|
|
865 |
var list=((window._period||{}).leadsList)||[];
|
|
|
866 |
var h='<button class="x" onclick="closeModal()">×</button><h3>Leads on route</h3>'+
|
|
|
867 |
'<p class="desc">'+list.length+' lead'+(list.length===1?'':'s')+' created · '+esc(pRange())+'</p>';
|
|
|
868 |
if(!list.length){ h+='<p class="desc">No leads created in this range.</p>'; }
|
|
|
869 |
else {
|
|
|
870 |
h+='<table><thead><tr><th>Executive</th><th>Lead</th><th>City</th><th>Date</th><th>Status</th></tr></thead><tbody>';
|
|
|
871 |
list.forEach(function(r){
|
|
|
872 |
h+='<tr><td style="font-weight:600;white-space:nowrap">'+esc(r.exec)+'</td>'+
|
|
|
873 |
'<td>'+esc(r.lead||'-')+'</td><td>'+esc(r.city||'')+'</td>'+
|
|
|
874 |
'<td style="white-space:nowrap">'+esc(r.date||'')+'</td><td>'+esc(r.status||'')+'</td></tr>';
|
|
|
875 |
});
|
|
|
876 |
h+='</tbody></table>';
|
|
|
877 |
}
|
|
|
878 |
pModal(h);
|
|
|
879 |
}
|
| 36877 |
vikas |
880 |
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 |
881 |
var ATT_META = {
|
|
|
882 |
notPunchedIn: {bg:'#fff0f0', fg:'#e03131', ic:'⚠', title:'Not punched in', sub:'executives with a beat, not started', cls:'badflag'},
|
| 36941 |
vikas |
883 |
idleStale: {bg:'#fff4e6', fg:'#e8590c', ic:'⏱', title:'Idle — off-store > 59 min', sub:'stationary >20 m from any store', cls:'warnflag'},
|
| 36882 |
vikas |
884 |
geofence: {bg:'#fff0f0', fg:'#e03131', ic:'📍', title:'Check-in outside store geofence', sub:'>50 m from partner\'s saved location', cls:'badflag'},
|
|
|
885 |
locationOff: {bg:'#fff4e6', fg:'#e8590c', ic:'📴', title:'Location turned off mid-journey', sub:'GPS toggled off', cls:'warnflag'},
|
|
|
886 |
deferredAwaiting:{bg:'#eef1f6', fg:'#69748c', ic:'↻', title:'Deferred, awaiting re-action', sub:'shops deferred today', cls:''}
|
|
|
887 |
};
|
| 36877 |
vikas |
888 |
function attentionList(a){
|
| 36882 |
vikas |
889 |
window._att = a;
|
|
|
890 |
var order=['notPunchedIn','idleStale','geofence','locationOff','deferredAwaiting'];
|
| 36941 |
vikas |
891 |
var h='<div class="attrow">';
|
| 36882 |
vikas |
892 |
order.forEach(function(k){
|
|
|
893 |
var m=ATT_META[k], list=a[k]||[], n=list.length;
|
|
|
894 |
var detail = n ? names(list.map(function(x){return x.name;}),3) : m.sub;
|
|
|
895 |
var cls = n ? ' att clickable' : ' att';
|
|
|
896 |
var click = n ? ' onclick="openAttention(\''+k+'\')"' : '';
|
|
|
897 |
h+='<div class="'+cls.trim()+'"'+click+'><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.ic+'</div>'+
|
|
|
898 |
'<div class="ab"><div class="atitle">'+m.title+'</div><div class="adet">'+detail+'</div></div>'+
|
|
|
899 |
'<div class="acount '+(n?m.cls:'')+'">'+n+'</div>'+(n?'<span class="chev">▸</span>':'')+'</div>';
|
|
|
900 |
});
|
| 36941 |
vikas |
901 |
return h+'</div>';
|
| 36877 |
vikas |
902 |
}
|
| 36882 |
vikas |
903 |
function openAttention(key){
|
|
|
904 |
var a=window._att||{}, list=a[key]||[], m=ATT_META[key]||{title:'Detail'};
|
|
|
905 |
var mb=el('bj-modal-back'), md=el('bj-modal');
|
| 36897 |
vikas |
906 |
// Off-site check-ins can be approved here, but only by L5/Final (canApprove).
|
|
|
907 |
var showApprove = (key==='geofence' && window._canApprove);
|
| 36882 |
vikas |
908 |
var h='<button class="x" onclick="closeModal()">×</button><h3>'+m.title+'</h3>'+
|
| 36897 |
vikas |
909 |
'<p class="desc">'+list.length+' '+(list.length===1?'item':'items')+
|
|
|
910 |
(showApprove?' · approving updates the partner\'s saved coordinates':'')+'</p>';
|
| 36882 |
vikas |
911 |
if(!list.length){ h+='<p class="desc">Nothing here.</p>'; }
|
|
|
912 |
else{
|
| 36941 |
vikas |
913 |
var date=window._boardDate||'';
|
|
|
914 |
h+='<p class="desc">Click a row to open that executive\'s journey detail.</p><table><tbody>';
|
| 36882 |
vikas |
915 |
list.forEach(function(x){
|
| 36897 |
vikas |
916 |
var act='';
|
| 36941 |
vikas |
917 |
// approve button gets its own cell that swallows the click so it doesn't drill down
|
|
|
918 |
if(showApprove) act='<td class="num" style="white-space:nowrap" onclick="event.stopPropagation()">'+(x.trackingId?'<button class="btn-ok" onclick="openValidate('+x.trackingId+')">Validate & Approve</button>':'')+'</td>';
|
|
|
919 |
var canOpen = x.userId>0;
|
|
|
920 |
var go = canOpen ? ' class="clickable" onclick="openExec('+x.userId+",'"+js(x.name)+"','"+date+"')\"" : '';
|
|
|
921 |
h+='<tr'+go+'><td style="font-weight:600;white-space:nowrap">'+esc(x.name)+'</td>'+
|
| 36897 |
vikas |
922 |
'<td style="color:#69748c">'+esc(x.detail||'')+'</td>'+act+'</tr>';
|
| 36882 |
vikas |
923 |
});
|
|
|
924 |
h+='</tbody></table>';
|
|
|
925 |
}
|
|
|
926 |
md.innerHTML=h; mb.classList.add('show');
|
|
|
927 |
}
|
| 36897 |
vikas |
928 |
// Step 2: review the check-in photo/document before approving.
|
|
|
929 |
function openValidate(trackingId){
|
|
|
930 |
var list=(window._att&&window._att.geofence)||[], item=null;
|
|
|
931 |
for(var i=0;i<list.length;i++){ if(list[i].trackingId===trackingId){ item=list[i]; break; } }
|
|
|
932 |
if(!item){ return; }
|
|
|
933 |
var img = (item.attachment && window._mediaUrl)
|
| 36903 |
vikas |
934 |
? '<img class="valimg" src="'+esc(window._mediaUrl+item.attachment)+'" onclick="bjOpenImage(this.src)" onerror="this.outerHTML=\'<div class="valph">📷 Photo unavailable</div>\'">'
|
| 36897 |
vikas |
935 |
: '<div class="valph">📷 No photo on this check-in</div>';
|
|
|
936 |
el('bj-modal').innerHTML='<button class="x" onclick="closeModal()">×</button><h3>Validate check-in</h3>'+
|
|
|
937 |
'<p class="desc">'+esc(item.name)+' · '+esc(item.detail||'')+'</p>'+
|
|
|
938 |
'<div class="valwrap">'+img+'</div>'+
|
|
|
939 |
'<div class="valactions">'+
|
|
|
940 |
'<button class="exectab" onclick="openAttention(\'geofence\')">← Back</button>'+
|
|
|
941 |
'<button class="btn-ok" onclick="approveValidated('+trackingId+',this)">Approve</button>'+
|
|
|
942 |
'</div>';
|
|
|
943 |
}
|
|
|
944 |
function approveValidated(trackingId, btn){
|
|
|
945 |
btn.disabled=true; btn.textContent='Approving…';
|
|
|
946 |
fetch(CTX+'/beat-journey/approve-checkin',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'trackingId='+trackingId})
|
|
|
947 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
948 |
.then(function(){
|
|
|
949 |
if(window._att && window._att.geofence){ window._att.geofence = window._att.geofence.filter(function(x){ return x.trackingId!==trackingId; }); }
|
|
|
950 |
if(window._att && window._att.geofence && window._att.geofence.length) openAttention('geofence');
|
|
|
951 |
else closeModal();
|
|
|
952 |
})
|
|
|
953 |
.catch(function(){ btn.disabled=false; btn.textContent='Approve'; alert('Approve failed (not authorised, or server error).'); });
|
|
|
954 |
}
|
| 36877 |
vikas |
955 |
|
| 37060 |
vikas |
956 |
// Mark an unscheduled executive on leave for the board date. Manager / super-user only
|
|
|
957 |
// (server-enforced). Optimistically drops them from the Unscheduled list on success.
|
|
|
958 |
function markLeave(authUserId, name, btn){
|
|
|
959 |
if(!authUserId) return;
|
|
|
960 |
var reason = prompt('Mark '+name+' on leave for '+(window._boardDate||'today')+'?\nOptional reason:', '');
|
|
|
961 |
if(reason===null) return; // cancelled
|
|
|
962 |
btn.disabled=true; btn.textContent='Marking…';
|
|
|
963 |
var body='authUserId='+authUserId+'&date='+encodeURIComponent(window._boardDate||'')+'&reason='+encodeURIComponent(reason||'');
|
|
|
964 |
fetch(CTX+'/beat-journey/mark-leave',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body})
|
|
|
965 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
966 |
.then(function(){
|
|
|
967 |
(window._execList||[]).forEach(function(x){ if(x.authUserId===authUserId){ x.onLeave=true; x.statusLabel='On leave'; } });
|
|
|
968 |
openCards('unscheduled'); // re-render (row now filtered out of Unscheduled)
|
|
|
969 |
loadToday(); // refresh KPI tiles / counts in the background
|
|
|
970 |
})
|
|
|
971 |
.catch(function(){ btn.disabled=false; btn.textContent='Mark leave'; alert('Could not mark leave (not authorised, or server error).'); });
|
|
|
972 |
}
|
|
|
973 |
|
| 36877 |
vikas |
974 |
/* ============================ PERIOD ============================ */
|
|
|
975 |
function loadPeriod(){
|
|
|
976 |
spinner();
|
|
|
977 |
var u = BASE + '/data?startDate=' + encodeURIComponent(el('bj-start').value) + '&endDate=' + encodeURIComponent(el('bj-end').value) + qs();
|
|
|
978 |
fetch(u, {headers:{'Accept':'application/json'}})
|
|
|
979 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
980 |
.then(renderPeriod).catch(fail);
|
|
|
981 |
}
|
|
|
982 |
function tile(t,v){ return '<div class="tile"><div class="t">'+t+'</div><div class="v">'+v+'</div></div>'; }
|
|
|
983 |
function renderPeriod(d){
|
| 36913 |
vikas |
984 |
window._period = d; // drill-downs read scorecard / lists from here
|
| 36877 |
vikas |
985 |
var k=d.kpis, f=d.funnel, df=d.deferral, u=d.utilization, t=d.travel, o=d.outcomes;
|
|
|
986 |
var neverTouched = Math.max((f.plannedStops||0)-(f.checkedIn||0)-(df.total||0),0);
|
|
|
987 |
var h='';
|
|
|
988 |
h+='<div class="kpis">';
|
| 36929 |
vikas |
989 |
h+=kpi('⚖','#fff4e6','#f08c00','Adherence',k.adherencePct+'<small>%</small>',num(k.doneStops)+' of '+num(k.plannedStops)+' planned visits done',adhColor(k.adherencePct),"openPScore('adherencePct','Adherence','Planned visits done vs planned, per executive')");
|
| 36913 |
vikas |
990 |
h+=kpi('🗺','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad',"openCoverage('L1')");
|
| 36929 |
vikas |
991 |
h+=kpiCols('🚶','#ebfbee','#2f9e44','Average Visit (per day)',
|
| 36941 |
vikas |
992 |
[['Franchisee',k.avgPartnersPerActiveDay],['Lead',k.avgLeadsPerActiveDay]],
|
| 36929 |
vikas |
993 |
null,"openPScore('done','Visits / active day','Visits completed per executive')");
|
|
|
994 |
h+=kpiCols('💬','#eef1fb','#3b5bdb','Average discussion (per executive)',
|
|
|
995 |
[['Partner',k.avgDiscussionPartnerMin],['Lead',k.avgDiscussionLeadMin]],
|
|
|
996 |
k.avgDiscussionMin>0&&k.avgDiscussionMin<30?'#e8590c':null,"openPScore('discussionMin','Average discussion minutes','Avg in-store discussion per executive')",'min');
|
|
|
997 |
h+=kpiCols('↻','#fff0f0','#e03131','Auto "missed"',
|
|
|
998 |
[['Unscheduled',k.autoMissedUnscheduled],['Unfilled agenda',k.autoMissedUnfilledAgenda],['Missed punch',k.autoMissedMissedPunchin]],
|
|
|
999 |
'#e03131',"openMissed()");
|
| 36913 |
vikas |
1000 |
h+=kpi('★','#fff0f6','#d6336c','Leads on route',num(k.leadsCreated),'this period',null,"openPLeads()");
|
| 36877 |
vikas |
1001 |
h+='</div>';
|
|
|
1002 |
|
| 36941 |
vikas |
1003 |
h+='<div class="sectionlabel">Journey Summary</div><div class="card"><p class="desc">Planned (PJP) and self-assigned visits combined.</p><div class="funnel">'+summaryCards(d.summary)+'</div></div>';
|
| 36877 |
vikas |
1004 |
|
|
|
1005 |
h+='<div class="sectionlabel">Adherence & Coverage</div><div class="grid g3">';
|
|
|
1006 |
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>';
|
|
|
1007 |
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>';
|
|
|
1008 |
h+='</div>';
|
|
|
1009 |
|
|
|
1010 |
h+='<div class="sectionlabel">Deferrals, Travel & Outcomes</div><div class="grid g2">';
|
|
|
1011 |
h+='<div class="card"><h2>Deferral nature</h2><p class="desc">Deliberate, or just auto-flagged missed beats?</p>'+deferDonut(df)+'</div>';
|
|
|
1012 |
h+='<div class="card"><h2>Travel & route quality</h2><p class="desc">Straight-line distance per leg between consecutive stops.</p>'+travelBlock(t)+'</div>';
|
|
|
1013 |
h+='</div>';
|
|
|
1014 |
|
|
|
1015 |
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>'+
|
|
|
1016 |
'<div class="tiles" style="grid-template-columns:repeat(4,1fr)">'+
|
|
|
1017 |
tile('Partner remark filled', o.remarkFilledPct+'<small>%</small>')+
|
|
|
1018 |
tile('Avg partner rating', (o.avgRating||0)+'<small>/5</small>')+
|
|
|
1019 |
tile('Audit checklist done', o.auditDonePct+'<small>%</small>')+
|
|
|
1020 |
tile('Next-visit follow-up set', o.nextVisitPct+'<small>%</small>')+
|
|
|
1021 |
'</div></div>';
|
|
|
1022 |
|
|
|
1023 |
h+='<div class="sectionlabel">What to fix</div>'+findingsBlock(d.findings);
|
|
|
1024 |
h+=sourcesCard();
|
|
|
1025 |
el('bj-content').innerHTML=h;
|
|
|
1026 |
window._scorecard = d.scorecard;
|
|
|
1027 |
}
|
|
|
1028 |
var sortKey=null, sortAsc=false;
|
|
|
1029 |
function sortCard(key){
|
|
|
1030 |
if(sortKey===key) sortAsc=!sortAsc; else { sortKey=key; sortAsc=false; }
|
|
|
1031 |
window._scorecard.sort(function(a,b){ var x=a[key],y=b[key];
|
|
|
1032 |
if(typeof x==='string') return sortAsc? (''+x).localeCompare(y):(''+y).localeCompare(x);
|
|
|
1033 |
return sortAsc? x-y : y-x; });
|
|
|
1034 |
document.getElementById('scTbl').outerHTML = scorecardTable(window._scorecard);
|
|
|
1035 |
}
|
|
|
1036 |
function scorecardTable(rows){
|
|
|
1037 |
if(!rows||!rows.length) return '<p class="desc">No beat owners in scope for this period.</p>';
|
|
|
1038 |
var h='<table id="scTbl"><thead><tr>'+
|
|
|
1039 |
'<th onclick="sortCard(\'name\')">Executive</th>'+
|
|
|
1040 |
'<th class="num" onclick="sortCard(\'planned\')">Plan</th>'+
|
|
|
1041 |
'<th class="num" onclick="sortCard(\'done\')">Done</th>'+
|
|
|
1042 |
'<th class="num" onclick="sortCard(\'adherencePct\')">Adher.</th>'+
|
| 37028 |
vikas |
1043 |
'<th class="num" onclick="sortCard(\'avgLeadVisitsPerDay\')" title="Avg lead visits per active (punched-in) day">Lead/day</th>'+
|
|
|
1044 |
'<th class="num" onclick="sortCard(\'avgFranchiseeVisitsPerDay\')" title="Avg franchisee visits per active (punched-in) day">Fr/day</th>'+
|
| 36877 |
vikas |
1045 |
'<th class="num" onclick="sortCard(\'discussionMin\')">Disc.</th>'+
|
|
|
1046 |
'<th class="num" onclick="sortCard(\'workingHrs\')">Hrs</th>'+
|
| 37028 |
vikas |
1047 |
'<th class="num" onclick="sortCard(\'leads\')">Leads</th></tr></thead><tbody>';
|
| 36877 |
vikas |
1048 |
rows.forEach(function(r){
|
|
|
1049 |
var c=adhColor(r.adherencePct);
|
| 37028 |
vikas |
1050 |
h+='<tr class="clickable" onclick="openExecPeriod('+r.authUserId+',\''+js(r.name)+'\')"><td><span class="lvl">'+esc(r.level)+'</span>'+esc(r.name)+'</td>'+
|
| 36877 |
vikas |
1051 |
'<td class="num">'+r.planned+'</td><td class="num">'+r.done+'</td>'+
|
|
|
1052 |
'<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>'+
|
| 37028 |
vikas |
1053 |
'<td class="num">'+(r.avgLeadVisitsPerDay||'-')+'</td>'+
|
|
|
1054 |
'<td class="num">'+(r.avgFranchiseeVisitsPerDay||'-')+'</td>'+
|
| 36882 |
vikas |
1055 |
'<td class="num'+(r.discussionMin>0&&r.discussionMin<10?' warnflag':'')+'">'+(r.discussionMin||'-')+(r.discussionMin?'m':'')+'</td>'+
|
| 37028 |
vikas |
1056 |
'<td class="num">'+r.workingHrs+'</td><td class="num">'+r.leads+'</td></tr>';
|
| 36877 |
vikas |
1057 |
});
|
|
|
1058 |
return h+'</tbody></table>';
|
|
|
1059 |
}
|
|
|
1060 |
function coverageBars(rows){
|
|
|
1061 |
if(!rows||!rows.length) return '<p class="desc">No coverage data.</p>';
|
|
|
1062 |
var max=0; rows.forEach(function(r){ if(r.totalUsers>max)max=r.totalUsers; }); if(!max)max=1;
|
|
|
1063 |
var h=''; rows.forEach(function(r){
|
|
|
1064 |
var w=pct(r.withPjp,max), wt=pct(r.totalUsers,max);
|
| 36913 |
vikas |
1065 |
h+='<div class="barrow clickable" onclick="openCoverage(\''+js(r.level)+'\')" title="Click to see these executives"><div class="name">'+esc(r.level)+'</div><div class="track">'+
|
| 36877 |
vikas |
1066 |
'<div style="position:absolute;inset:0;width:'+wt+'%;background:#e3e8f5;border-radius:7px"></div>'+
|
|
|
1067 |
'<div class="fill" style="width:'+w+'%;position:relative;background:linear-gradient(90deg,#1098ad,#22b8cf)">'+(r.withPjp||'')+'</div></div>'+
|
|
|
1068 |
'<div class="meta">'+r.withPjp+'/'+r.totalUsers+' · '+r.coveragePct+'%</div></div>';
|
|
|
1069 |
});
|
|
|
1070 |
return h;
|
|
|
1071 |
}
|
|
|
1072 |
function utilBlock(u){
|
|
|
1073 |
return '<h2 style="margin-top:18px">Working-hour utilization</h2><p class="desc">Where the average working day goes.</p>'+
|
|
|
1074 |
'<div class="util">'+
|
|
|
1075 |
'<div style="width:'+u.inStorePct+'%;background:#2f9e44">In-store '+u.inStorePct+'%</div>'+
|
|
|
1076 |
'<div style="width:'+u.travelPct+'%;background:#3b5bdb">Travel '+u.travelPct+'%</div>'+
|
|
|
1077 |
'<div style="width:'+u.idlePct+'%;background:#e03131">Idle '+u.idlePct+'%</div></div>'+
|
|
|
1078 |
'<div class="utlegend"><span><span class="dot" style="background:#2f9e44;margin:0"></span>Selling '+u.inStoreHrs+' h</span>'+
|
|
|
1079 |
'<span><span class="dot" style="background:#3b5bdb;margin:0"></span>Travel '+u.travelHrs+' h</span>'+
|
|
|
1080 |
'<span><span class="dot" style="background:#e03131;margin:0"></span>Idle '+u.idleHrs+' h</span></div>';
|
|
|
1081 |
}
|
|
|
1082 |
function deferDonut(df){
|
|
|
1083 |
var sysPct=df.systemPct||0;
|
|
|
1084 |
var g='conic-gradient(#f59f00 0 '+sysPct+'%, #2f9e44 '+sysPct+'% 100%)';
|
| 36913 |
vikas |
1085 |
return '<div class="donutwrap"><div class="donut clickable" onclick="openDeferrals()" title="Click to see all deferrals" style="background:'+g+'"><div class="center">'+
|
| 36877 |
vikas |
1086 |
'<div style="font-size:24px;font-weight:780">'+num(df.total)+'</div><div style="font-size:11px;color:#97a1b5">deferrals</div></div></div>'+
|
|
|
1087 |
'<div class="legend">'+
|
| 36913 |
vikas |
1088 |
'<div class="it clickable" onclick="openDeferrals(\'system\')"><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>'+
|
|
|
1089 |
'<div class="it clickable" onclick="openDeferrals(\'genuine\')"><span class="dot" style="background:#2f9e44"></span><div><b>'+df.human+'</b> genuine reason ('+df.humanPct+'%)</div></div>'+
|
| 36877 |
vikas |
1090 |
'<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>'+
|
| 36913 |
vikas |
1091 |
'</div></div><p class="desc" style="margin-top:13px">Click the chart for all deferrals, or a legend row for system / genuine. "System" vs "genuine" is read from the deferral reason.</p>';
|
| 36877 |
vikas |
1092 |
}
|
|
|
1093 |
function travelBlock(t){
|
|
|
1094 |
var rows=t.legBuckets||[], max=0; rows.forEach(function(r){ if(r.count>max)max=r.count; }); if(!max)max=1;
|
|
|
1095 |
var colors={'0-2 km':'#2f9e44','2-5 km':'#2f9e44','5-15 km':'#82c91e','15-50 km':'#f59f00','50 km+':'#e03131'};
|
|
|
1096 |
var h=''; rows.forEach(function(r){
|
|
|
1097 |
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>';
|
|
|
1098 |
});
|
|
|
1099 |
h+='<div class="tiles" style="margin-top:14px">'+
|
|
|
1100 |
tile('Avg leg', t.avgLegKm+'<small> km</small>')+
|
|
|
1101 |
tile('Legs > 15 km', t.pctOver15+'<small>%</small>')+
|
|
|
1102 |
tile('Actual / estimate', (t.actualVsEstimateRatio||0)+'<small>×</small>')+'</div>';
|
|
|
1103 |
return h;
|
|
|
1104 |
}
|
|
|
1105 |
function findingsBlock(items){
|
|
|
1106 |
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>';
|
|
|
1107 |
var sev={high:'#e03131',med:'#f59f00',low:'#2f9e44'};
|
|
|
1108 |
var h='<div class="callout"><h2>Recommended management actions</h2><p class="desc">Generated from this period\'s data.</p>';
|
|
|
1109 |
items.forEach(function(x){
|
|
|
1110 |
h+='<div class="fix"><div class="sev" style="background:'+(sev[x.severity]||'#3b5bdb')+'"></div><div class="fb">'+
|
|
|
1111 |
'<div class="ft">'+esc(x.title)+'</div><div class="fd">'+esc(x.detail)+'</div>'+
|
|
|
1112 |
'<div class="act">→ '+esc(x.action)+'</div></div></div>';
|
|
|
1113 |
});
|
|
|
1114 |
return h+'</div>';
|
|
|
1115 |
}
|
|
|
1116 |
function sourcesCard(){
|
|
|
1117 |
return '<div class="sectionlabel">Where the numbers come from</div><div class="card">'+
|
|
|
1118 |
'<p class="desc">Nothing new needs to be captured — everything here is built from what the field app already records on each journey.</p>'+
|
|
|
1119 |
'<ul style="margin:0;padding-left:18px;line-height:1.95;font-size:13px;color:#3a4459">'+
|
|
|
1120 |
'<li>The <b>journey timeline</b> (punch-in, check-ins/outs, deferrals, punch-out) comes from what the executive taps in the app.</li>'+
|
|
|
1121 |
'<li><b>Adherence</b> compares the partners planned on each beat against the stores actually visited.</li>'+
|
|
|
1122 |
'<li><b>Time, travel and distance</b> use the in-store and travel durations logged per visit.</li>'+
|
|
|
1123 |
'<li><b>Geofence / fake-visit checks</b> compare the check-in GPS against the partner\'s saved shop location (flagged beyond ~50 m).</li>'+
|
|
|
1124 |
'<li><b>Visit outcomes</b> (remarks, rating, audit, next visit) come from the check-out form.</li>'+
|
|
|
1125 |
'<li><b>Coverage and the manager view</b> follow the existing L1–L5 sales hierarchy, so each manager sees only their team.</li>'+
|
|
|
1126 |
'</ul></div>';
|
|
|
1127 |
}
|
|
|
1128 |
|
|
|
1129 |
/* ============================ EXEC DRILLDOWN ============================ */
|
| 36897 |
vikas |
1130 |
function openExec(userId, name, date, tab){
|
| 36877 |
vikas |
1131 |
if(!userId){ return; }
|
| 36897 |
vikas |
1132 |
window._exec = {userId:userId, name:name, date:date, detail:null, tab:(tab||'timeline')};
|
| 36877 |
vikas |
1133 |
var mb=el('bj-modal-back'), m=el('bj-modal');
|
|
|
1134 |
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>';
|
|
|
1135 |
mb.classList.add('show');
|
| 36882 |
vikas |
1136 |
fetch(CTX+'/beat-journey/journey-detail?userId='+userId+'&date='+encodeURIComponent(date),{headers:{'Accept':'application/json'}})
|
| 36877 |
vikas |
1137 |
.then(function(r){ return r.json(); }).then(function(d){
|
| 36882 |
vikas |
1138 |
window._mediaUrl = d.mediaUrl || '';
|
| 36897 |
vikas |
1139 |
window._exec.detail = d;
|
|
|
1140 |
renderExec(window._exec.tab);
|
|
|
1141 |
}).catch(function(){ el('bj-modal').innerHTML='<button class="x" onclick="closeModal()">×</button><p class="desc">Could not load detail.</p>'; });
|
|
|
1142 |
}
|
|
|
1143 |
function renderExec(tab){
|
|
|
1144 |
var ex=window._exec; if(!ex) return;
|
|
|
1145 |
var d=ex.detail||{};
|
|
|
1146 |
var head='<button class="x" onclick="closeModal()">×</button><h3>'+esc(ex.name)+'</h3>'+
|
|
|
1147 |
'<p class="desc">Journey detail · '+esc(ex.date)+' · battery '+(d.batteryLevel==null?'-':d.batteryLevel+'%')+' · loc-off '+(d.locationToggleCount||0)+'</p>'+
|
|
|
1148 |
'<div class="exectabs">'+
|
|
|
1149 |
'<button class="exectab'+(tab==='timeline'?' active':'')+'" onclick="renderExec(\'timeline\')">📝 Timeline</button>'+
|
|
|
1150 |
'<button class="exectab'+(tab==='map'?' active':'')+'" onclick="renderExec(\'map\')">📍 Live Location</button>'+
|
|
|
1151 |
'</div>';
|
|
|
1152 |
if(tab==='map'){
|
| 36903 |
vikas |
1153 |
el('bj-modal').innerHTML=head+'<div id="bjRouteLegend" class="bjlegend"></div>'+
|
|
|
1154 |
'<div id="bjMap"><div class="loading"><div class="spinner"></div>Loading map…</div></div>';
|
| 36897 |
vikas |
1155 |
bjShowMap(ex.userId, ex.date);
|
|
|
1156 |
} else {
|
|
|
1157 |
el('bj-modal').innerHTML=head+buildTimelineHtml(d, ex.date);
|
|
|
1158 |
}
|
|
|
1159 |
}
|
|
|
1160 |
function buildTimelineHtml(d, date){
|
|
|
1161 |
var all=(d.visits||[]).slice();
|
|
|
1162 |
// Sequence like the field report: Punch in -> visited stops (by check-in time)
|
|
|
1163 |
// -> PENDING/not-visited -> Punch out. The attendance row carries both punch-in
|
|
|
1164 |
// & punch-out times; split it into the two bookends.
|
|
|
1165 |
var att=null, visited=[], pending=[];
|
|
|
1166 |
all.forEach(function(x){
|
|
|
1167 |
var tt=(x.taskType||'').toLowerCase(), mt=(x.markType||'').toUpperCase();
|
|
|
1168 |
if(tt==='attendance' || mt.indexOf('PUNCH')>=0){ if(!att) att=x; }
|
|
|
1169 |
else if(x.checkInTime){ visited.push(x); }
|
|
|
1170 |
else { pending.push(x); }
|
|
|
1171 |
});
|
|
|
1172 |
visited.sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); });
|
| 36882 |
vikas |
1173 |
|
| 36903 |
vikas |
1174 |
var distKm=0, inStoreSec=0, leadsN=0;
|
| 36897 |
vikas |
1175 |
all.forEach(function(x){
|
|
|
1176 |
var tt=(x.taskType||'').toLowerCase();
|
|
|
1177 |
if(tt==='attendance') return;
|
|
|
1178 |
if(x.totalDistance) distKm += (parseFloat(x.totalDistance)||0);
|
|
|
1179 |
inStoreSec += toSec(x.timeSpent);
|
|
|
1180 |
if(tt==='lead') leadsN++;
|
|
|
1181 |
});
|
|
|
1182 |
var workSec = (att && att.checkInTime && att.checkOutTime) ? (toSec(att.checkOutTime)-toSec(att.checkInTime)) : (att?toSec(att.timeSpent):0);
|
|
|
1183 |
if(workSec<0) workSec=0;
|
| 36903 |
vikas |
1184 |
// Travel = working − in-store, so In-store + Travel = Working hours exactly
|
|
|
1185 |
// (summing per-leg transit under-counts the idle/waiting between stops).
|
|
|
1186 |
var transitSec = Math.max(0, workSec - inStoreSec);
|
| 36882 |
vikas |
1187 |
|
| 36897 |
vikas |
1188 |
var seq=[];
|
|
|
1189 |
if(att && att.checkInTime) seq.push(punchEntry('in', att.checkInTime));
|
|
|
1190 |
visited.forEach(function(x){ seq.push(x); });
|
|
|
1191 |
pending.forEach(function(x){ seq.push(x); });
|
|
|
1192 |
if(att && att.checkOutTime) seq.push(punchEntry('out', att.checkOutTime));
|
| 36882 |
vikas |
1193 |
|
| 36897 |
vikas |
1194 |
var h='<div class="jstats">'+
|
|
|
1195 |
jstat(fmtSec(workSec),'Working hours')+
|
|
|
1196 |
jstat(fmtSec(inStoreSec),'In-store time')+
|
|
|
1197 |
jstat(fmtSec(transitSec),'Travel time')+
|
|
|
1198 |
jstat(distKm.toFixed(2)+' km','Distance Covered')+
|
| 36905 |
vikas |
1199 |
jstat(visited.length+(leadsN?' / '+leadsN+'L':''),'Visits / Leads')+
|
| 36897 |
vikas |
1200 |
'</div>';
|
|
|
1201 |
if(!seq.length){ return h+'<p class="desc">No tracking events for this day.</p>'; }
|
|
|
1202 |
h+='<div class="tl">';
|
|
|
1203 |
var ri=0;
|
|
|
1204 |
seq.forEach(function(x){
|
|
|
1205 |
if(!x._punch && x.checkInTime){
|
|
|
1206 |
var tr=(x.transitTime && x.transitTime!=='00:00:00') ? x.transitTime : null;
|
|
|
1207 |
var km=(x.totalDistance && parseFloat(x.totalDistance)>0) ? (parseFloat(x.totalDistance).toFixed(2)+' km') : null;
|
| 36955 |
vikas |
1208 |
var eta=(x.estimatedTime && x.estimatedTime!=='00:00:00') ? x.estimatedTime : null;
|
|
|
1209 |
if(tr || km || eta){
|
|
|
1210 |
var parts=[]; if(tr) parts.push('🚗 '+esc(tr)); if(km) parts.push(esc(km)); if(eta) parts.push('ETA '+esc(eta));
|
|
|
1211 |
// flag the leg red when actual transit exceeds 1.5x the ETA (same rule as the export)
|
|
|
1212 |
var overrun = eta && toSec(tr) > 1.5*toSec(eta);
|
|
|
1213 |
h+='<div class="tlgap"><span class="gappill'+(overrun?' overrun':'')+'"'+(overrun?' title="Travel overran 1.5x the ETA"':'')+'>'+parts.join(' · ')+'</span></div>';
|
| 36877 |
vikas |
1214 |
}
|
| 36897 |
vikas |
1215 |
}
|
|
|
1216 |
h+=tlRow(x, ri%2===0?'left':'right'); ri++;
|
|
|
1217 |
});
|
|
|
1218 |
h+='</div>';
|
|
|
1219 |
return h;
|
| 36877 |
vikas |
1220 |
}
|
| 36897 |
vikas |
1221 |
|
|
|
1222 |
/* ---- live location map (reuses /beat-report/live-tracking pathPoints) ---- */
|
|
|
1223 |
function bjLoadMaps(cb){
|
|
|
1224 |
if(window.google && window.google.maps){ cb(); return; }
|
|
|
1225 |
window._mapsCbs = window._mapsCbs || [];
|
|
|
1226 |
window._mapsCbs.push(cb);
|
|
|
1227 |
if(window._mapsLoading) return;
|
|
|
1228 |
window._mapsLoading=true;
|
|
|
1229 |
window.__bjMapsReady=function(){ (window._mapsCbs||[]).forEach(function(f){ try{f();}catch(e){} }); window._mapsCbs=[]; };
|
|
|
1230 |
var s=document.createElement('script');
|
|
|
1231 |
s.src='https://maps.googleapis.com/maps/api/js?key=AIzaSyAckO0y4Z6WhBOuMjNjioWLSYZDhGEvGBc&libraries=geometry&v=weekly&callback=__bjMapsReady';
|
|
|
1232 |
s.async=true; document.head.appendChild(s);
|
|
|
1233 |
}
|
|
|
1234 |
function bjShowMap(userId, date){
|
|
|
1235 |
bjLoadMaps(function(){
|
|
|
1236 |
fetch(CTX+'/beat-journey/track-map?userId='+userId+'&date='+encodeURIComponent(date),{headers:{'Accept':'application/json'}})
|
|
|
1237 |
.then(function(r){ return r.json(); }).then(function(data){ bjDrawMap(data); })
|
|
|
1238 |
.catch(function(){ var e=document.getElementById('bjMap'); if(e) e.innerHTML='<div class="loading">Could not load location.</div>'; });
|
|
|
1239 |
});
|
|
|
1240 |
}
|
|
|
1241 |
function bjMarkColor(mt){
|
|
|
1242 |
mt=(mt||'').toUpperCase();
|
|
|
1243 |
if(mt.indexOf('CHECKOUT')>=0) return '#2f9e44';
|
|
|
1244 |
if(mt==='CHECKIN') return '#f59f00';
|
|
|
1245 |
if(mt==='DEFERRED') return '#e8590c';
|
|
|
1246 |
if(mt==='PENDING') return '#97a1b5';
|
|
|
1247 |
return '#3b5bdb';
|
|
|
1248 |
}
|
|
|
1249 |
function bjMarkInfo(v,color){
|
|
|
1250 |
return '<div style="font-size:12px;max-width:240px"><b>'+esc(v.name||'')+'</b><br>'+
|
|
|
1251 |
'<span style="background:'+color+';color:#fff;border-radius:4px;padding:1px 6px;font-size:10px">'+esc(v.markType||'')+'</span>'+
|
|
|
1252 |
(v.checkInTime?'<br>In: <b>'+esc(v.checkInTime)+'</b>':'')+
|
|
|
1253 |
(v.checkOutTime?'<br>Out: <b>'+esc(v.checkOutTime)+'</b>':'')+
|
|
|
1254 |
(v.timeSpent?'<br>Time spent: <b>'+esc(v.timeSpent)+'</b>':'')+
|
|
|
1255 |
(v.address?'<br>'+esc(v.address):'')+'</div>';
|
|
|
1256 |
}
|
|
|
1257 |
// Teardrop pin (same shape as Beat Report) with a white disc + label.
|
|
|
1258 |
function bjPin(color, label){
|
|
|
1259 |
var svg='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="40" viewBox="0 0 28 40">'+
|
|
|
1260 |
'<path d="M14 0C6.268 0 0 6.268 0 14c0 10.5 14 26 14 26s14-15.5 14-26C28 6.268 21.732 0 14 0z" fill="'+color+'"/>'+
|
|
|
1261 |
'<circle cx="14" cy="14" r="7" fill="#ffffff"/>'+
|
|
|
1262 |
'<text x="14" y="18" text-anchor="middle" font-size="10" font-weight="bold" fill="'+color+'">'+label+'</text></svg>';
|
|
|
1263 |
return {url:'data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(svg), scaledSize:new google.maps.Size(28,40), anchor:new google.maps.Point(14,40)};
|
|
|
1264 |
}
|
|
|
1265 |
// Larger pin sized to fit a duration label (idle stops).
|
|
|
1266 |
function bjStopPin(label){
|
|
|
1267 |
var s=String(label), d=Math.max(40, 14+s.length*7), H=d+10, r=d/2;
|
|
|
1268 |
var svg='<svg xmlns="http://www.w3.org/2000/svg" width="'+d+'" height="'+H+'" viewBox="0 0 '+d+' '+H+'">'+
|
|
|
1269 |
'<polygon points="'+(r-7)+','+(d-7)+' '+(r+7)+','+(d-7)+' '+r+','+H+'" fill="#e8590c"/>'+
|
|
|
1270 |
'<circle cx="'+r+'" cy="'+r+'" r="'+(r-2)+'" fill="#e8590c" stroke="#ffffff" stroke-width="2"/>'+
|
|
|
1271 |
'<text x="'+r+'" y="'+(r+4)+'" text-anchor="middle" font-size="12" font-weight="bold" fill="#ffffff">'+s+'</text></svg>';
|
|
|
1272 |
return {url:'data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(svg), scaledSize:new google.maps.Size(d,H), anchor:new google.maps.Point(r,H)};
|
|
|
1273 |
}
|
|
|
1274 |
function fmtDur2(sec){ sec=sec||0; var m=Math.floor(sec/60), s=sec%60; return m+'m'+(s>0?' '+s+'s':''); }
|
|
|
1275 |
function bjDrawMap(data){
|
|
|
1276 |
var box=document.getElementById('bjMap'); if(!box) return;
|
|
|
1277 |
box.innerHTML='';
|
|
|
1278 |
var map=new google.maps.Map(box,{zoom:12,center:{lat:20.5937,lng:78.9629},mapTypeControl:true,streetViewControl:false});
|
|
|
1279 |
var bounds=new google.maps.LatLngBounds(), has=false;
|
|
|
1280 |
function pin(pos,icon,zi,title,info){
|
|
|
1281 |
has=true; bounds.extend(pos);
|
|
|
1282 |
var m=new google.maps.Marker({position:pos,map:map,zIndex:zi||100,icon:icon,title:title||''});
|
|
|
1283 |
if(info){ var iw=new google.maps.InfoWindow({content:info}); m.addListener('click',function(){ iw.open(map,m); }); }
|
|
|
1284 |
return m;
|
|
|
1285 |
}
|
|
|
1286 |
|
| 36903 |
vikas |
1287 |
// 1) last-known location marker (the GPS trail itself is drawn as the "taken"
|
|
|
1288 |
// route in bjDrawRoutes, straight from location_tracking_path).
|
| 36897 |
vikas |
1289 |
var pts=(data.path||[]).map(function(p){ return {lat:p.lat,lng:p.lng}; });
|
|
|
1290 |
if(pts.length){
|
|
|
1291 |
pts.forEach(function(p){ bounds.extend(p); has=true; });
|
|
|
1292 |
var lastPt=pts[pts.length-1];
|
|
|
1293 |
var lk=new google.maps.Marker({position:lastPt,map:map,zIndex:999,title:'Last Known Location',
|
|
|
1294 |
icon:{url:CTX+'/resources/images/salesman.png',scaledSize:new google.maps.Size(40,40),anchor:new google.maps.Point(20,20)}});
|
|
|
1295 |
var lkIw=new google.maps.InfoWindow({content:'<div style="font-size:12px"><b>📍 Last known location</b></div>'});
|
|
|
1296 |
lk.addListener('click',function(){ lkIw.open(map,lk); });
|
|
|
1297 |
}
|
|
|
1298 |
|
|
|
1299 |
// 2) idle stops (>=10 min within 50 m)
|
|
|
1300 |
(data.idleStops||[]).forEach(function(s){
|
|
|
1301 |
var lbl=fmtDur2(s.seconds);
|
|
|
1302 |
pin({lat:s.lat,lng:s.lng}, bjStopPin(lbl), 80, 'Idle '+lbl,
|
|
|
1303 |
'<div style="font-size:12px"><b>⏱ Idle stop</b><br>Stayed ~'+esc(lbl)+' within 50 m</div>');
|
|
|
1304 |
});
|
|
|
1305 |
|
| 36954 |
vikas |
1306 |
// 3) every visit — visited stops are numbered in array order; pending / deferred
|
|
|
1307 |
// stops (plotted at their planned store location) get a neutral dot and sit
|
|
|
1308 |
// below the visited pins.
|
| 36897 |
vikas |
1309 |
var idx=0;
|
|
|
1310 |
(data.visits||[]).forEach(function(v){
|
|
|
1311 |
var color=bjMarkColor(v.markType);
|
| 36954 |
vikas |
1312 |
var visited = !!v.checkInTime;
|
|
|
1313 |
pin({lat:v.lat,lng:v.lng}, bjPin(color, visited?String(++idx):'•'), visited?100:60,
|
| 36897 |
vikas |
1314 |
(v.name||'')+' ('+v.markType+')', bjMarkInfo(v,color));
|
|
|
1315 |
});
|
|
|
1316 |
|
|
|
1317 |
// 4) punch in / punch out
|
|
|
1318 |
if(data.punchIn){
|
|
|
1319 |
pin({lat:data.punchIn.lat,lng:data.punchIn.lng}, bjPin('#2f9e44','P'), 300,
|
|
|
1320 |
'Punch in'+(data.punchIn.time?' · '+data.punchIn.time:''),
|
|
|
1321 |
'<div style="font-size:12px"><b>⚑ Punch in</b>'+(data.punchIn.time?'<br>'+esc(data.punchIn.time):'')+'</div>');
|
|
|
1322 |
}
|
|
|
1323 |
if(data.punchOut){
|
|
|
1324 |
pin({lat:data.punchOut.lat,lng:data.punchOut.lng}, bjPin('#7048e8','X'), 300,
|
|
|
1325 |
'Punch out'+(data.punchOut.time?' · '+data.punchOut.time:''),
|
|
|
1326 |
'<div style="font-size:12px"><b>⚑ Punch out</b>'+(data.punchOut.time?'<br>'+esc(data.punchOut.time):'')+'</div>');
|
|
|
1327 |
}
|
|
|
1328 |
|
| 36903 |
vikas |
1329 |
// 5) routes — actual road taken vs Google-suggested optimal, for comparison.
|
| 37028 |
vikas |
1330 |
// Suggested-route checkpoints = the whole day's stops (planned + visited), not
|
|
|
1331 |
// just the ones already visited, so the suggestion shows from punch-in onward
|
|
|
1332 |
// (and even before the first check-in). optimizeWaypoints reorders them, so the
|
|
|
1333 |
// input order only needs to keep punch-in first / punch-out last; visited stops
|
|
|
1334 |
// are listed by check-in time and still-pending stops after them.
|
| 36903 |
vikas |
1335 |
var cps=[];
|
|
|
1336 |
if(data.punchIn) cps.push({lat:data.punchIn.lat,lng:data.punchIn.lng});
|
|
|
1337 |
(data.visits||[]).slice()
|
| 37028 |
vikas |
1338 |
.filter(function(v){ return v.lat && v.lng; }) // every stop with coords
|
| 36903 |
vikas |
1339 |
.sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); })
|
|
|
1340 |
.forEach(function(v){ cps.push({lat:v.lat,lng:v.lng}); });
|
|
|
1341 |
if(data.punchOut) cps.push({lat:data.punchOut.lat,lng:data.punchOut.lng});
|
| 36929 |
vikas |
1342 |
// Actual time window of the GPS trail (first → last breadcrumb timestamp),
|
|
|
1343 |
// shown on the taken-route click detail alongside the driving estimate.
|
|
|
1344 |
var takenTime='';
|
|
|
1345 |
var dp=data.path||[];
|
|
|
1346 |
if(dp.length>=2 && dp[0].ts && dp[dp.length-1].ts && dp[dp.length-1].ts>dp[0].ts){
|
|
|
1347 |
var t0=dp[0].ts, t1=dp[dp.length-1].ts;
|
|
|
1348 |
takenTime=bjClock(t0)+' → '+bjClock(t1)+' ('+bjElapsed(t1-t0)+')';
|
|
|
1349 |
}
|
|
|
1350 |
bjDrawRoutes(map, cps, pts, takenTime, dp);
|
| 36903 |
vikas |
1351 |
|
| 36897 |
vikas |
1352 |
if(has){
|
|
|
1353 |
map.fitBounds(bounds);
|
|
|
1354 |
google.maps.event.addListenerOnce(map,'idle',function(){ if(map.getZoom()>16) map.setZoom(16); });
|
|
|
1355 |
} else {
|
|
|
1356 |
box.innerHTML='<div class="loading">No GPS / location recorded for this day.</div>';
|
|
|
1357 |
}
|
|
|
1358 |
}
|
| 36903 |
vikas |
1359 |
// TAKEN route = the recorded GPS trail (location_tracking_path) routed along the
|
|
|
1360 |
// road network so it follows actual roads rather than straight hops between sparse
|
|
|
1361 |
// pings. SUGGESTED route = the Google-optimal road route through the journey's
|
|
|
1362 |
// checkpoints (green dotted). Both are drawn and compared by distance.
|
| 36929 |
vikas |
1363 |
function bjDrawRoutes(map, cps, pathPts, takenTime, timedPts){
|
| 36903 |
vikas |
1364 |
var legend=bjRouteLegend();
|
|
|
1365 |
|
| 37032 |
vikas |
1366 |
// De-jitter the breadcrumb first: stationary GPS drift (e.g. under a roof)
|
|
|
1367 |
// scatters points around a fixed spot, which makes the road-snapped route weave
|
|
|
1368 |
// back and forth and inflates the taken distance. Collapsing sub-JITTER_MIN_M
|
|
|
1369 |
// hops removes those clusters before we draw or measure the trail.
|
|
|
1370 |
var trail=bjCleanTrail(pathPts, 30);
|
|
|
1371 |
|
| 36903 |
vikas |
1372 |
// taken — road route traced through the recorded breadcrumb (in order)
|
| 37032 |
vikas |
1373 |
if(trail && trail.length>=2){
|
| 36903 |
vikas |
1374 |
var ds1=new google.maps.DirectionsService();
|
| 37032 |
vikas |
1375 |
var sample=bjSample(trail, 25); // Directions cap: 25 incl. ends
|
| 36903 |
vikas |
1376 |
var o=sample[0], d=sample[sample.length-1];
|
|
|
1377 |
var vias=sample.slice(1,-1).map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:false}; });
|
|
|
1378 |
bjRouteRequest(ds1, o, d, vias, false, function(res){
|
| 36929 |
vikas |
1379 |
bjRenderRoute(map, res, {color:'#e03131', dashed:false, z:55, label:'Route taken (actual GPS)', time:takenTime, timed:timedPts});
|
| 36903 |
vikas |
1380 |
legend.set('taken', bjRouteKm(res));
|
|
|
1381 |
}, function(status){
|
|
|
1382 |
console.warn('Directions (taken) failed:', status);
|
| 37032 |
vikas |
1383 |
// fallback: plot the cleaned breadcrumb (straight hops), with arrows, and measure along it
|
|
|
1384 |
var poly=new google.maps.Polyline({path:trail,geodesic:false,strokeColor:'#e03131',strokeOpacity:.9,strokeWeight:4,zIndex:55,map:map,
|
| 36929 |
vikas |
1385 |
icons:[{icon:{path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW,strokeColor:'#e03131',fillColor:'#e03131',fillOpacity:1,scale:3},offset:'3%',repeat:'120px'}]});
|
| 37032 |
vikas |
1386 |
var km=google.maps.geometry.spherical.computeLength(trail.map(function(p){ return new google.maps.LatLng(p.lat,p.lng); }))/1000;
|
| 36929 |
vikas |
1387 |
bjRouteClick(map, poly, '#e03131', 'Route taken (actual GPS)', km, '', takenTime, timedPts);
|
|
|
1388 |
legend.set('taken', km);
|
| 36903 |
vikas |
1389 |
});
|
|
|
1390 |
} else {
|
|
|
1391 |
legend.set('taken', false);
|
|
|
1392 |
}
|
|
|
1393 |
|
|
|
1394 |
// suggested — Google-optimal route through punch-in → visits → punch-out
|
|
|
1395 |
if(cps.length>=2){
|
|
|
1396 |
var ds=new google.maps.DirectionsService();
|
|
|
1397 |
var origin=cps[0], dest=cps[cps.length-1], mids=cps.slice(1,-1), capped=false;
|
|
|
1398 |
if(mids.length>23){ mids=mids.slice(0,23); capped=true; } // Directions allows 23 waypoints
|
|
|
1399 |
var wpts=mids.map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:true}; });
|
|
|
1400 |
bjRouteRequest(ds, origin, dest, wpts, true, function(res){
|
| 36929 |
vikas |
1401 |
bjRenderRoute(map, res, {color:'#2f9e44', dashed:true, z:50, label:'Suggested route (optimal)'});
|
| 36903 |
vikas |
1402 |
legend.set('suggested', bjRouteKm(res));
|
|
|
1403 |
}, function(status){ console.warn('Directions (suggested) failed:', status); legend.set('suggested', false); });
|
|
|
1404 |
if(capped) legend.note('Suggested route limited to first 23 stops');
|
|
|
1405 |
} else {
|
|
|
1406 |
legend.set('suggested', false);
|
|
|
1407 |
}
|
|
|
1408 |
}
|
| 37032 |
vikas |
1409 |
// Collapse stationary GPS jitter: keep the first point, then only points that
|
|
|
1410 |
// moved at least `minM` metres from the last kept one; the true final point is
|
|
|
1411 |
// always kept so the trail ends at the last known location. This removes the
|
|
|
1412 |
// dense drift clusters (weak signal indoors) that otherwise draw back-and-forth
|
|
|
1413 |
// loops and inflate the taken distance, while preserving real movement.
|
|
|
1414 |
function bjCleanTrail(pts, minM){
|
|
|
1415 |
if(!pts || pts.length<2) return pts ? pts.slice() : [];
|
|
|
1416 |
var out=[pts[0]], last=pts[0];
|
|
|
1417 |
for(var i=1;i<pts.length-1;i++){
|
|
|
1418 |
var p=pts[i];
|
|
|
1419 |
var d=google.maps.geometry.spherical.computeDistanceBetween(
|
|
|
1420 |
new google.maps.LatLng(last.lat,last.lng), new google.maps.LatLng(p.lat,p.lng));
|
|
|
1421 |
if(d>=minM){ out.push(p); last=p; }
|
|
|
1422 |
}
|
|
|
1423 |
out.push(pts[pts.length-1]); // keep the final fix regardless
|
|
|
1424 |
return out;
|
|
|
1425 |
}
|
| 36903 |
vikas |
1426 |
// Evenly downsample a list to at most `max` items, always keeping first and last.
|
|
|
1427 |
function bjSample(arr, max){
|
|
|
1428 |
if(arr.length<=max) return arr.slice();
|
|
|
1429 |
var out=[], step=(arr.length-1)/(max-1);
|
|
|
1430 |
for(var i=0;i<max;i++) out.push(arr[Math.round(i*step)]);
|
|
|
1431 |
return out;
|
|
|
1432 |
}
|
|
|
1433 |
function bjRouteRequest(ds, origin, dest, wpts, optimize, ok, err){
|
|
|
1434 |
ds.route({
|
|
|
1435 |
origin:new google.maps.LatLng(origin.lat,origin.lng),
|
|
|
1436 |
destination:new google.maps.LatLng(dest.lat,dest.lng),
|
|
|
1437 |
waypoints:wpts, optimizeWaypoints:optimize,
|
|
|
1438 |
travelMode:google.maps.TravelMode.DRIVING
|
|
|
1439 |
}, function(res, status){
|
|
|
1440 |
if(status===google.maps.DirectionsStatus.OK && res && res.routes && res.routes.length) ok(res);
|
|
|
1441 |
else err(status);
|
|
|
1442 |
});
|
|
|
1443 |
}
|
| 36929 |
vikas |
1444 |
// Draw a route as a custom polyline (so we can add direction arrows + a click
|
|
|
1445 |
// info window). Forward arrows are repeated along the line; dashed routes also
|
|
|
1446 |
// get the dash pattern. Clicking the line opens its distance / duration detail.
|
| 36903 |
vikas |
1447 |
function bjRenderRoute(map, res, opt){
|
| 36929 |
vikas |
1448 |
var path=res.routes[0].overview_path;
|
|
|
1449 |
var arrow={icon:{path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW, strokeColor:opt.color,
|
|
|
1450 |
strokeOpacity:1, strokeWeight:1, fillColor:opt.color, fillOpacity:1, scale:opt.dashed?2.6:3.2},
|
|
|
1451 |
offset:'3%', repeat:opt.dashed?'95px':'120px'};
|
|
|
1452 |
var icons=[arrow];
|
| 36903 |
vikas |
1453 |
if(opt.dashed){
|
| 36929 |
vikas |
1454 |
icons.unshift({icon:{path:'M 0,-1 0,1', strokeColor:opt.color, strokeOpacity:1, strokeWeight:4, scale:3}, offset:'0', repeat:'14px'});
|
| 36903 |
vikas |
1455 |
}
|
| 36929 |
vikas |
1456 |
var poly=new google.maps.Polyline({path:path, map:map, zIndex:opt.z,
|
|
|
1457 |
strokeColor:opt.color, strokeOpacity:opt.dashed?0:0.85, strokeWeight:opt.dashed?0:5, icons:icons});
|
|
|
1458 |
bjRouteClick(map, poly, opt.color, opt.label||(opt.dashed?'Suggested route':'Route taken'),
|
|
|
1459 |
bjRouteKm(res), bjRouteDur(res), opt.time, opt.timed);
|
| 36903 |
vikas |
1460 |
}
|
| 36929 |
vikas |
1461 |
// Attach a click handler that opens an info window with the route's details at
|
|
|
1462 |
// the clicked point. Reused by both the routed line and the breadcrumb fallback.
|
|
|
1463 |
// `time` (optional) is the actual GPS time window, e.g. "09:28 → 16:03 (6h 35m)".
|
|
|
1464 |
// `timed` (optional) are the raw breadcrumbs with timestamps; when present, the
|
|
|
1465 |
// captured time at the nearest breadcrumb to the click is shown.
|
|
|
1466 |
function bjRouteClick(map, poly, color, label, km, dur, time, timed){
|
|
|
1467 |
var iw=new google.maps.InfoWindow();
|
|
|
1468 |
poly.addListener('click', function(e){
|
|
|
1469 |
var here='';
|
|
|
1470 |
if(timed && timed.length){
|
|
|
1471 |
var np=bjNearestPt(timed, e.latLng);
|
|
|
1472 |
if(np && np.ts) here='📍 At this point: <b>'+bjClockS(np.ts)+'</b><br>';
|
|
|
1473 |
}
|
|
|
1474 |
iw.setContent('<div style="font-size:12px;line-height:1.5"><b style="color:'+color+'">➤ '+esc(label)+'</b><br>'+
|
|
|
1475 |
here+(time?'Time: <b>'+time+'</b><br>':'')+
|
|
|
1476 |
'Distance: <b>'+km.toFixed(1)+' km</b>'+(dur?'<br>Est. drive: <b>'+esc(dur)+'</b>':'')+'</div>');
|
|
|
1477 |
iw.setPosition(e.latLng); iw.open(map);
|
|
|
1478 |
});
|
|
|
1479 |
}
|
|
|
1480 |
// Nearest timestamped breadcrumb to a clicked LatLng (great-circle distance).
|
|
|
1481 |
function bjNearestPt(timed, latLng){
|
|
|
1482 |
var best=null, bd=Infinity;
|
|
|
1483 |
for(var i=0;i<timed.length;i++){
|
|
|
1484 |
var d=google.maps.geometry.spherical.computeDistanceBetween(latLng, new google.maps.LatLng(timed[i].lat, timed[i].lng));
|
|
|
1485 |
if(d<bd){ bd=d; best=timed[i]; }
|
|
|
1486 |
}
|
|
|
1487 |
return best;
|
|
|
1488 |
}
|
|
|
1489 |
// Format an epoch-millis timestamp as a local HH:MM clock, and a millisecond
|
|
|
1490 |
// span as a compact "Nh Nm" / "Nm" elapsed string (for the GPS time window).
|
|
|
1491 |
function bjClock(ts){
|
|
|
1492 |
var d=new Date(ts), h=d.getHours(), m=d.getMinutes();
|
|
|
1493 |
return (h<10?'0':'')+h+':'+(m<10?'0':'')+m;
|
|
|
1494 |
}
|
|
|
1495 |
function bjClockS(ts){
|
|
|
1496 |
var d=new Date(ts), h=d.getHours(), m=d.getMinutes(), s=d.getSeconds();
|
|
|
1497 |
return (h<10?'0':'')+h+':'+(m<10?'0':'')+m+':'+(s<10?'0':'')+s;
|
|
|
1498 |
}
|
|
|
1499 |
function bjElapsed(ms){
|
|
|
1500 |
var s=Math.round(ms/1000), h=Math.floor(s/3600), m=Math.round((s%3600)/60);
|
|
|
1501 |
return h ? (h+'h '+m+'m') : (m+'m');
|
|
|
1502 |
}
|
| 36903 |
vikas |
1503 |
function bjRouteKm(res){
|
|
|
1504 |
var m=0; (res.routes[0].legs||[]).forEach(function(l){ m+=(l.distance?l.distance.value:0); }); return m/1000;
|
|
|
1505 |
}
|
| 36929 |
vikas |
1506 |
function bjRouteDur(res){
|
|
|
1507 |
var s=0; (res.routes[0].legs||[]).forEach(function(l){ s+=(l.duration?l.duration.value:0); });
|
|
|
1508 |
if(!s) return '';
|
|
|
1509 |
var h=Math.floor(s/3600), m=Math.round((s%3600)/60);
|
|
|
1510 |
return h ? (h+'h '+m+'m') : (m+'m');
|
|
|
1511 |
}
|
| 36903 |
vikas |
1512 |
// Route-comparison panel rendered into the modal (#bjRouteLegend), above the map.
|
|
|
1513 |
// Each distance fills in as its route resolves. value: number = km, false =
|
|
|
1514 |
// unavailable, undefined = still loading.
|
|
|
1515 |
function bjRouteLegend(){
|
|
|
1516 |
var div=el('bjRouteLegend');
|
|
|
1517 |
if(!div) return { set:function(){}, note:function(){} };
|
|
|
1518 |
var taken, sugg, noteTxt='';
|
|
|
1519 |
function val(v){ return typeof v==='number' ? '<b>'+v.toFixed(1)+' km</b>' : (v===false ? 'n/a' : '…'); }
|
|
|
1520 |
function render(){
|
|
|
1521 |
var cmp='';
|
|
|
1522 |
if(typeof taken==='number' && typeof sugg==='number'){
|
|
|
1523 |
cmp = taken>sugg+0.05
|
|
|
1524 |
? '<span style="color:#e03131">+'+(taken-sugg).toFixed(1)+' km longer than suggested</span>'
|
|
|
1525 |
: '<span style="color:#2f9e44">on / better than the suggested route</span>';
|
|
|
1526 |
}
|
|
|
1527 |
div.innerHTML=
|
|
|
1528 |
'<span class="lg">Route comparison</span>'+
|
|
|
1529 |
'<span><span class="sw ln"></span>Taken (GPS)</span>'+
|
|
|
1530 |
'<span><span class="sw dt"></span>Suggested</span>'+
|
|
|
1531 |
cmp+
|
|
|
1532 |
(noteTxt?'<span style="color:#e8590c">'+noteTxt+'</span>':'');
|
|
|
1533 |
}
|
|
|
1534 |
render();
|
|
|
1535 |
return { set:function(k,v){ if(k==='taken')taken=v; else sugg=v; render(); }, note:function(t){ noteTxt=t; render(); } };
|
|
|
1536 |
}
|
| 36882 |
vikas |
1537 |
function tlSecs(t){ if(!t) return 1e9; var p=t.split(':'); return (+p[0])*3600+(+(p[1]||0))*60+(+(p[2]||0)); }
|
|
|
1538 |
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)); }
|
|
|
1539 |
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); }
|
|
|
1540 |
function jstat(v,l){ return '<div class="js"><div class="jv">'+v+'</div><div class="jl">'+l+'</div></div>'; }
|
|
|
1541 |
function hhmm(t){ return t? t.slice(0,5):''; }
|
| 37039 |
vikas |
1542 |
// Best-effort: pull a "when will they order" hint out of a free-text remark.
|
|
|
1543 |
// English + Hinglish. Display-only; returns '' when nothing recognizable is found.
|
|
|
1544 |
function bjScheduledFromRemark(remark){
|
|
|
1545 |
if(!remark) return '';
|
|
|
1546 |
var t = String(remark).toLowerCase();
|
|
|
1547 |
// day-after (before tomorrow/kal, since "parso" is distinct)
|
|
|
1548 |
if(/\b(day[\s-]?after(\s+tomorrow)?|parso)\b/.test(t)) return 'day after';
|
|
|
1549 |
if(/\b(tomorrow|kal|tmrw|tmr)\b/.test(t)) return 'tomorrow';
|
|
|
1550 |
if(/\b(today|aaj|abhi)\b/.test(t)) return 'today';
|
|
|
1551 |
// range "N-M days" / "N-M din" (check before single N)
|
|
|
1552 |
var range = t.match(/\b(\d{1,2})\s*[-–to]+\s*(\d{1,2})\s*(days?|din)\b/);
|
|
|
1553 |
if(range) return 'in '+range[1]+'–'+range[2]+' days';
|
|
|
1554 |
var nd = t.match(/\b(?:in\s+)?(\d{1,2})\s*(days?|din)\b/);
|
|
|
1555 |
if(nd) return 'in '+nd[1]+' day'+(nd[1]==='1'?'':'s');
|
|
|
1556 |
if(/\b(this\s*week|is\s*week|is\s*haft[ae])\b/.test(t)) return 'this week';
|
|
|
1557 |
if(/\b(next\s*week|agle\s*haft[ae]|agla\s*week)\b/.test(t)) return 'next week';
|
|
|
1558 |
// explicit "DD Mon" / "DD Month"
|
|
|
1559 |
var mon = t.match(/\b(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/);
|
|
|
1560 |
if(mon){
|
|
|
1561 |
var M={jan:'Jan',feb:'Feb',mar:'Mar',apr:'Apr',may:'May',jun:'Jun',jul:'Jul',aug:'Aug',sep:'Sep',oct:'Oct',nov:'Nov',dec:'Dec'};
|
|
|
1562 |
return parseInt(mon[1],10)+' '+M[mon[2]];
|
|
|
1563 |
}
|
|
|
1564 |
// "DD/MM" or "DD-MM" (reject year-like / out-of-range month)
|
|
|
1565 |
var dm = t.match(/\b(\d{1,2})[\/\-](\d{1,2})\b(?!\d)/);
|
|
|
1566 |
if(dm && parseInt(dm[2],10)>=1 && parseInt(dm[2],10)<=12){
|
|
|
1567 |
var mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
1568 |
return parseInt(dm[1],10)+' '+mo[parseInt(dm[2],10)-1];
|
|
|
1569 |
}
|
|
|
1570 |
// weekday names (English + common Hinglish)
|
|
|
1571 |
var wk = t.match(/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|somwar|mangalwar|budhwar|guruwar|shukrawar|shaniwar|raviwar|itwar)\b/);
|
|
|
1572 |
if(wk){
|
|
|
1573 |
var W={monday:'Mon',somwar:'Mon',tuesday:'Tue',mangalwar:'Tue',wednesday:'Wed',budhwar:'Wed',
|
|
|
1574 |
thursday:'Thu',guruwar:'Thu',friday:'Fri',shukrawar:'Fri',saturday:'Sat',shaniwar:'Sat',
|
|
|
1575 |
sunday:'Sun',raviwar:'Sun',itwar:'Sun'};
|
|
|
1576 |
return W[wk[1]];
|
|
|
1577 |
}
|
|
|
1578 |
return '';
|
|
|
1579 |
}
|
| 36882 |
vikas |
1580 |
function tlThumb(att){
|
| 36903 |
vikas |
1581 |
if(att && window._mediaUrl) return '<img class="tlthumb" src="'+esc(window._mediaUrl+att)+'" onclick="bjOpenImage(this.src)" onerror="tlImgErr(this)">';
|
| 37044 |
vikas |
1582 |
return ''; // no image -> render nothing (no placeholder box)
|
| 36882 |
vikas |
1583 |
}
|
|
|
1584 |
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); }
|
|
|
1585 |
function punchEntry(kind, time){ return {_punch:kind, checkInTime:time, markType:(kind==='in'?'PUNCHIN':'PUNCHOUT'), taskType:'attendance'}; }
|
|
|
1586 |
function tlStyle(x){
|
|
|
1587 |
var mt=(x.markType||'').toUpperCase(), tt=(x.taskType||'').toLowerCase();
|
|
|
1588 |
if(x._punch || tt==='attendance' || mt.indexOf('PUNCH')>=0) return {c:'#7048e8', l:(x._punch==='out'?'Punch out':'Punch in'), ic:'⚑'};
|
|
|
1589 |
if(mt.indexOf('CHECKOUT')>=0) return {c:'#2f9e44', l:'Visited', ic:'✓'};
|
| 37034 |
vikas |
1590 |
if(mt==='CHECKIN') return {c:'#1098ad', l:'In', ic:'📍'};
|
| 37044 |
vikas |
1591 |
if(mt==='DEFERRED') return {c:'#e8590c', l:'Deferred', ic:'⚠'};
|
| 36882 |
vikas |
1592 |
if(mt==='PENDING' || !x.checkInTime) return {c:'#97a1b5', l:'Pending', ic:'○'};
|
|
|
1593 |
return {c:'#3b5bdb', l:(mt||'-'), ic:'●'};
|
|
|
1594 |
}
|
|
|
1595 |
function tlTitle(x){
|
|
|
1596 |
if(x._punch) return x._punch==='in' ? 'Journey start (Punch in)' : 'Journey end (Punch out)';
|
|
|
1597 |
var n=x.taskName||'';
|
| 36903 |
vikas |
1598 |
var taskType=(x.taskType||'').toLowerCase();
|
|
|
1599 |
if(taskType==='franchisee-visit') {
|
|
|
1600 |
var pipe = n.lastIndexOf('|');
|
|
|
1601 |
if (pipe >= 0) n = n.slice(pipe + 1); // drop leading agenda tags
|
|
|
1602 |
n = n.replace(/\s*VISIT\b.*$/i, '').replace(/\s*visit\s*$/i, '').replace(/\bnull\b/gi, '').replace(/\s{2,}/g, ' ').trim();
|
|
|
1603 |
}
|
| 36882 |
vikas |
1604 |
return n || '(stop)';
|
|
|
1605 |
}
|
| 37044 |
vikas |
1606 |
// Agenda chips: task_name is "agenda1, agenda2 | Outlet" — show the prefix (partner visits only).
|
|
|
1607 |
function tlAgendas(x){
|
|
|
1608 |
if(x._punch || (x.taskType||'').toLowerCase()!=='franchisee-visit') return '';
|
|
|
1609 |
var n=x.taskName||'', pipe=n.lastIndexOf(' | ');
|
|
|
1610 |
var part = pipe>0 ? n.slice(0,pipe) : '';
|
|
|
1611 |
if(!part) return '';
|
|
|
1612 |
var out='';
|
|
|
1613 |
part.split(',').forEach(function(a){ var t=a.trim(); if(t) out += '<span class="tlag">'+esc(t)+'</span>'; });
|
|
|
1614 |
return out ? '<div class="tlags">'+out+'</div>' : '';
|
|
|
1615 |
}
|
| 36882 |
vikas |
1616 |
function tlRow(x, side){
|
|
|
1617 |
var s=tlStyle(x);
|
|
|
1618 |
var tt=(x.taskType||'').toLowerCase();
|
|
|
1619 |
var timeTop = x.checkInTime ? hhmm(x.checkInTime) : '-';
|
|
|
1620 |
var tlabel = x._punch ? (x._punch==='in'?'punch in':'punch out') : (x.checkInTime?'check-in':'pending');
|
|
|
1621 |
var meta;
|
|
|
1622 |
if(x._punch){
|
|
|
1623 |
meta = (x._punch==='in'?'Punch in':'Punch out') + ' · ' + hhmm(x.checkInTime);
|
|
|
1624 |
} else {
|
|
|
1625 |
var io = (x.checkInTime?hhmm(x.checkInTime):'-') + ' → ' + (x.checkOutTime?hhmm(x.checkOutTime):'-');
|
|
|
1626 |
var dur = (x.timeSpent && x.timeSpent!=='00:00:00') ? ' · '+esc(x.timeSpent) : '';
|
|
|
1627 |
var typeLabel = tt==='lead' ? 'Lead' : (tt==='office-visit'?'Office':'Partner');
|
|
|
1628 |
meta = x.checkInTime ? (typeLabel+' · '+io+dur) : (typeLabel+' · not visited');
|
|
|
1629 |
}
|
|
|
1630 |
// image (or placeholder) + battery + location-toggle on real stops
|
|
|
1631 |
var extra='';
|
|
|
1632 |
if(!x._punch){
|
|
|
1633 |
var chips='';
|
|
|
1634 |
if(x.battery!=null) chips += '<span class="tlchip">🔋 '+x.battery+'%</span>';
|
|
|
1635 |
if(x.locOff!=null) chips += '<span class="tlchip">📴 '+x.locOff+'</span>';
|
| 37044 |
vikas |
1636 |
// orders placed that day (any agenda): one badge -> drawer lists them all.
|
|
|
1637 |
// No order + billing agenda -> a parsed "scheduled" chip instead.
|
|
|
1638 |
if(x.orders && x.orders.length){
|
|
|
1639 |
var n=x.orders.length;
|
|
|
1640 |
chips += '<button type="button" class="tlbadge ord" title="View orders placed this day"'
|
|
|
1641 |
+ ' onclick="bjOpenOrderList('+x.taskId+')">🧾 '+n+' order'+(n>1?'s':'')+'</button>';
|
|
|
1642 |
} else if(x.billingAgenda){
|
|
|
1643 |
var sch = bjScheduledFromRemark(x.remarks);
|
|
|
1644 |
if(sch) chips += '<span class="tlbadge sch" title="Scheduled (from remark)">📅 '+esc(sch)+'</span>';
|
| 37039 |
vikas |
1645 |
}
|
| 37044 |
vikas |
1646 |
var thumb=tlThumb(x.attachment);
|
|
|
1647 |
if(thumb || chips) extra = '<div class="tlextra">'+thumb+chips+'</div>';
|
| 36882 |
vikas |
1648 |
}
|
| 36903 |
vikas |
1649 |
var card='<div class="tlcard">'+
|
| 36882 |
vikas |
1650 |
'<div class="tm" style="background:'+s.c+'">'+timeTop+'<small>'+tlabel+'</small></div>'+
|
|
|
1651 |
'<div class="bd"><div class="ti">'+esc(tlTitle(x))+'</div>'+
|
|
|
1652 |
'<span class="stt" style="background:'+s.c+'">'+s.l+'</span>'+
|
| 36941 |
vikas |
1653 |
(tt==='lead' ? '<span class="stt unplanned">Unplanned</span>' : '')+
|
| 37044 |
vikas |
1654 |
tlAgendas(x)+
|
| 36882 |
vikas |
1655 |
'<div class="mt">'+meta+'</div>'+extra+'</div>'+
|
| 36903 |
vikas |
1656 |
'</div>';
|
|
|
1657 |
// remark sits on the opposite half of the timeline (empty spacer when none)
|
| 37038 |
vikas |
1658 |
var rk='';
|
|
|
1659 |
if(!x._punch && x.remarks){
|
|
|
1660 |
rk='<div class="rk"><div class="rkl">Remark</div>'+esc(x.remarks);
|
|
|
1661 |
if(x.taskId && x.taskId>0){
|
|
|
1662 |
// one big-string timeline -> global fn + primitive args (partner name escaped for the attribute)
|
|
|
1663 |
rk+='<button type="button" class="rkhist" onclick="bjOpenRemarkHistory('+x.taskId+',\''+js(x.taskType||'')+'\',\''+js(tlTitle(x))+'\')">📜 Past discussions</button>';
|
|
|
1664 |
}
|
|
|
1665 |
rk+='</div>';
|
|
|
1666 |
}
|
| 36903 |
vikas |
1667 |
var remark='<div class="tlremark">'+rk+'</div>';
|
|
|
1668 |
var halves = side==='left' ? (card+remark) : (remark+card);
|
|
|
1669 |
return '<div class="tlrow '+side+'">'+
|
|
|
1670 |
'<div class="node" style="background:'+s.c+'">'+s.ic+'</div>'+
|
|
|
1671 |
halves+'</div>';
|
| 36882 |
vikas |
1672 |
}
|
| 36877 |
vikas |
1673 |
function closeModal(){ el('bj-modal-back').classList.remove('show'); }
|
| 36903 |
vikas |
1674 |
// Image lightbox — opens an attachment larger, stacked above the journey modal.
|
|
|
1675 |
function bjOpenImage(src){ if(!src) return; el('bj-img').src=src; el('bj-img-back').classList.add('show'); }
|
|
|
1676 |
function bjCloseImage(){ el('bj-img-back').classList.remove('show'); el('bj-img').removeAttribute('src'); }
|
| 36877 |
vikas |
1677 |
|
| 37038 |
vikas |
1678 |
/* ===================== REMARK HISTORY DRAWER ===================== */
|
|
|
1679 |
// All prior discussions for one partner/lead (task_id + task_type), across every
|
|
|
1680 |
// executive and date, newest first. Paged via /beat-journey/remark-history.
|
|
|
1681 |
function bjOpenRemarkHistory(taskId, taskType, name){
|
|
|
1682 |
if(!taskId || taskId<=0) return;
|
|
|
1683 |
window._rh = { taskId:taskId, taskType:taskType, name:name, offset:0, limit:8, items:[], loading:false, total:0 };
|
|
|
1684 |
el('bj-rh-title').innerHTML='📜 '+esc(name);
|
|
|
1685 |
el('bj-rh-sub').textContent='Past discussions across all executives';
|
|
|
1686 |
el('bj-rh-list').innerHTML='<div class="rhloading"><div class="spinner"></div>Loading…</div>';
|
|
|
1687 |
el('bj-rh-more').style.display='none';
|
|
|
1688 |
el('bj-rh-back').classList.add('show');
|
|
|
1689 |
bjLoadRemarkHistory();
|
|
|
1690 |
}
|
|
|
1691 |
function closeRemarkHistory(){ el('bj-rh-back').classList.remove('show'); window._rh=null; }
|
|
|
1692 |
function bjLoadRemarkHistory(){
|
|
|
1693 |
var s=window._rh; if(!s || s.loading) return; // block re-entrant fetches
|
|
|
1694 |
s.loading=true;
|
|
|
1695 |
var more=el('bj-rh-more'); more.disabled=true; more.textContent='Loading…';
|
|
|
1696 |
var u=CTX+'/beat-journey/remark-history?taskId='+s.taskId
|
|
|
1697 |
+'&taskType='+encodeURIComponent(s.taskType||'')
|
|
|
1698 |
+'&offset='+s.offset+'&limit='+s.limit;
|
|
|
1699 |
fetch(u,{headers:{'Accept':'application/json'}})
|
|
|
1700 |
.then(function(r){ return r.json(); })
|
|
|
1701 |
.then(function(d){
|
|
|
1702 |
var st=window._rh; if(!st || st.taskId!==s.taskId) return; // drawer changed mid-flight
|
|
|
1703 |
st.total=d.total||0;
|
|
|
1704 |
(d.items||[]).forEach(function(it){ st.items.push(it); });
|
|
|
1705 |
st.offset += (d.items||[]).length;
|
|
|
1706 |
st.loading=false;
|
|
|
1707 |
bjRenderRemarkHistory(!!d.hasMore);
|
|
|
1708 |
})
|
|
|
1709 |
.catch(function(){
|
|
|
1710 |
var st=window._rh; if(st) st.loading=false;
|
|
|
1711 |
el('bj-rh-list').innerHTML='<div class="rhempty">Could not load past discussions.</div>';
|
|
|
1712 |
el('bj-rh-more').style.display='none';
|
|
|
1713 |
});
|
|
|
1714 |
}
|
|
|
1715 |
function bjRenderRemarkHistory(hasMore){
|
|
|
1716 |
var s=window._rh; if(!s) return;
|
|
|
1717 |
if(!s.items.length){
|
|
|
1718 |
el('bj-rh-list').innerHTML='<div class="rhempty">No earlier discussions for this partner.</div>';
|
|
|
1719 |
el('bj-rh-more').style.display='none';
|
|
|
1720 |
return;
|
|
|
1721 |
}
|
|
|
1722 |
var h='';
|
|
|
1723 |
s.items.forEach(function(it){
|
|
|
1724 |
var mt=(it.markType||'').toUpperCase();
|
|
|
1725 |
var badge = mt.indexOf('CHECKOUT')>=0 ? '<span class="rhb ok">visited</span>'
|
|
|
1726 |
: (mt==='DEFERRED' ? '<span class="rhb def">deferred</span>' : '');
|
|
|
1727 |
var when = esc(it.date||'')+(it.time?' · '+esc(hhmm(it.time)):'');
|
|
|
1728 |
h+='<div class="rhitem">'
|
|
|
1729 |
+'<div class="rhtop"><span class="rhdate">'+when+'</span>'+badge+'</div>'
|
|
|
1730 |
+'<div class="rhexec">'+esc(it.exec||'')+'</div>'
|
|
|
1731 |
+'<div class="rhtext">'+esc(it.remark||'')+'</div></div>';
|
|
|
1732 |
});
|
|
|
1733 |
el('bj-rh-list').innerHTML=h;
|
|
|
1734 |
var more=el('bj-rh-more');
|
|
|
1735 |
more.disabled=false; more.textContent='Load more';
|
|
|
1736 |
more.style.display = hasMore ? 'block' : 'none';
|
|
|
1737 |
}
|
|
|
1738 |
|
| 37044 |
vikas |
1739 |
/* ===================== ORDERS DRAWER ===================== */
|
|
|
1740 |
// All retail orders a partner placed on the visit day, each with its line items.
|
|
|
1741 |
function bjOpenOrderList(fofoId){
|
|
|
1742 |
if(!fofoId) return;
|
|
|
1743 |
var date=(window._exec && window._exec.date) || '';
|
|
|
1744 |
window._od = { fofoId:fofoId, date:date };
|
|
|
1745 |
el('bj-od-title').innerHTML='🧾 Orders';
|
|
|
1746 |
el('bj-od-sub').textContent='Orders placed on '+date;
|
| 37039 |
vikas |
1747 |
el('bj-od-body').innerHTML='<div class="odloading"><div class="spinner"></div>Loading…</div>';
|
|
|
1748 |
el('bj-od-back').classList.add('show');
|
| 37044 |
vikas |
1749 |
fetch(CTX+'/beat-journey/order-list?fofoId='+encodeURIComponent(fofoId)+'&date='+encodeURIComponent(date),
|
| 37039 |
vikas |
1750 |
{headers:{'Accept':'application/json'}})
|
|
|
1751 |
.then(function(r){ return r.json(); })
|
|
|
1752 |
.then(function(d){
|
| 37044 |
vikas |
1753 |
var st=window._od; if(!st || st.fofoId!==fofoId) return; // drawer changed mid-flight
|
|
|
1754 |
bjRenderOrderList(d);
|
| 37039 |
vikas |
1755 |
})
|
| 37044 |
vikas |
1756 |
.catch(function(){ el('bj-od-body').innerHTML='<div class="odempty">Could not load orders.</div>'; });
|
| 37039 |
vikas |
1757 |
}
|
|
|
1758 |
function closeOrderDetail(){ el('bj-od-back').classList.remove('show'); window._od=null; }
|
| 37044 |
vikas |
1759 |
function bjRenderOrderList(d){
|
|
|
1760 |
var orders=d.orders||[];
|
|
|
1761 |
if(!orders.length){ el('bj-od-body').innerHTML='<div class="odempty">No orders placed on this day.</div>'; return; }
|
| 37039 |
vikas |
1762 |
var money=function(n){ return '₹'+(Math.round(Number(n)||0)).toLocaleString(); };
|
| 37044 |
vikas |
1763 |
el('bj-od-sub').textContent=orders.length+' order'+(orders.length>1?'s':'')+' placed on '+(window._od?window._od.date:'');
|
|
|
1764 |
var h='';
|
|
|
1765 |
orders.forEach(function(o){
|
|
|
1766 |
h+='<div class="odorder"><div class="odsum">'
|
|
|
1767 |
+'<div><div class="k">Invoice</div><div class="v">'+esc(o.invoice||('#'+o.orderId))+'</div></div>'
|
|
|
1768 |
+'<div><div class="k">Amount</div><div class="v">'+money(o.amount)+'</div></div>'
|
|
|
1769 |
+'<div><div class="k">Time</div><div class="v">'+esc(o.time?hhmm(o.time):'—')+'</div></div>'
|
|
|
1770 |
+'</div>';
|
|
|
1771 |
var items=o.items||[];
|
|
|
1772 |
if(items.length){
|
|
|
1773 |
h+='<table class="odtable"><thead><tr><th>Item</th><th class="num">Qty</th><th class="num">MOP</th></tr></thead><tbody>';
|
|
|
1774 |
items.forEach(function(it){
|
|
|
1775 |
var name=[it.brand,it.model,it.color].filter(function(s){return s;}).map(esc).join(' · ');
|
|
|
1776 |
h+='<tr><td>'+(name||'—')+'</td><td class="num">'+(it.qty||0)+'</td><td class="num">'+money(it.mop)+'</td></tr>';
|
|
|
1777 |
});
|
|
|
1778 |
h+='</tbody></table>';
|
|
|
1779 |
} else { h+='<div class="odempty">No line items.</div>'; }
|
|
|
1780 |
h+='</div>';
|
| 37039 |
vikas |
1781 |
});
|
|
|
1782 |
el('bj-od-body').innerHTML=h;
|
|
|
1783 |
}
|
|
|
1784 |
|
| 36877 |
vikas |
1785 |
/* ============================ APPROVAL ============================ */
|
|
|
1786 |
function loadApproval(){
|
|
|
1787 |
spinner();
|
|
|
1788 |
var from=el('bj-start').value, to=el('bj-end').value;
|
|
|
1789 |
var u=CTX+'/beat-report/approval/data?fromDate='+encodeURIComponent(from)+'&toDate='+encodeURIComponent(to)+qs();
|
|
|
1790 |
fetch(u,{headers:{'Accept':'application/json'}}).then(function(r){ return r.json(); }).then(function(d){
|
|
|
1791 |
var rows=d.rows||[];
|
|
|
1792 |
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>'+
|
|
|
1793 |
'<p class="desc">Check-ins recorded >50 m from the partner\'s saved location, across your downline. Approving updates the partner\'s coordinates.</p>';
|
|
|
1794 |
if(!rows.length){ h+='<p class="desc">Nothing pending in this window.</p></div>'; el('bj-content').innerHTML=h; return; }
|
|
|
1795 |
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>';
|
|
|
1796 |
rows.forEach(function(r){
|
|
|
1797 |
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>'+
|
|
|
1798 |
'<td class="num '+(r.distanceM>200?'badflag':'warnflag')+'">'+r.distanceM+' m</td>'+
|
|
|
1799 |
'<td class="num"><button class="btn-ok" onclick="approve('+r.trackingId+')">Approve</button></td></tr>';
|
|
|
1800 |
});
|
|
|
1801 |
h+='</tbody></table></div>';
|
|
|
1802 |
el('bj-content').innerHTML=h;
|
|
|
1803 |
}).catch(fail);
|
|
|
1804 |
}
|
|
|
1805 |
function approve(trackingId){
|
|
|
1806 |
fetch(CTX+'/beat-report/approve-checkin',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'trackingId='+trackingId})
|
|
|
1807 |
.then(function(r){ return r.json(); }).then(function(){ var row=el('appr-'+trackingId); if(row) row.parentNode.removeChild(row); }).catch(function(){ alert('Approve failed'); });
|
|
|
1808 |
}
|
|
|
1809 |
|
| 36903 |
vikas |
1810 |
/* ============================ EXPORT ============================ */
|
|
|
1811 |
// Download the Employee Task Report (.xlsx). Today lens exports the single
|
|
|
1812 |
// selected day; Period lens exports the whole start..end range. Both carry the
|
|
|
1813 |
// current level / team scope (qs()). The download is served as an attachment, so
|
|
|
1814 |
// navigating to the URL triggers the file save without leaving the page.
|
|
|
1815 |
function exportReport(){
|
| 36913 |
vikas |
1816 |
var start, end, path;
|
|
|
1817 |
if(LENS==='period'){
|
|
|
1818 |
// Period lens: per-employee totals + a Raw Data sheet (separate endpoint).
|
|
|
1819 |
start=el('bj-start').value; end=el('bj-end').value; path='/export-period';
|
|
|
1820 |
} else {
|
|
|
1821 |
// Today lens: the day-grouped Employee Task Report (unchanged).
|
|
|
1822 |
start=el('bj-date').value; end=start; path='/export';
|
|
|
1823 |
}
|
|
|
1824 |
var u = BASE + path + '?startDate=' + encodeURIComponent(start) + '&endDate=' + encodeURIComponent(end) + qs();
|
| 36903 |
vikas |
1825 |
window.location.href = u;
|
|
|
1826 |
}
|
|
|
1827 |
|
| 36877 |
vikas |
1828 |
/* ============================ INIT ============================ */
|
|
|
1829 |
document.querySelectorAll('.lens').forEach(function(b){ b.addEventListener('click', function(){ setLens(b.getAttribute('data-lens')); }); });
|
|
|
1830 |
el('bj-apply').addEventListener('click', load);
|
| 36903 |
vikas |
1831 |
el('bj-export').addEventListener('click', exportReport);
|
| 36897 |
vikas |
1832 |
el('bj-level').addEventListener('change', onLevelChange);
|
|
|
1833 |
if(el('bj-user')) el('bj-user').addEventListener('change', load);
|
|
|
1834 |
el('bj-date').addEventListener('change', updateTodayLabel);
|
| 36877 |
vikas |
1835 |
el('bj-modal-back').addEventListener('click', function(e){ if(e.target===el('bj-modal-back')) closeModal(); });
|
| 36903 |
vikas |
1836 |
el('bj-img-back').addEventListener('click', function(e){ if(e.target===el('bj-img-back')) bjCloseImage(); });
|
|
|
1837 |
el('bj-img-x').addEventListener('click', bjCloseImage);
|
|
|
1838 |
// Esc closes the lightbox first (if open), otherwise the journey modal.
|
|
|
1839 |
document.addEventListener('keydown', function(e){
|
|
|
1840 |
if(e.key!=='Escape') return;
|
|
|
1841 |
if(el('bj-img-back').classList.contains('show')) bjCloseImage();
|
|
|
1842 |
else if(el('bj-modal-back').classList.contains('show')) closeModal();
|
|
|
1843 |
});
|
| 36897 |
vikas |
1844 |
updateTodayLabel();
|
| 36877 |
vikas |
1845 |
load();
|
|
|
1846 |
</script>
|
|
|
1847 |
</body>
|
|
|
1848 |
</html>
|