Subversion Repositories SmartDukaan

Rev

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

Rev 36917 Rev 36929
Line 32... Line 32...
32
  .lenses{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:20px}
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}
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)}
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}
35
  .lens .b{display:inline-block;background:#eef1fb;color:var(--brand);font-size:11px;font-weight:700;border-radius:999px;padding:1px 7px;margin-left:6px}
36
  .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
36
  .kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
37
  .kpi{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 15px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
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}
38
  .kpi.clickable{cursor:pointer;transition:border-color .12s,box-shadow .12s,transform .12s}
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)}
39
  .kpi.clickable:hover{border-color:#c7d0e8;box-shadow:0 3px 10px rgba(20,30,60,.09);transform:translateY(-1px)}
40
  .kpi .top{display:flex;align-items:center;gap:8px}
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}
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}
42
  .kpi .label{font-size:11.5px;color:var(--muted);font-weight:600;line-height:1.2}
43
  .kpi .val{font-size:26px;font-weight:760;margin-top:9px;letter-spacing:-.02em;line-height:1}
43
  .kpi .val{font-size:26px;font-weight:760;margin-top:auto;padding-top:9px;letter-spacing:-.02em;line-height:1}
44
  .kpi .val small{font-size:13px;color:var(--soft);font-weight:600}
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}
45
  .kpi .foot{font-size:11.5px;color:var(--soft);margin-top:7px}
-
 
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}
46
  .grid{display:grid;gap:16px}
53
  .grid{display:grid;gap:16px}
47
  .g2{grid-template-columns:1fr 1fr}
54
  .g2{grid-template-columns:1fr 1fr}
48
  .g3{grid-template-columns:1.55fr 1fr}
55
  .g3{grid-template-columns:1.55fr 1fr}
49
  .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)}
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)}
50
  .card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}
57
  .card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}
Line 63... Line 70...
63
  .fstep .fv{font-size:26px;font-weight:770;margin-top:6px}
70
  .fstep .fv{font-size:26px;font-weight:770;margin-top:6px}
64
  .fstep .fbar{height:5px;border-radius:3px;margin-top:10px;background:linear-gradient(90deg,var(--brand),var(--brand2))}
71
  .fstep .fbar{height:5px;border-radius:3px;margin-top:10px;background:linear-gradient(90deg,var(--brand),var(--brand2))}
65
  .fstep .drop{font-size:11px;color:var(--red);margin-top:7px;font-weight:600}
72
  .fstep .drop{font-size:11px;color:var(--red);margin-top:7px;font-weight:600}
66
  .fstep .keep{font-size:11px;color:var(--good);margin-top:7px;font-weight:600}
73
  .fstep .keep{font-size:11px;color:var(--good);margin-top:7px;font-weight:600}
67
  .fstep .arrow{position:absolute;right:-12px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:15px}
74
  .fstep .arrow{position:absolute;right:-12px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:15px}
-
 
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}
68
  .leak{display:flex;gap:10px;margin-top:12px}
82
  .leak{display:flex;gap:10px;margin-top:12px}
69
  .leakbox{flex:1;border:1px solid var(--line2);border-radius:10px;padding:10px 12px;background:#f8f9fc}
83
  .leakbox{flex:1;border:1px solid var(--line2);border-radius:10px;padding:10px 12px;background:#f8f9fc}
70
  .leakbox .lt{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
84
  .leakbox .lt{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
71
  .leakbox .lv{font-size:20px;font-weight:760;margin-top:4px}
85
  .leakbox .lv{font-size:20px;font-weight:760;margin-top:4px}
72
  table{width:100%;border-collapse:collapse;font-size:13px}
86
  table{width:100%;border-collapse:collapse;font-size:13px}
Line 339... Line 353...
339
    .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
353
    .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
340
    .then(renderToday).catch(fail);
354
    .then(renderToday).catch(fail);
341
}
355
}
342
function kpi(ic, bg, fg, label, val, foot, valColor, click){
356
function kpi(ic, bg, fg, label, val, foot, valColor, click){
343
  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>'+
357
  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>'+
-
 
358
    '<div class="label">'+label+'</div></div>'+
-
 
359
    '<div class="foot">'+foot+'</div>'+
344
    '<div class="label">'+label+'</div></div><div class="val"'+(valColor?' style="color:'+valColor+'"':'')+'>'+val+'</div>'+
360
    '<div class="val"'+(valColor?' style="color:'+valColor+'"':'')+'>'+val+'</div></div>';
-
 
361
}
-
 
362
// Multi-column breakdown card: centered title, a row of sub-labels, the number under each.
-
 
363
// cols = [[label, value], ...]; unit (optional) is appended after each number, e.g. 'min'.
-
 
364
function kpiCols(ic, bg, fg, title, cols, fg2, click, unit){
-
 
365
  var lab='', val='', u=unit?' <small>'+unit+'</small>':'';
-
 
366
  for(var i=0;i<cols.length;i++){
-
 
367
    var sep=i?'<span class="bsep">&middot;</span>':'';
-
 
368
    lab+=sep+'<div class="blab">'+cols[i][0]+'</div>';
-
 
369
    val+=(i?'<span class="bsep">&nbsp;</span>':'')+'<div class="bval"'+(fg2?' style="color:'+fg2+'"':'')+'>'+num(cols[i][1])+u+'</div>';
-
 
370
  }
-
 
371
  return '<div class="kpi kpicols'+(click?' clickable':'')+'"'+(click?' onclick="'+click+'" title="Click for details"':'')+'>'+
-
 
372
    '<div class="top"><div class="ic" style="background:'+bg+';color:'+fg+'">'+ic+'</div><div class="label">'+title+'</div></div>'+
-
 
373
    '<div class="brow blabs">'+lab+'</div>'+
345
    '<div class="foot">'+foot+'</div></div>';
374
    '<div class="brow bvals">'+val+'</div></div>';
-
 
375
}
-
 
376
// Period journey summary as a row of cards (old funnel layout). Each card is a
-
 
377
// group (Total Visits / Avg Time Spent / Idle-Break / Deferred / Completed) split
-
 
378
// into Planned vs Self Assigned columns. col=[[label,value,colorClass],...].
-
 
379
function summaryCard(title, cols, last){
-
 
380
  var body='';
-
 
381
  for(var i=0;i<cols.length;i++){
-
 
382
    body+='<div class="scol"><div class="sl">'+cols[i][0]+'</div>'+
-
 
383
          '<div class="sv'+(cols[i][2]?' '+cols[i][2]:'')+'">'+cols[i][1]+'</div></div>';
-
 
384
  }
-
 
385
  return '<div class="fstep"><div class="fn">'+title+'</div><div class="ssplit">'+body+'</div>'+
-
 
386
    (last?'':'<span class="arrow">&#10148;</span>')+'</div>';
-
 
387
}
-
 
388
function summaryCards(s){
-
 
389
  if(!s) return '';
-
 
390
  var mn=function(v){return num(v)+'<span class="u">min</span>';};
-
 
391
  var dpC=s.deferredPlanned>0?'bad':'', dsC=s.deferredSelf>0?'bad':'';
-
 
392
  return summaryCard('Total Visits',[['Planned',num(s.plannedVisits),''],['Self Assigned',num(s.selfVisits),'']])+
-
 
393
    summaryCard('Average Time Spent',[['Planned',mn(s.avgTimePlannedMin),''],['Self Assigned',mn(s.avgTimeSelfMin),'']])+
-
 
394
    summaryCard('Idle / Break',[['Total Average',mn(s.idleAvgMin),'']])+
-
 
395
    summaryCard('Completed Visits',[['Planned',num(s.completedPlanned),'good'],['Self Assigned',num(s.completedSelf),'good']])+
-
 
396
    summaryCard('Deferred',[['Planned',num(s.deferredPlanned),dpC],['Self Assigned',num(s.deferredSelf),dsC]],true);
346
}
397
}
347
function fstep(name, val, w, sub, drop, last, key){
398
function fstep(name, val, w, sub, drop, last, key){
348
  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>'+
399
  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>'+
349
    '<div class="fbar" style="width:'+Math.min(w,100)+'%"></div><div class="'+(drop?'drop':'keep')+'">'+sub+'</div>'+
400
    '<div class="fbar" style="width:'+Math.min(w,100)+'%"></div><div class="'+(drop?'drop':'keep')+'">'+sub+'</div>'+
350
    (last?'':'<span class="arrow">&#10148;</span>')+'</div>';
401
    (last?'':'<span class="arrow">&#10148;</span>')+'</div>';
Line 489... Line 540...
489
// Funnel drill-down: each stage shows the executives who reached it AND the
540
// Funnel drill-down: each stage shows the executives who reached it AND the
490
// drop-off cohort that fell out at this step (the "−N …" sub-line). Filters
541
// drop-off cohort that fell out at this step (the "−N …" sub-line). Filters
491
// mirror the backend funnel conditions exactly (rows carry the per-stage flags).
542
// mirror the backend funnel conditions exactly (rows carry the per-stage flags).
492
var FUNNEL_META = {
543
var FUNNEL_META = {
493
  punchedIn:  {title:'Punched In',   desc:'Started the day (punch-in recorded)', f:function(r){ return r.punchedIn; }},
544
  punchedIn:  {title:'Punched In',   desc:'Started the day (punch-in recorded)', f:function(r){ return r.punchedIn; }},
494
  travelling: {title:'Traveling',    desc:'On the road between stops',           f:function(r){ return r.state==='TRAVELLING'; }},
545
  travelling: {title:'Traveling',    desc:'On the road between stops',           f:function(r){ return r.state==='TRAVELLING' && !r.idleAway; }},
495
  inStore:    {title:'In Store',     desc:'Currently checked in at a stop',      f:function(r){ return r.state==='IN_STORE'; }},
546
  inStore:    {title:'In Store',     desc:'Currently checked in at a stop',      f:function(r){ return r.state==='IN_STORE'; }},
496
  idle:       {title:'Idle / Break', desc:'Idle off-store beyond the threshold', f:function(r){ return r.idleAway; }},
547
  idle:       {title:'Idle / Break', desc:'Between stops, idle off-store beyond the threshold', f:function(r){ return r.state==='TRAVELLING' && r.idleAway; }},
497
  punchedOut: {title:'Punched Out',  desc:'Ended the day (punch-out recorded)',  f:function(r){ return r.punchedOut; }}
548
  punchedOut: {title:'Punched Out',  desc:'Ended the day (punch-out recorded)',  f:function(r){ return r.punchedOut; }}
498
};
549
};
499
function funnelRows(list, date){
550
function funnelRows(list, date){
500
  if(!list.length) return '<p class="desc">None.</p>';
551
  if(!list.length) return '<p class="desc">None.</p>';
501
  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>';
552
  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>';
Line 692... Line 743...
692
    });
743
    });
693
    h+='</tbody></table>';
744
    h+='</tbody></table>';
694
  }
745
  }
695
  pModal(h);
746
  pModal(h);
696
}
747
}
-
 
748
// Auto "missed" drill — the three execution-gap causes behind the card, plus
-
 
749
// the underlying deferral records (system-flagged "beat missed" / "no punch-in").
-
 
750
function openMissed(){
-
 
751
  var k=(window._period||{}).kpis||{};
-
 
752
  var rows=[
-
 
753
    ['Unscheduled', k.autoMissedUnscheduled||0, 'Executives (sales roles) with no PJP scheduled in this range'],
-
 
754
    ['Unfilled agenda', k.autoMissedUnfilledAgenda||0, 'Scheduled beat-days where the agenda was never filled'],
-
 
755
    ['Missed punch', k.autoMissedMissedPunchin||0, 'Scheduled beat-days the owner never punched in on']
-
 
756
  ];
-
 
757
  var h='<button class="x" onclick="closeModal()">&times;</button><h3>Auto &ldquo;missed&rdquo;</h3>'+
-
 
758
        '<p class="desc">Why planned execution broke down &middot; '+esc(pRange())+'</p>'+
-
 
759
        '<table><thead><tr><th>Cause</th><th class="num">Count</th><th>What it means</th></tr></thead><tbody>';
-
 
760
  rows.forEach(function(r){
-
 
761
    h+='<tr><td style="font-weight:600;white-space:nowrap">'+esc(r[0])+'</td>'+
-
 
762
       '<td class="num cnt cred">'+num(r[1])+'</td>'+
-
 
763
       '<td style="color:#69748c">'+esc(r[2])+'</td></tr>';
-
 
764
  });
-
 
765
  h+='<tr class="boardtot"><td><b>Total</b></td><td class="num cnt"><b>'+num(k.autoMissed||0)+'</b></td><td></td></tr>';
-
 
766
  h+='</tbody></table>'+
-
 
767
     '<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 &ldquo;beat missed&rdquo; / &ldquo;no punch-in&rdquo; entries.</p>';
-
 
768
  pModal(h);
-
 
769
}
697
// Leads drill (period) — every lead created in the range.
770
// Leads drill (period) — every lead created in the range.
698
function openPLeads(){
771
function openPLeads(){
699
  var list=((window._period||{}).leadsList)||[];
772
  var list=((window._period||{}).leadsList)||[];
700
  var h='<button class="x" onclick="closeModal()">&times;</button><h3>Leads on route</h3>'+
773
  var h='<button class="x" onclick="closeModal()">&times;</button><h3>Leads on route</h3>'+
701
        '<p class="desc">'+list.length+' lead'+(list.length===1?'':'s')+' created &middot; '+esc(pRange())+'</p>';
774
        '<p class="desc">'+list.length+' lead'+(list.length===1?'':'s')+' created &middot; '+esc(pRange())+'</p>';
Line 796... Line 869...
796
  window._period = d;          // drill-downs read scorecard / lists from here
869
  window._period = d;          // drill-downs read scorecard / lists from here
797
  var k=d.kpis, f=d.funnel, df=d.deferral, u=d.utilization, t=d.travel, o=d.outcomes;
870
  var k=d.kpis, f=d.funnel, df=d.deferral, u=d.utilization, t=d.travel, o=d.outcomes;
798
  var neverTouched = Math.max((f.plannedStops||0)-(f.checkedIn||0)-(df.total||0),0);
871
  var neverTouched = Math.max((f.plannedStops||0)-(f.checkedIn||0)-(df.total||0),0);
799
  var h='';
872
  var h='';
800
  h+='<div class="kpis">';
873
  h+='<div class="kpis">';
801
  h+=kpi('&#9878;','#fff4e6','#f08c00','Adherence',k.adherencePct+'<small>%</small>',num(k.doneStops)+' of '+num(k.plannedStops)+' stops done',adhColor(k.adherencePct),"openPScore('adherencePct','Adherence','Planned vs done per executive')");
874
  h+=kpi('&#9878;','#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')");
802
  h+=kpi('&#128506;','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad',"openCoverage('L1')");
875
  h+=kpi('&#128506;','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad',"openCoverage('L1')");
-
 
876
  h+=kpiCols('&#128694;','#ebfbee','#2f9e44','Average Visit (per day)',
-
 
877
    [['Franchisee',k.avgPartnersPerActiveDay],['Planned lead',k.avgPlannedLeadsPerActiveDay],['Unplanned lead',k.avgUnplannedLeadsPerActiveDay]],
803
  h+=kpi('&#128694;','#ebfbee','#2f9e44','Visits / active day',k.avgVisitsPerActiveDay,'across '+num(k.journeys)+' journeys',null,"openPScore('done','Visits / active day','Visits completed per executive')");
878
    null,"openPScore('done','Visits / active day','Visits completed per executive')");
-
 
879
  h+=kpiCols('&#128172;','#eef1fb','#3b5bdb','Average discussion (per executive)',
-
 
880
    [['Partner',k.avgDiscussionPartnerMin],['Lead',k.avgDiscussionLeadMin]],
804
  h+=kpi('&#128172;','#eef1fb','#3b5bdb','Discussion / store',k.avgDiscussionMin+' <small>min</small>','target 30&ndash;45 min', k.avgDiscussionMin>0&&k.avgDiscussionMin<30?'#e8590c':null,"openPScore('discussionMin','Discussion / store','Avg in-store discussion per executive')");
881
    k.avgDiscussionMin>0&&k.avgDiscussionMin<30?'#e8590c':null,"openPScore('discussionMin','Average discussion minutes','Avg in-store discussion per executive')",'min');
-
 
882
  h+=kpiCols('&#8635;','#fff0f0','#e03131','Auto "missed"',
805
  h+=kpi('&#8635;','#fff0f0','#e03131','Auto "missed"',num(k.autoMissed),k.autoMissedSystemPct+'% system-flagged','#e03131',"openDeferrals()");
883
    [['Unscheduled',k.autoMissedUnscheduled],['Unfilled agenda',k.autoMissedUnfilledAgenda],['Missed punch',k.autoMissedMissedPunchin]],
-
 
884
    '#e03131',"openMissed()");
806
  h+=kpi('&#9733;','#fff0f6','#d6336c','Leads on route',num(k.leadsCreated),'this period',null,"openPLeads()");
885
  h+=kpi('&#9733;','#fff0f6','#d6336c','Leads on route',num(k.leadsCreated),'this period',null,"openPLeads()");
807
  h+='</div>';
886
  h+='</div>';
808
 
887
 
809
  h+='<div class="sectionlabel">Aggregate Journey Funnel</div><div class="card"><div class="funnel">';
888
  h+='<div class="sectionlabel">Journey Summary</div><div class="card"><p class="desc">Planned = visits on the PJP &middot; Self Assigned = ad-hoc visits the executive added.</p><div class="funnel">'+summaryCards(d.summary)+'</div></div>';
810
  var pl=f.plannedStops||1;
-
 
811
  h+=fstep('Planned stops',f.plannedStops,100,'scheduled',false,false,'p_planned');
-
 
812
  h+=fstep('Planned on punch-in days',f.punchedIn,pct(f.punchedIn,pl),fdrop(f.dropPunch,'no punch-in'),f.dropPunch>0,false,'p_punchedin');
-
 
813
  h+=fstep('Checked in',f.checkedIn,pct(f.checkedIn,pl),fdrop(f.dropCheckin,'reached, skipped'),f.dropCheckin>0,false,'p_checkedin');
-
 
814
  h+=fstep('Checked out (done)',f.checkedOut,pct(f.checkedOut,pl),fdrop(f.dropCheckout,'no checkout'),f.dropCheckout>0,true,'p_checkedout');
-
 
815
  h+='</div></div>';
-
 
816
 
889
 
817
  h+='<div class="sectionlabel">Adherence &amp; Coverage</div><div class="grid g3">';
890
  h+='<div class="sectionlabel">Adherence &amp; Coverage</div><div class="grid g3">';
818
  h+='<div class="card"><h2>Per-executive scorecard <span style="color:#97a1b5;font-weight:500;font-size:12px">&middot; '+d.scorecard.length+' executives</span></h2><p class="desc">The coaching list &mdash; click a header to sort. Reds are where to intervene.</p>'+scorecardTable(d.scorecard)+'</div>';
891
  h+='<div class="card"><h2>Per-executive scorecard <span style="color:#97a1b5;font-weight:500;font-size:12px">&middot; '+d.scorecard.length+' executives</span></h2><p class="desc">The coaching list &mdash; click a header to sort. Reds are where to intervene.</p>'+scorecardTable(d.scorecard)+'</div>';
819
  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>';
892
  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>';
820
  h+='</div>';
893
  h+='</div>';
Line 1137... Line 1210...
1137
  (data.visits||[]).slice()
1210
  (data.visits||[]).slice()
1138
    .filter(function(v){ return v.lat && v.lng; })
1211
    .filter(function(v){ return v.lat && v.lng; })
1139
    .sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); })
1212
    .sort(function(a,b){ return tlSecs(a.checkInTime)-tlSecs(b.checkInTime); })
1140
    .forEach(function(v){ cps.push({lat:v.lat,lng:v.lng}); });
1213
    .forEach(function(v){ cps.push({lat:v.lat,lng:v.lng}); });
1141
  if(data.punchOut) cps.push({lat:data.punchOut.lat,lng:data.punchOut.lng});
1214
  if(data.punchOut) cps.push({lat:data.punchOut.lat,lng:data.punchOut.lng});
-
 
1215
  // Actual time window of the GPS trail (first → last breadcrumb timestamp),
-
 
1216
  // shown on the taken-route click detail alongside the driving estimate.
-
 
1217
  var takenTime='';
-
 
1218
  var dp=data.path||[];
-
 
1219
  if(dp.length>=2 && dp[0].ts && dp[dp.length-1].ts && dp[dp.length-1].ts>dp[0].ts){
-
 
1220
    var t0=dp[0].ts, t1=dp[dp.length-1].ts;
-
 
1221
    takenTime=bjClock(t0)+' &rarr; '+bjClock(t1)+' ('+bjElapsed(t1-t0)+')';
-
 
1222
  }
1142
  bjDrawRoutes(map, cps, pts);
1223
  bjDrawRoutes(map, cps, pts, takenTime, dp);
1143
 
1224
 
1144
  if(has){
1225
  if(has){
1145
    map.fitBounds(bounds);
1226
    map.fitBounds(bounds);
1146
    google.maps.event.addListenerOnce(map,'idle',function(){ if(map.getZoom()>16) map.setZoom(16); });
1227
    google.maps.event.addListenerOnce(map,'idle',function(){ if(map.getZoom()>16) map.setZoom(16); });
1147
  } else {
1228
  } else {
Line 1150... Line 1231...
1150
}
1231
}
1151
// TAKEN route = the recorded GPS trail (location_tracking_path) routed along the
1232
// TAKEN route = the recorded GPS trail (location_tracking_path) routed along the
1152
// road network so it follows actual roads rather than straight hops between sparse
1233
// road network so it follows actual roads rather than straight hops between sparse
1153
// pings. SUGGESTED route = the Google-optimal road route through the journey's
1234
// pings. SUGGESTED route = the Google-optimal road route through the journey's
1154
// checkpoints (green dotted). Both are drawn and compared by distance.
1235
// checkpoints (green dotted). Both are drawn and compared by distance.
1155
function bjDrawRoutes(map, cps, pathPts){
1236
function bjDrawRoutes(map, cps, pathPts, takenTime, timedPts){
1156
  var legend=bjRouteLegend();
1237
  var legend=bjRouteLegend();
1157
 
1238
 
1158
  // taken — road route traced through the recorded breadcrumb (in order)
1239
  // taken — road route traced through the recorded breadcrumb (in order)
1159
  if(pathPts && pathPts.length>=2){
1240
  if(pathPts && pathPts.length>=2){
1160
    var ds1=new google.maps.DirectionsService();
1241
    var ds1=new google.maps.DirectionsService();
1161
    var sample=bjSample(pathPts, 25);                          // Directions cap: 25 incl. ends
1242
    var sample=bjSample(pathPts, 25);                          // Directions cap: 25 incl. ends
1162
    var o=sample[0], d=sample[sample.length-1];
1243
    var o=sample[0], d=sample[sample.length-1];
1163
    var vias=sample.slice(1,-1).map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:false}; });
1244
    var vias=sample.slice(1,-1).map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:false}; });
1164
    bjRouteRequest(ds1, o, d, vias, false, function(res){
1245
    bjRouteRequest(ds1, o, d, vias, false, function(res){
1165
      bjRenderRoute(map, res, {color:'#e03131', dashed:false, z:55});
1246
      bjRenderRoute(map, res, {color:'#e03131', dashed:false, z:55, label:'Route taken (actual GPS)', time:takenTime, timed:timedPts});
1166
      legend.set('taken', bjRouteKm(res));
1247
      legend.set('taken', bjRouteKm(res));
1167
    }, function(status){
1248
    }, function(status){
1168
      console.warn('Directions (taken) failed:', status);
1249
      console.warn('Directions (taken) failed:', status);
1169
      // fallback: plot the raw breadcrumb (straight hops) and measure along it
1250
      // fallback: plot the raw breadcrumb (straight hops), with arrows, and measure along it
1170
      new google.maps.Polyline({path:pathPts,geodesic:false,strokeColor:'#e03131',strokeOpacity:.9,strokeWeight:4,zIndex:55,map:map});
1251
      var poly=new google.maps.Polyline({path:pathPts,geodesic:false,strokeColor:'#e03131',strokeOpacity:.9,strokeWeight:4,zIndex:55,map:map,
-
 
1252
        icons:[{icon:{path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW,strokeColor:'#e03131',fillColor:'#e03131',fillOpacity:1,scale:3},offset:'3%',repeat:'120px'}]});
1171
      var ll=pathPts.map(function(p){ return new google.maps.LatLng(p.lat,p.lng); });
1253
      var km=google.maps.geometry.spherical.computeLength(pathPts.map(function(p){ return new google.maps.LatLng(p.lat,p.lng); }))/1000;
1172
      legend.set('taken', google.maps.geometry.spherical.computeLength(ll)/1000);
1254
      bjRouteClick(map, poly, '#e03131', 'Route taken (actual GPS)', km, '', takenTime, timedPts);
-
 
1255
      legend.set('taken', km);
1173
    });
1256
    });
1174
  } else {
1257
  } else {
1175
    legend.set('taken', false);
1258
    legend.set('taken', false);
1176
  }
1259
  }
1177
 
1260
 
Line 1180... Line 1263...
1180
    var ds=new google.maps.DirectionsService();
1263
    var ds=new google.maps.DirectionsService();
1181
    var origin=cps[0], dest=cps[cps.length-1], mids=cps.slice(1,-1), capped=false;
1264
    var origin=cps[0], dest=cps[cps.length-1], mids=cps.slice(1,-1), capped=false;
1182
    if(mids.length>23){ mids=mids.slice(0,23); capped=true; }  // Directions allows 23 waypoints
1265
    if(mids.length>23){ mids=mids.slice(0,23); capped=true; }  // Directions allows 23 waypoints
1183
    var wpts=mids.map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:true}; });
1266
    var wpts=mids.map(function(p){ return {location:new google.maps.LatLng(p.lat,p.lng), stopover:true}; });
1184
    bjRouteRequest(ds, origin, dest, wpts, true, function(res){
1267
    bjRouteRequest(ds, origin, dest, wpts, true, function(res){
1185
      bjRenderRoute(map, res, {color:'#2f9e44', dashed:true, z:50});
1268
      bjRenderRoute(map, res, {color:'#2f9e44', dashed:true, z:50, label:'Suggested route (optimal)'});
1186
      legend.set('suggested', bjRouteKm(res));
1269
      legend.set('suggested', bjRouteKm(res));
1187
    }, function(status){ console.warn('Directions (suggested) failed:', status); legend.set('suggested', false); });
1270
    }, function(status){ console.warn('Directions (suggested) failed:', status); legend.set('suggested', false); });
1188
    if(capped) legend.note('Suggested route limited to first 23 stops');
1271
    if(capped) legend.note('Suggested route limited to first 23 stops');
1189
  } else {
1272
  } else {
1190
    legend.set('suggested', false);
1273
    legend.set('suggested', false);
Line 1206... Line 1289...
1206
  }, function(res, status){
1289
  }, function(res, status){
1207
    if(status===google.maps.DirectionsStatus.OK && res && res.routes && res.routes.length) ok(res);
1290
    if(status===google.maps.DirectionsStatus.OK && res && res.routes && res.routes.length) ok(res);
1208
    else err(status);
1291
    else err(status);
1209
  });
1292
  });
1210
}
1293
}
-
 
1294
// Draw a route as a custom polyline (so we can add direction arrows + a click
-
 
1295
// info window). Forward arrows are repeated along the line; dashed routes also
-
 
1296
// get the dash pattern. Clicking the line opens its distance / duration detail.
1211
function bjRenderRoute(map, res, opt){
1297
function bjRenderRoute(map, res, opt){
-
 
1298
  var path=res.routes[0].overview_path;
-
 
1299
  var arrow={icon:{path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW, strokeColor:opt.color,
1212
  var poly={strokeColor:opt.color, strokeWeight:opt.dashed?0:5, strokeOpacity:opt.dashed?0:0.85, zIndex:opt.z};
1300
                   strokeOpacity:1, strokeWeight:1, fillColor:opt.color, fillOpacity:1, scale:opt.dashed?2.6:3.2},
-
 
1301
             offset:'3%', repeat:opt.dashed?'95px':'120px'};
-
 
1302
  var icons=[arrow];
1213
  if(opt.dashed){
1303
  if(opt.dashed){
1214
    poly.icons=[{icon:{path:'M 0,-1 0,1', strokeColor:opt.color, strokeOpacity:1, strokeWeight:4, scale:3}, offset:'0', repeat:'14px'}];
1304
    icons.unshift({icon:{path:'M 0,-1 0,1', strokeColor:opt.color, strokeOpacity:1, strokeWeight:4, scale:3}, offset:'0', repeat:'14px'});
-
 
1305
  }
-
 
1306
  var poly=new google.maps.Polyline({path:path, map:map, zIndex:opt.z,
-
 
1307
    strokeColor:opt.color, strokeOpacity:opt.dashed?0:0.85, strokeWeight:opt.dashed?0:5, icons:icons});
-
 
1308
  bjRouteClick(map, poly, opt.color, opt.label||(opt.dashed?'Suggested route':'Route taken'),
-
 
1309
    bjRouteKm(res), bjRouteDur(res), opt.time, opt.timed);
-
 
1310
}
-
 
1311
// Attach a click handler that opens an info window with the route's details at
-
 
1312
// the clicked point. Reused by both the routed line and the breadcrumb fallback.
-
 
1313
// `time` (optional) is the actual GPS time window, e.g. "09:28 → 16:03 (6h 35m)".
-
 
1314
// `timed` (optional) are the raw breadcrumbs with timestamps; when present, the
-
 
1315
// captured time at the nearest breadcrumb to the click is shown.
-
 
1316
function bjRouteClick(map, poly, color, label, km, dur, time, timed){
-
 
1317
  var iw=new google.maps.InfoWindow();
-
 
1318
  poly.addListener('click', function(e){
-
 
1319
    var here='';
-
 
1320
    if(timed && timed.length){
-
 
1321
      var np=bjNearestPt(timed, e.latLng);
-
 
1322
      if(np && np.ts) here='&#128205; At this point: <b>'+bjClockS(np.ts)+'</b><br>';
-
 
1323
    }
-
 
1324
    iw.setContent('<div style="font-size:12px;line-height:1.5"><b style="color:'+color+'">&#10148; '+esc(label)+'</b><br>'+
-
 
1325
      here+(time?'Time: <b>'+time+'</b><br>':'')+
-
 
1326
      'Distance: <b>'+km.toFixed(1)+' km</b>'+(dur?'<br>Est. drive: <b>'+esc(dur)+'</b>':'')+'</div>');
-
 
1327
    iw.setPosition(e.latLng); iw.open(map);
-
 
1328
  });
-
 
1329
}
-
 
1330
// Nearest timestamped breadcrumb to a clicked LatLng (great-circle distance).
-
 
1331
function bjNearestPt(timed, latLng){
-
 
1332
  var best=null, bd=Infinity;
-
 
1333
  for(var i=0;i<timed.length;i++){
-
 
1334
    var d=google.maps.geometry.spherical.computeDistanceBetween(latLng, new google.maps.LatLng(timed[i].lat, timed[i].lng));
-
 
1335
    if(d<bd){ bd=d; best=timed[i]; }
1215
  }
1336
  }
-
 
1337
  return best;
-
 
1338
}
-
 
1339
// Format an epoch-millis timestamp as a local HH:MM clock, and a millisecond
-
 
1340
// span as a compact "Nh Nm" / "Nm" elapsed string (for the GPS time window).
-
 
1341
function bjClock(ts){
-
 
1342
  var d=new Date(ts), h=d.getHours(), m=d.getMinutes();
-
 
1343
  return (h<10?'0':'')+h+':'+(m<10?'0':'')+m;
-
 
1344
}
-
 
1345
function bjClockS(ts){
1216
  new google.maps.DirectionsRenderer({map:map, directions:res, suppressMarkers:true,
1346
  var d=new Date(ts), h=d.getHours(), m=d.getMinutes(), s=d.getSeconds();
-
 
1347
  return (h<10?'0':'')+h+':'+(m<10?'0':'')+m+':'+(s<10?'0':'')+s;
-
 
1348
}
-
 
1349
function bjElapsed(ms){
1217
    suppressInfoWindows:true, preserveViewport:true, polylineOptions:poly});
1350
  var s=Math.round(ms/1000), h=Math.floor(s/3600), m=Math.round((s%3600)/60);
-
 
1351
  return h ? (h+'h '+m+'m') : (m+'m');
1218
}
1352
}
1219
function bjRouteKm(res){
1353
function bjRouteKm(res){
1220
  var m=0; (res.routes[0].legs||[]).forEach(function(l){ m+=(l.distance?l.distance.value:0); }); return m/1000;
1354
  var m=0; (res.routes[0].legs||[]).forEach(function(l){ m+=(l.distance?l.distance.value:0); }); return m/1000;
1221
}
1355
}
-
 
1356
function bjRouteDur(res){
-
 
1357
  var s=0; (res.routes[0].legs||[]).forEach(function(l){ s+=(l.duration?l.duration.value:0); });
-
 
1358
  if(!s) return '';
-
 
1359
  var h=Math.floor(s/3600), m=Math.round((s%3600)/60);
-
 
1360
  return h ? (h+'h '+m+'m') : (m+'m');
-
 
1361
}
1222
// Route-comparison panel rendered into the modal (#bjRouteLegend), above the map.
1362
// Route-comparison panel rendered into the modal (#bjRouteLegend), above the map.
1223
// Each distance fills in as its route resolves. value: number = km, false =
1363
// Each distance fills in as its route resolves. value: number = km, false =
1224
// unavailable, undefined = still loading.
1364
// unavailable, undefined = still loading.
1225
function bjRouteLegend(){
1365
function bjRouteLegend(){
1226
  var div=el('bjRouteLegend');
1366
  var div=el('bjRouteLegend');