Subversion Repositories SmartDukaan

Rev

Rev 36897 | Rev 36905 | Go to most recent revision | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 36897 Rev 36903
Line 135... Line 135...
135
  .hide{display:none}
135
  .hide{display:none}
136
  .loading{padding:70px;text-align:center;color:var(--muted)}
136
  .loading{padding:70px;text-align:center;color:var(--muted)}
137
  .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}
137
  .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}
138
  @keyframes spin{to{transform:rotate(360deg)}}
138
  @keyframes spin{to{transform:rotate(360deg)}}
139
  .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}
139
  .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}
-
 
140
  /* image lightbox — stacks above the journey modal */
-
 
141
  .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}
-
 
142
  .img-back.show{display:flex}
-
 
143
  .img-back img{max-width:95%;max-height:92vh;border-radius:10px;box-shadow:0 16px 60px rgba(0,0,0,.5);background:#fff}
-
 
144
  .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}
-
 
145
  .img-x:hover{background:rgba(255,255,255,.3)}
140
  .modal-back.show{display:flex}
146
  .modal-back.show{display:flex}
141
  .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}
147
  .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}
142
  .modal h3{margin:0 0 2px;font-size:17px;text-align:center}
148
  .modal h3{margin:0 0 2px;font-size:17px;text-align:center}
143
  .modal > .desc{text-align:center}
149
  .modal > .desc{text-align:center}
144
  .modal .x{position:absolute;top:14px;right:16px;cursor:pointer;color:var(--soft);font-size:22px;line-height:1;border:0;background:none}
150
  .modal .x{position:absolute;top:14px;right:16px;cursor:pointer;color:var(--soft);font-size:22px;line-height:1;border:0;background:none}
Line 151... Line 157...
151
  /* journey timeline (exec drilldown) */
157
  /* journey timeline (exec drilldown) */
152
  .tl{position:relative;margin:14px 2px 4px;padding:4px 0}
158
  .tl{position:relative;margin:14px 2px 4px;padding:4px 0}
153
  .tl::before{content:"";position:absolute;left:50%;top:6px;bottom:6px;width:2px;background:#e9edf5;transform:translateX(-50%)}
159
  .tl::before{content:"";position:absolute;left:50%;top:6px;bottom:6px;width:2px;background:#e9edf5;transform:translateX(-50%)}
154
  .tlgap{position:relative;display:flex;justify-content:center;margin:-3px 0}
160
  .tlgap{position:relative;display:flex;justify-content:center;margin:-3px 0}
155
  .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}
161
  .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}
156
  .tlrow{position:relative;display:flex;margin:14px 0}
162
  .tlrow{position:relative;display:flex;justify-content:space-between;align-items:flex-start;margin:14px 0}
157
  .tlrow.left{justify-content:flex-start}
-
 
158
  .tlrow.right{justify-content:flex-end}
-
 
159
  .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}
163
  .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}
160
  .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}
164
  .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}
161
  .tlrow.left .tlcard{flex-direction:row-reverse;text-align:right}
165
  .tlrow.left .tlcard{flex-direction:row-reverse;text-align:right}
162
  .tlrow.right .tlcard{flex-direction:row;text-align:left}
166
  .tlrow.right .tlcard{flex-direction:row;text-align:left}
-
 
167
  /* remark on the opposite half, hugging the centre line */
-
 
168
  .tlremark{width:calc(50% - 26px);display:flex;align-items:flex-start;padding-top:6px}
-
 
169
  .tlrow.right .tlremark{justify-content:flex-end}
-
 
170
  .tlrow.left .tlremark{justify-content:flex-start}
-
 
171
  .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}
-
 
172
  .tlrow.right .tlremark .rk{text-align:right}
-
 
173
  .tlrow.left .tlremark .rk{text-align:left}
-
 
174
  .tlremark .rkl{font-size:9px;text-transform:uppercase;letter-spacing:.05em;color:var(--soft);font-weight:700;margin-bottom:3px}
163
  .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}
175
  .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}
164
  .tlcard .tm small{font-weight:600;font-size:8.5px;opacity:.9;text-transform:uppercase;letter-spacing:.04em;margin-top:3px}
176
  .tlcard .tm small{font-weight:600;font-size:8.5px;opacity:.9;text-transform:uppercase;letter-spacing:.04em;margin-top:3px}
165
  .tlcard .bd{padding:8px 11px;flex:1;min-width:0}
177
  .tlcard .bd{padding:8px 11px;flex:1;min-width:0}
166
  .tlcard .bd .ti{font-size:12.5px;font-weight:700;color:var(--ink);line-height:1.25;word-break:break-word}
178
  .tlcard .bd .ti{font-size:12.5px;font-weight:700;color:var(--ink);line-height:1.25;word-break:break-word}
167
  .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}
179
  .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}
Line 174... Line 186...
174
  .tlchip{font-size:10.5px;font-weight:600;color:var(--muted);background:#f1f3f9;border-radius:6px;padding:2px 7px;white-space:nowrap}
186
  .tlchip{font-size:10.5px;font-weight:600;color:var(--muted);background:#f1f3f9;border-radius:6px;padding:2px 7px;white-space:nowrap}
175
  .exectabs{display:flex;gap:6px;justify-content:center;margin:12px 0 4px}
187
  .exectabs{display:flex;gap:6px;justify-content:center;margin:12px 0 4px}
176
  .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}
188
  .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}
177
  .exectab.active{background:var(--brand);color:#fff;border-color:var(--brand)}
189
  .exectab.active{background:var(--brand);color:#fff;border-color:var(--brand)}
178
  #bjMap{width:100%;height:460px;border-radius:12px;border:1px solid var(--line);margin-top:10px;background:#eef1f7;overflow:hidden}
190
  #bjMap{width:100%;height:460px;border-radius:12px;border:1px solid var(--line);margin-top:10px;background:#eef1f7;overflow:hidden}
-
 
191
  .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}
-
 
192
  .bjlegend .lg{font-weight:700}
-
 
193
  .bjlegend .sw{display:inline-block;width:24px;vertical-align:middle;margin-right:7px}
-
 
194
  .bjlegend .ln{border-top:4px solid #e03131}
-
 
195
  .bjlegend .dt{border-top:4px dotted #2f9e44}
179
  .valwrap{display:flex;justify-content:center;margin:14px 0 6px}
196
  .valwrap{display:flex;justify-content:center;margin:14px 0 6px}
180
  .valimg{max-width:100%;max-height:440px;border-radius:12px;border:1px solid var(--line);cursor:zoom-in}
197
  .valimg{max-width:100%;max-height:440px;border-radius:12px;border:1px solid var(--line);cursor:zoom-in}
181
  .valph{width:100%;padding:48px;text-align:center;color:var(--soft);border:1px dashed #cfd6e6;border-radius:12px;background:#f4f6fb;font-size:14px}
198
  .valph{width:100%;padding:48px;text-align:center;color:var(--soft);border:1px dashed #cfd6e6;border-radius:12px;background:#f4f6fb;font-size:14px}
182
  .valactions{display:flex;justify-content:center;gap:10px;margin-top:8px}
199
  .valactions{display:flex;justify-content:center;gap:10px;margin-top:8px}
183
  @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}}
200
  @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}}
184
  @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}}
201
  @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}}
185
</style>
202
</style>
186
</head>
203
</head>
187
<body>
204
<body>
188
<div class="wrap">
205
<div class="wrap">
189
  <header class="top">
206
  <header class="top">
Line 207... Line 224...
207
      <span id="bj-period-range" class="hide">
224
      <span id="bj-period-range" class="hide">
208
        <input class="ctl" type="date" id="bj-start" value="$monthStart">
225
        <input class="ctl" type="date" id="bj-start" value="$monthStart">
209
        <input class="ctl" type="date" id="bj-end" value="$today">
226
        <input class="ctl" type="date" id="bj-end" value="$today">
210
      </span>
227
      </span>
211
      <button class="btn-r" id="bj-apply">Apply</button>
228
      <button class="btn-r" id="bj-apply">Apply</button>
-
 
229
      <button class="btn-r" id="bj-export" style="background:#2f9e44" title="Download an Excel report for the selected date / range">&#11015; Export Excel</button>
212
    </div>
230
    </div>
213
  </header>
231
  </header>
214
 
232
 
215
  <div class="lenses">
233
  <div class="lenses">
216
    <button class="lens active" data-lens="today" id="lens-today">Today (Live)</button>
234
    <button class="lens active" data-lens="today" id="lens-today">Today (Live)</button>
Line 222... Line 240...
222
 
240
 
223
<div class="modal-back" id="bj-modal-back">
241
<div class="modal-back" id="bj-modal-back">
224
  <div class="modal" id="bj-modal"></div>
242
  <div class="modal" id="bj-modal"></div>
225
</div>
243
</div>
226
 
244
 
-
 
245
<div class="img-back" id="bj-img-back">
-
 
246
  <button class="img-x" id="bj-img-x" title="Close">&times;</button>
-
 
247
  <img id="bj-img" alt="Attachment">
-
 
248
</div>
-
 
249
 
227
<script>
250
<script>
228
var CTX = '${rc.contextPath}';
251
var CTX = '${rc.contextPath}';
229
var BASE = CTX + '/beat-journey';
252
var BASE = CTX + '/beat-journey';
230
var LENS = 'today';
253
var LENS = 'today';
231
var TODAY = '$today';
254
var TODAY = '$today';
Line 429... Line 452...
429
function openValidate(trackingId){
452
function openValidate(trackingId){
430
  var list=(window._att&&window._att.geofence)||[], item=null;
453
  var list=(window._att&&window._att.geofence)||[], item=null;
431
  for(var i=0;i<list.length;i++){ if(list[i].trackingId===trackingId){ item=list[i]; break; } }
454
  for(var i=0;i<list.length;i++){ if(list[i].trackingId===trackingId){ item=list[i]; break; } }
432
  if(!item){ return; }
455
  if(!item){ return; }
433
  var img = (item.attachment && window._mediaUrl)
456
  var img = (item.attachment && window._mediaUrl)
434
    ? '<img class="valimg" src="'+esc(window._mediaUrl+item.attachment)+'" onclick="window.open(this.src,\'_blank\')" onerror="this.outerHTML=\'<div class=&quot;valph&quot;>&#128247; Photo unavailable</div>\'">'
457
    ? '<img class="valimg" src="'+esc(window._mediaUrl+item.attachment)+'" onclick="bjOpenImage(this.src)" onerror="this.outerHTML=\'<div class=&quot;valph&quot;>&#128247; Photo unavailable</div>\'">'
435
    : '<div class="valph">&#128247; No photo on this check-in</div>';
458
    : '<div class="valph">&#128247; No photo on this check-in</div>';
436
  el('bj-modal').innerHTML='<button class="x" onclick="closeModal()">&times;</button><h3>Validate check-in</h3>'+
459
  el('bj-modal').innerHTML='<button class="x" onclick="closeModal()">&times;</button><h3>Validate check-in</h3>'+
437
    '<p class="desc">'+esc(item.name)+' &middot; '+esc(item.detail||'')+'</p>'+
460
    '<p class="desc">'+esc(item.name)+' &middot; '+esc(item.detail||'')+'</p>'+
438
    '<div class="valwrap">'+img+'</div>'+
461
    '<div class="valwrap">'+img+'</div>'+
439
    '<div class="valactions">'+
462
    '<div class="valactions">'+
Line 632... Line 655...
632
    '<div class="exectabs">'+
655
    '<div class="exectabs">'+
633
      '<button class="exectab'+(tab==='timeline'?' active':'')+'" onclick="renderExec(\'timeline\')">&#128221; Timeline</button>'+
656
      '<button class="exectab'+(tab==='timeline'?' active':'')+'" onclick="renderExec(\'timeline\')">&#128221; Timeline</button>'+
634
      '<button class="exectab'+(tab==='map'?' active':'')+'" onclick="renderExec(\'map\')">&#128205; Live Location</button>'+
657
      '<button class="exectab'+(tab==='map'?' active':'')+'" onclick="renderExec(\'map\')">&#128205; Live Location</button>'+
635
    '</div>';
658
    '</div>';
636
  if(tab==='map'){
659
  if(tab==='map'){
-
 
660
    el('bj-modal').innerHTML=head+'<div id="bjRouteLegend" class="bjlegend"></div>'+
637
    el('bj-modal').innerHTML=head+'<div id="bjMap"><div class="loading"><div class="spinner"></div>Loading map&hellip;</div></div>';
661
      '<div id="bjMap"><div class="loading"><div class="spinner"></div>Loading map&hellip;</div></div>';
638
    bjShowMap(ex.userId, ex.date);
662
    bjShowMap(ex.userId, ex.date);
639
  } else {
663
  } else {
640
    el('bj-modal').innerHTML=head+buildTimelineHtml(d, ex.date);
664
    el('bj-modal').innerHTML=head+buildTimelineHtml(d, ex.date);
641
  }
665
  }
642
}
666
}
Line 652... Line 676...
652
    else if(x.checkInTime){ visited.push(x); }
676
    else if(x.checkInTime){ visited.push(x); }
653
    else { pending.push(x); }
677
    else { pending.push(x); }
654
  });
678
  });
655
  visited.sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); });
679
  visited.sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); });
656
 
680
 
657
  var distKm=0, inStoreSec=0, transitSec=0, leadsN=0;
681
  var distKm=0, inStoreSec=0, leadsN=0;
658
  all.forEach(function(x){
682
  all.forEach(function(x){
659
    var tt=(x.taskType||'').toLowerCase();
683
    var tt=(x.taskType||'').toLowerCase();
660
    if(tt==='attendance') return;
684
    if(tt==='attendance') return;
661
    if(x.totalDistance) distKm += (parseFloat(x.totalDistance)||0);
685
    if(x.totalDistance) distKm += (parseFloat(x.totalDistance)||0);
662
    inStoreSec += toSec(x.timeSpent);
686
    inStoreSec += toSec(x.timeSpent);
663
    transitSec += toSec(x.transitTime);
-
 
664
    if(tt==='lead') leadsN++;
687
    if(tt==='lead') leadsN++;
665
  });
688
  });
666
  var workSec = (att && att.checkInTime && att.checkOutTime) ? (toSec(att.checkOutTime)-toSec(att.checkInTime)) : (att?toSec(att.timeSpent):0);
689
  var workSec = (att && att.checkInTime && att.checkOutTime) ? (toSec(att.checkOutTime)-toSec(att.checkInTime)) : (att?toSec(att.timeSpent):0);
667
  if(workSec<0) workSec=0;
690
  if(workSec<0) workSec=0;
-
 
691
  // Travel = working − in-store, so In-store + Travel = Working hours exactly
-
 
692
  // (summing per-leg transit under-counts the idle/waiting between stops).
-
 
693
  var transitSec = Math.max(0, workSec - inStoreSec);
668
 
694
 
669
  var seq=[];
695
  var seq=[];
670
  if(att && att.checkInTime) seq.push(punchEntry('in', att.checkInTime));
696
  if(att && att.checkInTime) seq.push(punchEntry('in', att.checkInTime));
671
  visited.forEach(function(x){ seq.push(x); });
697
  visited.forEach(function(x){ seq.push(x); });
672
  pending.forEach(function(x){ seq.push(x); });
698
  pending.forEach(function(x){ seq.push(x); });
Line 760... Line 786...
760
    var m=new google.maps.Marker({position:pos,map:map,zIndex:zi||100,icon:icon,title:title||''});
786
    var m=new google.maps.Marker({position:pos,map:map,zIndex:zi||100,icon:icon,title:title||''});
761
    if(info){ var iw=new google.maps.InfoWindow({content:info}); m.addListener('click',function(){ iw.open(map,m); }); }
787
    if(info){ var iw=new google.maps.InfoWindow({content:info}); m.addListener('click',function(){ iw.open(map,m); }); }
762
    return m;
788
    return m;
763
  }
789
  }
764
 
790
 
765
  // 1) GPS trail + last-known location (same bike/rider icon as Beat Report)
791
  // 1) last-known location marker (the GPS trail itself is drawn as the "taken"
-
 
792
  // route in bjDrawRoutes, straight from location_tracking_path).
766
  var pts=(data.path||[]).map(function(p){ return {lat:p.lat,lng:p.lng}; });
793
  var pts=(data.path||[]).map(function(p){ return {lat:p.lat,lng:p.lng}; });
767
  if(pts.length){
794
  if(pts.length){
768
    new google.maps.Polyline({path:pts,geodesic:true,strokeColor:'#e03131',strokeOpacity:.7,strokeWeight:3,zIndex:40,map:map});
-
 
769
    pts.forEach(function(p){ bounds.extend(p); has=true; });
795
    pts.forEach(function(p){ bounds.extend(p); has=true; });
770
    var lastPt=pts[pts.length-1];
796
    var lastPt=pts[pts.length-1];
771
    var lk=new google.maps.Marker({position:lastPt,map:map,zIndex:999,title:'Last Known Location',
797
    var lk=new google.maps.Marker({position:lastPt,map:map,zIndex:999,title:'Last Known Location',
772
      icon:{url:CTX+'/resources/images/salesman.png',scaledSize:new google.maps.Size(40,40),anchor:new google.maps.Point(20,20)}});
798
      icon:{url:CTX+'/resources/images/salesman.png',scaledSize:new google.maps.Size(40,40),anchor:new google.maps.Point(20,20)}});
773
    var lkIw=new google.maps.InfoWindow({content:'<div style="font-size:12px"><b>&#128205; Last known location</b></div>'});
799
    var lkIw=new google.maps.InfoWindow({content:'<div style="font-size:12px"><b>&#128205; Last known location</b></div>'});
Line 799... Line 825...
799
    pin({lat:data.punchOut.lat,lng:data.punchOut.lng}, bjPin('#7048e8','X'), 300,
825
    pin({lat:data.punchOut.lat,lng:data.punchOut.lng}, bjPin('#7048e8','X'), 300,
800
      'Punch out'+(data.punchOut.time?' · '+data.punchOut.time:''),
826
      'Punch out'+(data.punchOut.time?' · '+data.punchOut.time:''),
801
      '<div style="font-size:12px"><b>&#9873; Punch out</b>'+(data.punchOut.time?'<br>'+esc(data.punchOut.time):'')+'</div>');
827
      '<div style="font-size:12px"><b>&#9873; Punch out</b>'+(data.punchOut.time?'<br>'+esc(data.punchOut.time):'')+'</div>');
802
  }
828
  }
803
 
829
 
-
 
830
  // 5) routes — actual road taken vs Google-suggested optimal, for comparison.
-
 
831
  // Checkpoints in journey order: punch in → visits (by check-in time) → punch out.
-
 
832
  var cps=[];
-
 
833
  if(data.punchIn) cps.push({lat:data.punchIn.lat,lng:data.punchIn.lng});
-
 
834
  (data.visits||[]).slice()
-
 
835
    .filter(function(v){ return v.lat && v.lng; })
-
 
836
    .sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); })
-
 
837
    .forEach(function(v){ cps.push({lat:v.lat,lng:v.lng}); });
-
 
838
  if(data.punchOut) cps.push({lat:data.punchOut.lat,lng:data.punchOut.lng});
-
 
839
  bjDrawRoutes(map, cps, pts);
-
 
840
 
804
  if(has){
841
  if(has){
805
    map.fitBounds(bounds);
842
    map.fitBounds(bounds);
806
    google.maps.event.addListenerOnce(map,'idle',function(){ if(map.getZoom()>16) map.setZoom(16); });
843
    google.maps.event.addListenerOnce(map,'idle',function(){ if(map.getZoom()>16) map.setZoom(16); });
807
  } else {
844
  } else {
808
    box.innerHTML='<div class="loading">No GPS / location recorded for this day.</div>';
845
    box.innerHTML='<div class="loading">No GPS / location recorded for this day.</div>';
809
  }
846
  }
810
}
847
}
-
 
848
// TAKEN route = the recorded GPS trail (location_tracking_path) routed along the
-
 
849
// road network so it follows actual roads rather than straight hops between sparse
-
 
850
// pings. SUGGESTED route = the Google-optimal road route through the journey's
-
 
851
// checkpoints (green dotted). Both are drawn and compared by distance.
-
 
852
function bjDrawRoutes(map, cps, pathPts){
-
 
853
  var legend=bjRouteLegend();
-
 
854
 
-
 
855
  // taken — road route traced through the recorded breadcrumb (in order)
-
 
856
  if(pathPts && pathPts.length>=2){
-
 
857
    var ds1=new google.maps.DirectionsService();
-
 
858
    var sample=bjSample(pathPts, 25);                          // Directions cap: 25 incl. ends
-
 
859
    var o=sample[0], d=sample[sample.length-1];
-
 
860
    var vias=sample.slice(1,-1).map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:false}; });
-
 
861
    bjRouteRequest(ds1, o, d, vias, false, function(res){
-
 
862
      bjRenderRoute(map, res, {color:'#e03131', dashed:false, z:55});
-
 
863
      legend.set('taken', bjRouteKm(res));
-
 
864
    }, function(status){
-
 
865
      console.warn('Directions (taken) failed:', status);
-
 
866
      // fallback: plot the raw breadcrumb (straight hops) and measure along it
-
 
867
      new google.maps.Polyline({path:pathPts,geodesic:false,strokeColor:'#e03131',strokeOpacity:.9,strokeWeight:4,zIndex:55,map:map});
-
 
868
      var ll=pathPts.map(function(p){ return new google.maps.LatLng(p.lat,p.lng); });
-
 
869
      legend.set('taken', google.maps.geometry.spherical.computeLength(ll)/1000);
-
 
870
    });
-
 
871
  } else {
-
 
872
    legend.set('taken', false);
-
 
873
  }
-
 
874
 
-
 
875
  // suggested — Google-optimal route through punch-in → visits → punch-out
-
 
876
  if(cps.length>=2){
-
 
877
    var ds=new google.maps.DirectionsService();
-
 
878
    var origin=cps[0], dest=cps[cps.length-1], mids=cps.slice(1,-1), capped=false;
-
 
879
    if(mids.length>23){ mids=mids.slice(0,23); capped=true; }  // Directions allows 23 waypoints
-
 
880
    var wpts=mids.map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:true}; });
-
 
881
    bjRouteRequest(ds, origin, dest, wpts, true, function(res){
-
 
882
      bjRenderRoute(map, res, {color:'#2f9e44', dashed:true, z:50});
-
 
883
      legend.set('suggested', bjRouteKm(res));
-
 
884
    }, function(status){ console.warn('Directions (suggested) failed:', status); legend.set('suggested', false); });
-
 
885
    if(capped) legend.note('Suggested route limited to first 23 stops');
-
 
886
  } else {
-
 
887
    legend.set('suggested', false);
-
 
888
  }
-
 
889
}
-
 
890
// Evenly downsample a list to at most `max` items, always keeping first and last.
-
 
891
function bjSample(arr, max){
-
 
892
  if(arr.length<=max) return arr.slice();
-
 
893
  var out=[], step=(arr.length-1)/(max-1);
-
 
894
  for(var i=0;i<max;i++) out.push(arr[Math.round(i*step)]);
-
 
895
  return out;
-
 
896
}
-
 
897
function bjRouteRequest(ds, origin, dest, wpts, optimize, ok, err){
-
 
898
  ds.route({
-
 
899
    origin:new google.maps.LatLng(origin.lat,origin.lng),
-
 
900
    destination:new google.maps.LatLng(dest.lat,dest.lng),
-
 
901
    waypoints:wpts, optimizeWaypoints:optimize,
-
 
902
    travelMode:google.maps.TravelMode.DRIVING
-
 
903
  }, function(res, status){
-
 
904
    if(status===google.maps.DirectionsStatus.OK && res && res.routes && res.routes.length) ok(res);
-
 
905
    else err(status);
-
 
906
  });
-
 
907
}
-
 
908
function bjRenderRoute(map, res, opt){
-
 
909
  var poly={strokeColor:opt.color, strokeWeight:opt.dashed?0:5, strokeOpacity:opt.dashed?0:0.85, zIndex:opt.z};
-
 
910
  if(opt.dashed){
-
 
911
    poly.icons=[{icon:{path:'M 0,-1 0,1', strokeColor:opt.color, strokeOpacity:1, strokeWeight:4, scale:3}, offset:'0', repeat:'14px'}];
-
 
912
  }
-
 
913
  new google.maps.DirectionsRenderer({map:map, directions:res, suppressMarkers:true,
-
 
914
    suppressInfoWindows:true, preserveViewport:true, polylineOptions:poly});
-
 
915
}
-
 
916
function bjRouteKm(res){
-
 
917
  var m=0; (res.routes[0].legs||[]).forEach(function(l){ m+=(l.distance?l.distance.value:0); }); return m/1000;
-
 
918
}
-
 
919
// Route-comparison panel rendered into the modal (#bjRouteLegend), above the map.
-
 
920
// Each distance fills in as its route resolves. value: number = km, false =
-
 
921
// unavailable, undefined = still loading.
-
 
922
function bjRouteLegend(){
-
 
923
  var div=el('bjRouteLegend');
-
 
924
  if(!div) return { set:function(){}, note:function(){} };
-
 
925
  var taken, sugg, noteTxt='';
-
 
926
  function val(v){ return typeof v==='number' ? '<b>'+v.toFixed(1)+' km</b>' : (v===false ? 'n/a' : '…'); }
-
 
927
  function render(){
-
 
928
    var cmp='';
-
 
929
    if(typeof taken==='number' && typeof sugg==='number'){
-
 
930
      cmp = taken>sugg+0.05
-
 
931
        ? '<span style="color:#e03131">+'+(taken-sugg).toFixed(1)+' km longer than suggested</span>'
-
 
932
        : '<span style="color:#2f9e44">on / better than the suggested route</span>';
-
 
933
    }
-
 
934
    div.innerHTML=
-
 
935
      '<span class="lg">Route comparison</span>'+
-
 
936
      '<span><span class="sw ln"></span>Taken (GPS)</span>'+
-
 
937
      '<span><span class="sw dt"></span>Suggested</span>'+
-
 
938
      cmp+
-
 
939
      (noteTxt?'<span style="color:#e8590c">'+noteTxt+'</span>':'');
-
 
940
  }
-
 
941
  render();
-
 
942
  return { set:function(k,v){ if(k==='taken')taken=v; else sugg=v; render(); }, note:function(t){ noteTxt=t; render(); } };
-
 
943
}
811
function tlSecs(t){ if(!t) return 1e9; var p=t.split(':'); return (+p[0])*3600+(+(p[1]||0))*60+(+(p[2]||0)); }
944
function tlSecs(t){ if(!t) return 1e9; var p=t.split(':'); return (+p[0])*3600+(+(p[1]||0))*60+(+(p[2]||0)); }
812
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)); }
945
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)); }
813
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); }
946
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); }
814
function jstat(v,l){ return '<div class="js"><div class="jv">'+v+'</div><div class="jl">'+l+'</div></div>'; }
947
function jstat(v,l){ return '<div class="js"><div class="jv">'+v+'</div><div class="jl">'+l+'</div></div>'; }
815
function hhmm(t){ return t? t.slice(0,5):''; }
948
function hhmm(t){ return t? t.slice(0,5):''; }
816
function tlThumb(att){
949
function tlThumb(att){
817
  if(att && window._mediaUrl) return '<img class="tlthumb" src="'+esc(window._mediaUrl+att)+'" onclick="window.open(this.src,\'_blank\')" onerror="tlImgErr(this)">';
950
  if(att && window._mediaUrl) return '<img class="tlthumb" src="'+esc(window._mediaUrl+att)+'" onclick="bjOpenImage(this.src)" onerror="tlImgErr(this)">';
818
  return '<div class="tlph" title="No photo">&#128247;</div>';
951
  return '<div class="tlph" title="No photo">&#128247;</div>';
819
}
952
}
820
function tlImgErr(im){ var d=document.createElement('div'); d.className='tlph'; d.title='Photo unavailable'; d.innerHTML='&#128247;'; if(im.parentNode) im.parentNode.replaceChild(d, im); }
953
function tlImgErr(im){ var d=document.createElement('div'); d.className='tlph'; d.title='Photo unavailable'; d.innerHTML='&#128247;'; if(im.parentNode) im.parentNode.replaceChild(d, im); }
821
function punchEntry(kind, time){ return {_punch:kind, checkInTime:time, markType:(kind==='in'?'PUNCHIN':'PUNCHOUT'), taskType:'attendance'}; }
954
function punchEntry(kind, time){ return {_punch:kind, checkInTime:time, markType:(kind==='in'?'PUNCHIN':'PUNCHOUT'), taskType:'attendance'}; }
822
function tlStyle(x){
955
function tlStyle(x){
Line 829... Line 962...
829
  return {c:'#3b5bdb', l:(mt||'-'), ic:'&#9679;'};
962
  return {c:'#3b5bdb', l:(mt||'-'), ic:'&#9679;'};
830
}
963
}
831
function tlTitle(x){
964
function tlTitle(x){
832
  if(x._punch) return x._punch==='in' ? 'Journey start (Punch in)' : 'Journey end (Punch out)';
965
  if(x._punch) return x._punch==='in' ? 'Journey start (Punch in)' : 'Journey end (Punch out)';
833
  var n=x.taskName||'';
966
  var n=x.taskName||'';
-
 
967
  var taskType=(x.taskType||'').toLowerCase();
-
 
968
  if(taskType==='franchisee-visit') {
-
 
969
    var pipe = n.lastIndexOf('|');
834
  var pipe=n.lastIndexOf('|'); if(pipe>=0) n=n.slice(pipe+1);      // drop leading agenda tags
970
    if (pipe >= 0) n = n.slice(pipe + 1);      // drop leading agenda tags
835
  n=n.replace(/\s*VISIT\b.*$/i,'').replace(/\s*visit\s*$/i,'').replace(/\bnull\b/gi,'').replace(/\s{2,}/g,' ').trim();
971
    n = n.replace(/\s*VISIT\b.*$/i, '').replace(/\s*visit\s*$/i, '').replace(/\bnull\b/gi, '').replace(/\s{2,}/g, ' ').trim();
-
 
972
  }
836
  return n || '(stop)';
973
  return n || '(stop)';
837
}
974
}
838
function tlRow(x, side){
975
function tlRow(x, side){
839
  var s=tlStyle(x);
976
  var s=tlStyle(x);
840
  var tt=(x.taskType||'').toLowerCase();
977
  var tt=(x.taskType||'').toLowerCase();
Line 855... Line 992...
855
    var chips='';
992
    var chips='';
856
    if(x.battery!=null) chips += '<span class="tlchip">&#128267; '+x.battery+'%</span>';
993
    if(x.battery!=null) chips += '<span class="tlchip">&#128267; '+x.battery+'%</span>';
857
    if(x.locOff!=null) chips += '<span class="tlchip">&#128244; '+x.locOff+'</span>';
994
    if(x.locOff!=null) chips += '<span class="tlchip">&#128244; '+x.locOff+'</span>';
858
    extra = '<div class="tlextra">'+tlThumb(x.attachment)+chips+'</div>';
995
    extra = '<div class="tlextra">'+tlThumb(x.attachment)+chips+'</div>';
859
  }
996
  }
860
  return '<div class="tlrow '+side+'">'+
-
 
861
      '<div class="node" style="background:'+s.c+'">'+s.ic+'</div>'+
-
 
862
      '<div class="tlcard">'+
997
  var card='<div class="tlcard">'+
863
        '<div class="tm" style="background:'+s.c+'">'+timeTop+'<small>'+tlabel+'</small></div>'+
998
        '<div class="tm" style="background:'+s.c+'">'+timeTop+'<small>'+tlabel+'</small></div>'+
864
        '<div class="bd"><div class="ti">'+esc(tlTitle(x))+'</div>'+
999
        '<div class="bd"><div class="ti">'+esc(tlTitle(x))+'</div>'+
865
          '<span class="stt" style="background:'+s.c+'">'+s.l+'</span>'+
1000
          '<span class="stt" style="background:'+s.c+'">'+s.l+'</span>'+
866
          '<div class="mt">'+meta+'</div>'+extra+'</div>'+
1001
          '<div class="mt">'+meta+'</div>'+extra+'</div>'+
867
      '</div></div>';
1002
      '</div>';
-
 
1003
  // remark sits on the opposite half of the timeline (empty spacer when none)
-
 
1004
  var rk=(!x._punch && x.remarks) ? '<div class="rk"><div class="rkl">Remark</div>'+esc(x.remarks)+'</div>' : '';
-
 
1005
  var remark='<div class="tlremark">'+rk+'</div>';
-
 
1006
  var halves = side==='left' ? (card+remark) : (remark+card);
-
 
1007
  return '<div class="tlrow '+side+'">'+
-
 
1008
      '<div class="node" style="background:'+s.c+'">'+s.ic+'</div>'+
-
 
1009
      halves+'</div>';
868
}
1010
}
869
function closeModal(){ el('bj-modal-back').classList.remove('show'); }
1011
function closeModal(){ el('bj-modal-back').classList.remove('show'); }
-
 
1012
// Image lightbox — opens an attachment larger, stacked above the journey modal.
-
 
1013
function bjOpenImage(src){ if(!src) return; el('bj-img').src=src; el('bj-img-back').classList.add('show'); }
-
 
1014
function bjCloseImage(){ el('bj-img-back').classList.remove('show'); el('bj-img').removeAttribute('src'); }
870
 
1015
 
871
/* ============================ APPROVAL ============================ */
1016
/* ============================ APPROVAL ============================ */
872
function loadApproval(){
1017
function loadApproval(){
873
  spinner();
1018
  spinner();
874
  var from=el('bj-start').value, to=el('bj-end').value;
1019
  var from=el('bj-start').value, to=el('bj-end').value;
Line 891... Line 1036...
891
function approve(trackingId){
1036
function approve(trackingId){
892
  fetch(CTX+'/beat-report/approve-checkin',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'trackingId='+trackingId})
1037
  fetch(CTX+'/beat-report/approve-checkin',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'trackingId='+trackingId})
893
    .then(function(r){ return r.json(); }).then(function(){ var row=el('appr-'+trackingId); if(row) row.parentNode.removeChild(row); }).catch(function(){ alert('Approve failed'); });
1038
    .then(function(r){ return r.json(); }).then(function(){ var row=el('appr-'+trackingId); if(row) row.parentNode.removeChild(row); }).catch(function(){ alert('Approve failed'); });
894
}
1039
}
895
 
1040
 
-
 
1041
/* ============================ EXPORT ============================ */
-
 
1042
// Download the Employee Task Report (.xlsx). Today lens exports the single
-
 
1043
// selected day; Period lens exports the whole start..end range. Both carry the
-
 
1044
// current level / team scope (qs()). The download is served as an attachment, so
-
 
1045
// navigating to the URL triggers the file save without leaving the page.
-
 
1046
function exportReport(){
-
 
1047
  var start, end;
-
 
1048
  if(LENS==='period'){ start=el('bj-start').value; end=el('bj-end').value; }
-
 
1049
  else { start=el('bj-date').value; end=start; }
-
 
1050
  var u = BASE + '/export?startDate=' + encodeURIComponent(start) + '&endDate=' + encodeURIComponent(end) + qs();
-
 
1051
  window.location.href = u;
-
 
1052
}
-
 
1053
 
896
/* ============================ INIT ============================ */
1054
/* ============================ INIT ============================ */
897
document.querySelectorAll('.lens').forEach(function(b){ b.addEventListener('click', function(){ setLens(b.getAttribute('data-lens')); }); });
1055
document.querySelectorAll('.lens').forEach(function(b){ b.addEventListener('click', function(){ setLens(b.getAttribute('data-lens')); }); });
898
el('bj-apply').addEventListener('click', load);
1056
el('bj-apply').addEventListener('click', load);
-
 
1057
el('bj-export').addEventListener('click', exportReport);
899
el('bj-level').addEventListener('change', onLevelChange);
1058
el('bj-level').addEventListener('change', onLevelChange);
900
if(el('bj-user')) el('bj-user').addEventListener('change', load);
1059
if(el('bj-user')) el('bj-user').addEventListener('change', load);
901
el('bj-date').addEventListener('change', updateTodayLabel);
1060
el('bj-date').addEventListener('change', updateTodayLabel);
902
el('bj-modal-back').addEventListener('click', function(e){ if(e.target===el('bj-modal-back')) closeModal(); });
1061
el('bj-modal-back').addEventListener('click', function(e){ if(e.target===el('bj-modal-back')) closeModal(); });
-
 
1062
el('bj-img-back').addEventListener('click', function(e){ if(e.target===el('bj-img-back')) bjCloseImage(); });
-
 
1063
el('bj-img-x').addEventListener('click', bjCloseImage);
-
 
1064
// Esc closes the lightbox first (if open), otherwise the journey modal.
-
 
1065
document.addEventListener('keydown', function(e){
-
 
1066
  if(e.key!=='Escape') return;
-
 
1067
  if(el('bj-img-back').classList.contains('show')) bjCloseImage();
-
 
1068
  else if(el('bj-modal-back').classList.contains('show')) closeModal();
-
 
1069
});
903
updateTodayLabel();
1070
updateTodayLabel();
904
load();
1071
load();
905
</script>
1072
</script>
906
</body>
1073
</body>
907
</html>
1074
</html>