| 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">·</span>':'';
|
| - |
|
368 |
lab+=sep+'<div class="blab">'+cols[i][0]+'</div>';
|
| - |
|
369 |
val+=(i?'<span class="bsep"> </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">➤</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">➤</span>')+'</div>';
|
401 |
(last?'':'<span class="arrow">➤</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()">×</button><h3>Auto “missed”</h3>'+
|
| - |
|
758 |
'<p class="desc">Why planned execution broke down · '+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 “beat missed” / “no punch-in” 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()">×</button><h3>Leads on route</h3>'+
|
773 |
var h='<button class="x" onclick="closeModal()">×</button><h3>Leads on route</h3>'+
|
| 701 |
'<p class="desc">'+list.length+' lead'+(list.length===1?'':'s')+' created · '+esc(pRange())+'</p>';
|
774 |
'<p class="desc">'+list.length+' lead'+(list.length===1?'':'s')+' created · '+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('⚖','#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('⚖','#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('🗺','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad',"openCoverage('L1')");
|
875 |
h+=kpi('🗺','#e7f5ff','#1098ad','PJP coverage L1',k.coverageL1Pct+'<small>%</small>',k.coverageL1With+'/'+k.coverageL1Total+' have a plan','#1098ad',"openCoverage('L1')");
|
| - |
|
876 |
h+=kpiCols('🚶','#ebfbee','#2f9e44','Average Visit (per day)',
|
| - |
|
877 |
[['Franchisee',k.avgPartnersPerActiveDay],['Planned lead',k.avgPlannedLeadsPerActiveDay],['Unplanned lead',k.avgUnplannedLeadsPerActiveDay]],
|
| 803 |
h+=kpi('🚶','#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('💬','#eef1fb','#3b5bdb','Average discussion (per executive)',
|
| - |
|
880 |
[['Partner',k.avgDiscussionPartnerMin],['Lead',k.avgDiscussionLeadMin]],
|
| 804 |
h+=kpi('💬','#eef1fb','#3b5bdb','Discussion / store',k.avgDiscussionMin+' <small>min</small>','target 30–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('↻','#fff0f0','#e03131','Auto "missed"',
|
| 805 |
h+=kpi('↻','#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('★','#fff0f6','#d6336c','Leads on route',num(k.leadsCreated),'this period',null,"openPLeads()");
|
885 |
h+=kpi('★','#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 · 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 & Coverage</div><div class="grid g3">';
|
890 |
h+='<div class="sectionlabel">Adherence & 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">· '+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>';
|
891 |
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>';
|
| 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)+' → '+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='📍 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+'">➤ '+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');
|