| Line 158... |
Line 158... |
| 158 |
|
158 |
|
| 159 |
.hide{display:none !important}
|
159 |
.hide{display:none !important}
|
| 160 |
/* Liveness reads better as a ticking clock than as a boxed date field: the board is always
|
160 |
/* Liveness reads better as a ticking clock than as a boxed date field: the board is always
|
| 161 |
today, and a timestamp that never visibly moves looks stale rather than live. */
|
161 |
today, and a timestamp that never visibly moves looks stale rather than live. */
|
| 162 |
.liveclock{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--soft);font-weight:600}
|
162 |
.liveclock{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--soft);font-weight:600}
|
| - |
|
163 |
/* A readout, not a control: the dates are picked on the Command Center filter bar. */
|
| - |
|
164 |
.periodtag{display:inline-flex;align-items:baseline;gap:7px;font-size:12.5px;color:var(--soft);font-weight:600}
|
| - |
|
165 |
.periodtag .pl{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:700}
|
| - |
|
166 |
.periodtag #dashPeriod{color:var(--ink);font-weight:650;white-space:nowrap}
|
| 163 |
.livedot{width:7px;height:7px;border-radius:50%;background:#2f9e44;flex:none}
|
167 |
.livedot{width:7px;height:7px;border-radius:50%;background:#2f9e44;flex:none}
|
| 164 |
.livedot.beat{animation:livePulse .9s ease-out}
|
168 |
.livedot.beat{animation:livePulse .9s ease-out}
|
| 165 |
.livedot.stalled{background:#e03131}
|
169 |
.livedot.stalled{background:#e03131}
|
| 166 |
@keyframes livePulse{0%{box-shadow:0 0 0 0 rgba(47,158,68,.55)}100%{box-shadow:0 0 0 7px rgba(47,158,68,0)}}
|
170 |
@keyframes livePulse{0%{box-shadow:0 0 0 0 rgba(47,158,68,.55)}100%{box-shadow:0 0 0 7px rgba(47,158,68,0)}}
|
| 167 |
@media (prefers-reduced-motion:reduce){ .livedot.beat{animation:none} }
|
171 |
@media (prefers-reduced-motion:reduce){ .livedot.beat{animation:none} }
|
| Line 215... |
Line 219... |
| 215 |
/* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
|
219 |
/* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
|
| 216 |
th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
220 |
th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
| 217 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
221 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
| 218 |
th.sortable .arrow.up{border-bottom:5px solid currentColor}
|
222 |
th.sortable .arrow.up{border-bottom:5px solid currentColor}
|
| 219 |
th.sortable .arrow.down{border-top:5px solid currentColor}
|
223 |
th.sortable .arrow.down{border-top:5px solid currentColor}
|
| - |
|
224 |
/* Generic table search + sort, attached to every list table that does not sort server-side.
|
| - |
|
225 |
Deliberately NOT the .sortable class: renderPipeHead() rewrites every th.sortable in the
|
| - |
|
226 |
document, so sharing it would have the pipeline stamping its arrows onto these headers. */
|
| - |
|
227 |
th.tsort{cursor:pointer;user-select:none;white-space:nowrap}
|
| - |
|
228 |
th.tsort:hover{color:var(--brand2)}
|
| - |
|
229 |
th.tsort.on{color:var(--brand2)}
|
| - |
|
230 |
th.tsort .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
| - |
|
231 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
| - |
|
232 |
th.tsort .arrow.up{border-bottom:5px solid currentColor}
|
| - |
|
233 |
th.tsort .arrow.down{border-top:5px solid currentColor}
|
| - |
|
234 |
.tstools{display:flex;align-items:center;gap:9px;margin:0 0 10px;flex-wrap:wrap}
|
| - |
|
235 |
.tstools .inp{width:210px;font-size:12.5px;padding:7px 10px}
|
| - |
|
236 |
.tstools .tscount{font-size:11.5px;color:var(--soft);font-weight:600;font-variant-numeric:tabular-nums}
|
| - |
|
237 |
.tsempty{font-size:12.5px;color:var(--soft);font-style:italic;padding:14px 0;text-align:center}
|
| - |
|
238 |
|
| 220 |
/* Field label outside the filter bar (.ctl .lbl is scoped to it) + the record-action modals. */
|
239 |
/* Field label outside the filter bar (.ctl .lbl is scoped to it) + the record-action modals. */
|
| 221 |
.lbl{display:block;font-size:12px;font-weight:600;color:var(--soft);margin:10px 0 4px}
|
240 |
.lbl{display:block;font-size:12px;font-weight:600;color:var(--soft);margin:10px 0 4px}
|
| 222 |
.modal{position:fixed;inset:0;z-index:90;background:rgba(14,18,40,.44);display:flex;align-items:flex-start;
|
241 |
.modal{position:fixed;inset:0;z-index:90;background:rgba(14,18,40,.44);display:flex;align-items:flex-start;
|
| 223 |
justify-content:center;padding:6vh 16px;overflow:auto}
|
242 |
justify-content:center;padding:6vh 16px;overflow:auto}
|
| 224 |
.modal .box{background:#fff;border-radius:14px;width:min(640px,100%);box-shadow:0 26px 70px rgba(10,15,40,.3)}
|
243 |
.modal .box{background:#fff;border-radius:14px;width:min(640px,100%);box-shadow:0 26px 70px rgba(10,15,40,.3)}
|
| Line 240... |
Line 259... |
| 240 |
.drawer.open{transform:none}
|
259 |
.drawer.open{transform:none}
|
| 241 |
.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}
|
260 |
.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}
|
| 242 |
.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}
|
261 |
.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}
|
| 243 |
.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}
|
262 |
.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}
|
| 244 |
.drawer .dsub{color:var(--muted);font-size:12.5px}
|
263 |
.drawer .dsub{color:var(--muted);font-size:12.5px}
|
| - |
|
264 |
/* The title block takes the slack, so the tools slot and the close button sit against the right
|
| - |
|
265 |
edge instead of two competing auto margins splitting the gap between them. */
|
| - |
|
266 |
.drawer .dhmain{flex:1;min-width:0}
|
| - |
|
267 |
.drawer .dtools{flex:none;align-self:center}
|
| - |
|
268 |
.drawer .dtools .tstools{margin:0}
|
| - |
|
269 |
.drawer .dtools .tstools .inp{width:190px}
|
| 245 |
.drawer .x{margin-left:auto;cursor:pointer;font-size:22px;line-height:1;color:var(--soft);background:none;border:0;padding:0 2px}
|
270 |
.drawer .x{margin-left:auto;cursor:pointer;font-size:22px;line-height:1;color:var(--soft);background:none;border:0;padding:0 2px}
|
| 246 |
.drawer .dbody{padding:16px 18px 40px;overflow:auto;flex:1}
|
271 |
.drawer .dbody{padding:16px 18px 40px;overflow:auto;flex:1}
|
| 247 |
.drawer .card{margin-bottom:12px}
|
272 |
.drawer .card{margin-bottom:12px}
|
| 248 |
.dstat{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:12px}
|
273 |
.dstat{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:12px}
|
| 249 |
.dstat .box{background:#fff;border:1px solid var(--line);border-radius:11px;padding:11px 12px}
|
274 |
.dstat .box{background:#fff;border:1px solid var(--line);border-radius:11px;padding:11px 12px}
|
| Line 691... |
Line 716... |
| 691 |
<div class="crumb">Management · Cross-region roll-up</div>
|
716 |
<div class="crumb">Management · Cross-region roll-up</div>
|
| 692 |
<h1 class="page">Dashboards</h1>
|
717 |
<h1 class="page">Dashboards</h1>
|
| 693 |
<div class="sub">Funnel, SLA compliance, ageing & servicing across all regions — derived from the lead record.</div>
|
718 |
<div class="sub">Funnel, SLA compliance, ageing & servicing across all regions — derived from the lead record.</div>
|
| 694 |
</div>
|
719 |
</div>
|
| 695 |
<div class="hd-right">
|
720 |
<div class="hd-right">
|
| 696 |
<span class="ctl"><span class="lbl">Period</span><span id="dashPeriod">—</span></span>
|
721 |
<span class="periodtag"><span class="pl">Period</span><span id="dashPeriod">—</span></span>
|
| 697 |
<button class="btn ghost sm" onclick="exportPipeline()">Export CSV</button>
|
722 |
<button class="btn ghost sm" onclick="exportPipeline()">Export CSV</button>
|
| 698 |
</div>
|
723 |
</div>
|
| 699 |
</div>
|
724 |
</div>
|
| 700 |
|
725 |
|
| 701 |
<div class="kpis" id="dashKpis"></div>
|
726 |
<div class="kpis" id="dashKpis"></div>
|
| Line 826... |
Line 851... |
| 826 |
</div>
|
851 |
</div>
|
| 827 |
|
852 |
|
| 828 |
<!-- drawer + toast -->
|
853 |
<!-- drawer + toast -->
|
| 829 |
<div class="drawer-ov" id="ov" onclick="closeDrawer()"></div>
|
854 |
<div class="drawer-ov" id="ov" onclick="closeDrawer()"></div>
|
| 830 |
<div class="drawer" id="drawer">
|
855 |
<div class="drawer" id="drawer">
|
| 831 |
<div class="dh"><div><div class="crumb2" id="dCrumb"></div><h3 id="dTitle"></h3><div class="dsub" id="dSub"></div></div><button class="x" onclick="closeDrawer()">×</button></div>
|
856 |
<div class="dh"><div class="dhmain"><div class="crumb2" id="dCrumb"></div><h3 id="dTitle"></h3><div class="dsub" id="dSub"></div></div><div class="dtools" id="dTools"></div><button class="x" onclick="closeDrawer()">×</button></div>
|
| 832 |
<div class="dbody" id="dBody"></div>
|
857 |
<div class="dbody" id="dBody"></div>
|
| 833 |
</div>
|
858 |
</div>
|
| 834 |
<!-- Record actions: edit the record, and log a call outcome. Both write to the same endpoints
|
859 |
<!-- Record actions: edit the record, and log a call outcome. Both write to the same endpoints
|
| 835 |
the in-app lead record uses. -->
|
860 |
the in-app lead record uses. -->
|
| 836 |
<div class="modal hide" id="editModal">
|
861 |
<div class="modal hide" id="editModal">
|
| Line 1016... |
Line 1041... |
| 1016 |
// Under a lakh needs two decimals: a ₹1,000 lead is 0.01L, and one decimal would print it as
|
1041 |
// Under a lakh needs two decimals: a ₹1,000 lead is 0.01L, and one decimal would print it as
|
| 1017 |
// ₹0L — the same information loss the old scaling caused, just at the other end.
|
1042 |
// ₹0L — the same information loss the old scaling caused, just at the other end.
|
| 1018 |
var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
|
1043 |
var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
|
| 1019 |
return '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
|
1044 |
return '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
|
| 1020 |
|
1045 |
|
| - |
|
1046 |
/**
|
| - |
|
1047 |
* "1 - 21 Sep 2026" / "28 Aug - 21 Sep 2026" / "21 Dec 2025 - 3 Jan 2026".
|
| - |
|
1048 |
* ISO on both ends made the reader parse two 10-character strings to see it was one month.
|
| - |
|
1049 |
*/
|
| - |
|
1050 |
function periodLabel(from,to){
|
| - |
|
1051 |
if(!from||!to) return (from||to||'\u2014');
|
| - |
|
1052 |
var a=new Date(from), b=new Date(to);
|
| - |
|
1053 |
if(isNaN(a)||isNaN(b)) return from+' \u2192 '+to;
|
| - |
|
1054 |
var M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| - |
|
1055 |
var sameY=a.getFullYear()===b.getFullYear(), sameM=sameY&&a.getMonth()===b.getMonth();
|
| - |
|
1056 |
if(sameM) return a.getDate()+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
| - |
|
1057 |
if(sameY) return a.getDate()+' '+M[a.getMonth()]+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
| - |
|
1058 |
return a.getDate()+' '+M[a.getMonth()]+' '+a.getFullYear()+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
| - |
|
1059 |
}
|
| - |
|
1060 |
|
| - |
|
1061 |
function setDashPeriod(){ byId('dashPeriod').textContent=periodLabel(STATE.from,STATE.to); }
|
| - |
|
1062 |
|
| 1021 |
// ---- tabs
|
1063 |
// ---- tabs
|
| 1022 |
var SCREENS=['cmd','record','create','assign','live','dash','chk','audit'];
|
1064 |
var SCREENS=['cmd','record','create','assign','live','dash','chk','audit'];
|
| 1023 |
var LOADED={};
|
1065 |
var LOADED={};
|
| 1024 |
function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
|
1066 |
function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
|
| 1025 |
// Leaving the live tab must stop its poll; entering it starts a fresh one.
|
1067 |
// Leaving the live tab must stop its poll; entering it starts a fresh one.
|
| 1026 |
if(id==='live'){ startLive(); } else { stopLive(); }
|
1068 |
if(id==='live'){ startLive(); } else { stopLive(); }
|
| 1027 |
if(id==='chk') loadQuestions();
|
1069 |
if(id==='chk') loadQuestions();
|
| 1028 |
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');
|
1070 |
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');
|
| 1029 |
if(id==='dash'){ byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||''); loadBgc(); }
|
1071 |
if(id==='dash'){ setDashPeriod(); loadBgc(); }
|
| 1030 |
if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }
|
1072 |
if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }
|
| 1031 |
if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }
|
1073 |
if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }
|
| 1032 |
if(id==='audit'){ buildAudit(); } }
|
1074 |
if(id==='audit'){ buildAudit(); } }
|
| 1033 |
|
1075 |
|
| 1034 |
// ---- toast
|
1076 |
// ---- toast
|
| Line 1192... |
Line 1234... |
| 1192 |
PIPE.page=n; loadPipeline();
|
1234 |
PIPE.page=n; loadPipeline();
|
| 1193 |
}
|
1235 |
}
|
| 1194 |
|
1236 |
|
| 1195 |
function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];
|
1237 |
function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];
|
| 1196 |
case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];
|
1238 |
case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];
|
| - |
|
1239 |
case'qualified':return['STAGE','QUALIFIED'];
|
| - |
|
1240 |
// The Onboarded tile counts ONBOARDED + ACTIVE, so its drill has to as well.
|
| 1197 |
case'qualified':return['STAGE','QUALIFIED'];case'onboarded':return['STAGE','ONBOARDED'];default:return['ALL','']; } }
|
1241 |
case'onboarded':return['STAGE_IN','ONBOARDED,ACTIVE'];default:return['ALL','']; } }
|
| 1198 |
|
1242 |
|
| 1199 |
// ---- DASHBOARDS
|
1243 |
// ---- DASHBOARDS
|
| 1200 |
var DASH_KPI=[ {i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'📈',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
|
1244 |
var DASH_KPI=[ {i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'📈',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
|
| 1201 |
{i:'⚖',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'📞',bg:'#e3fafc',fg:'#0c8599'},
|
1245 |
{i:'⚖',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'📞',bg:'#e3fafc',fg:'#0c8599'},
|
| 1202 |
{i:'⌛',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'💰',bg:'#ebfbee',fg:'#2f9e44'} ];
|
1246 |
{i:'⌛',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'💰',bg:'#ebfbee',fg:'#2f9e44'} ];
|
| Line 1290... |
Line 1334... |
| 1290 |
byId('dashFix').innerHTML=out;
|
1334 |
byId('dashFix').innerHTML=out;
|
| 1291 |
}
|
1335 |
}
|
| 1292 |
|
1336 |
|
| 1293 |
// ---- drill drawer
|
1337 |
// ---- drill drawer
|
| 1294 |
function openDrawer(crumb,title,sub,html){ byId('dCrumb').textContent=crumb||''; byId('dTitle').textContent=title||'';
|
1338 |
function openDrawer(crumb,title,sub,html){ byId('dCrumb').textContent=crumb||''; byId('dTitle').textContent=title||'';
|
| - |
|
1339 |
byId('dSub').textContent=sub||'';
|
| - |
|
1340 |
// The filter for a drawer table lives in the drawer header, so it has to go when the body it
|
| - |
|
1341 |
// belongs to is replaced — otherwise the next drawer opens wearing the last one's filter.
|
| - |
|
1342 |
byId('dTools').innerHTML='';
|
| 1295 |
byId('dSub').textContent=sub||''; byId('dBody').innerHTML=html||''; byId('ov').classList.add('open'); byId('drawer').classList.add('open'); }
|
1343 |
byId('dBody').innerHTML=html||''; byId('ov').classList.add('open'); byId('drawer').classList.add('open'); }
|
| 1296 |
function closeDrawer(){ byId('ov').classList.remove('open'); byId('drawer').classList.remove('open'); }
|
1344 |
function closeDrawer(){ byId('ov').classList.remove('open'); byId('drawer').classList.remove('open'); }
|
| 1297 |
document.addEventListener('keydown',function(e){ if(e.key==='Escape')closeDrawer();
|
1345 |
document.addEventListener('keydown',function(e){ if(e.key==='Escape')closeDrawer();
|
| 1298 |
if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){ e.preventDefault(); byId('lmsSearch').focus(); } });
|
1346 |
if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){ e.preventDefault(); byId('lmsSearch').focus(); } });
|
| 1299 |
|
1347 |
|
| - |
|
1348 |
// No SLA column: the drawer lists leads by stage, region or search term, and the first-contact
|
| - |
|
1349 |
// clock only ever ran on leads created through the LMS -- so the column was a wall of em-dashes
|
| - |
|
1350 |
// with the occasional breach, carrying no information the drawer was opened to find. SLA lives
|
| - |
|
1351 |
// where it is the subject: the pipeline, the attention queue, and the SLA Compliance report.
|
| 1300 |
function drawerLeadTable(leads){ if(!leads||!leads.length) return '<div class="ph">No leads in this bucket.</div>';
|
1352 |
function drawerLeadTable(leads){ if(!leads||!leads.length) return '<div class="ph">No leads in this bucket.</div>';
|
| 1301 |
var h='<div class="card"><table><thead><tr><th>LMS ID</th><th>Business</th><th>Stage</th><th>Owner</th><th>SLA</th><th class="num">Value</th></tr></thead><tbody>';
|
1353 |
var h='<div class="card"><table><thead><tr><th>LMS ID</th><th>Business</th><th>Stage</th><th>Owner</th><th class="num">Value</th></tr></thead><tbody>';
|
| 1302 |
leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
1354 |
leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
| 1303 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
1355 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
| 1304 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td><td>'+slaCell(l.slaState)+'</td>'+
|
1356 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td>'+
|
| 1305 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
1357 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
| 1306 |
return h+'</tbody></table></div>'; }
|
1358 |
return h+'</tbody></table></div>'; }
|
| 1307 |
function drill(crumb,title,sub,bt,bk){ openDrawer(crumb,title,'Loading…','<div class="ph">Loading…</div>');
|
1359 |
function drill(crumb,title,sub,bt,bk){ openDrawer(crumb,title,'Loading…','<div class="ph">Loading…</div>');
|
| 1308 |
var q='?bucketType='+encodeURIComponent(bt)+(bk?'&bucketKey='+encodeURIComponent(bk):'')+qs();
|
1360 |
var q='?bucketType='+encodeURIComponent(bt)+(bk?'&bucketKey='+encodeURIComponent(bk):'')+qs();
|
| 1309 |
getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];
|
1361 |
getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];
|
| - |
|
1362 |
var cap=(d&&d.limit)?d.limit:0;
|
| - |
|
1363 |
// The drawer is capped, so say so -- otherwise a bar reading 170 opens a list of 100 and the
|
| - |
|
1364 |
// reader has to guess which number is wrong.
|
| - |
|
1365 |
var count=(cap&&leads.length>=cap)?('first '+leads.length+' lead(s)'):(leads.length+' lead(s)');
|
| 1310 |
openDrawer(crumb,title,(sub||'')+' · '+leads.length+' lead(s)',drawerLeadTable(leads)); }); }
|
1366 |
openDrawer(crumb,title,(sub||'')+' · '+count,drawerLeadTable(leads)); }); }
|
| 1311 |
|
1367 |
|
| 1312 |
// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawer
|
1368 |
// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawer
|
| 1313 |
var searchTimer=null, searchSeq=0;
|
1369 |
var searchTimer=null, searchSeq=0;
|
| 1314 |
function runSearch(){ var q=(byId('lmsSearch').value||'').trim();
|
1370 |
function runSearch(){ var q=(byId('lmsSearch').value||'').trim();
|
| 1315 |
if(q.length<2){ toast('Type at least 2 characters'); return; }
|
1371 |
if(q.length<2){ toast('Type at least 2 characters'); return; }
|
| Line 1345... |
Line 1401... |
| 1345 |
var eq=e.target.closest('[data-editq]');
|
1401 |
var eq=e.target.closest('[data-editq]');
|
| 1346 |
if(eq){ editQuestion(parseInt(eq.getAttribute('data-editq'),10)); return; }
|
1402 |
if(eq){ editQuestion(parseInt(eq.getAttribute('data-editq'),10)); return; }
|
| 1347 |
// Stage chips are multi-select; the in-call answer chips are single-select.
|
1403 |
// Stage chips are multi-select; the in-call answer chips are single-select.
|
| 1348 |
var qs=e.target.closest('.qstage');
|
1404 |
var qs=e.target.closest('.qstage');
|
| 1349 |
if(qs){ qs.classList.toggle('sel'); return; }
|
1405 |
if(qs){ qs.classList.toggle('sel'); return; }
|
| - |
|
1406 |
var tf=e.target.closest('.trailf');
|
| - |
|
1407 |
if(tf){ setTrailFilter(tf.getAttribute('data-trailtype')); return; }
|
| - |
|
1408 |
var tp=e.target.closest('#asgTiers .chkopt');
|
| - |
|
1409 |
if(tp){ byId('asgTiers').querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
| - |
|
1410 |
tp.classList.add('sel'); ASG.tier=tp.getAttribute('data-tier'); renderAssignList(); return; }
|
| - |
|
1411 |
// The assign-modal tier pills share the .chkopt class for styling, so they are handled above
|
| - |
|
1412 |
// and excluded here: this generic branch only picks an answer chip, and letting it swallow a
|
| - |
|
1413 |
// tier click selected the pill visually while never applying the filter.
|
| 1350 |
var co=e.target.closest('.chkopt');
|
1414 |
var co=e.target.closest('.chkopt');
|
| 1351 |
if(co){ var wrap=co.parentNode;
|
1415 |
if(co && !co.closest('#asgTiers') && !co.classList.contains('trailf')){ var wrap=co.parentNode;
|
| 1352 |
wrap.querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
1416 |
wrap.querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
| 1353 |
co.classList.add('sel'); return; }
|
1417 |
co.classList.add('sel'); return; }
|
| 1354 |
var asg=e.target.closest('[data-assign]');
|
1418 |
var asg=e.target.closest('[data-assign]');
|
| 1355 |
if(asg){ e.preventDefault(); e.stopPropagation();
|
1419 |
if(asg){ e.preventDefault(); e.stopPropagation();
|
| 1356 |
openAssign(parseInt(asg.getAttribute('data-assign'),10), asg.getAttribute('data-assignname'),
|
1420 |
openAssign(parseInt(asg.getAttribute('data-assign'),10), asg.getAttribute('data-assignname'),
|
| 1357 |
asg.getAttribute('data-assignowner'), asg.getAttribute('data-assignregion')); return; }
|
1421 |
asg.getAttribute('data-assignowner'), asg.getAttribute('data-assignregion')); return; }
|
| 1358 |
var pick=e.target.closest('[data-assignto]');
|
1422 |
var pick=e.target.closest('[data-assignto]');
|
| 1359 |
if(pick){ assignTo(parseInt(pick.getAttribute('data-assignto'),10)); return; }
|
1423 |
if(pick){ assignTo(parseInt(pick.getAttribute('data-assignto'),10)); return; }
|
| 1360 |
var tp=e.target.closest('#asgTiers .chkopt');
|
- |
|
| 1361 |
if(tp){ byId('asgTiers').querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
- |
|
| 1362 |
tp.classList.add('sel'); ASG.tier=tp.getAttribute('data-tier'); renderAssignList(); return; }
|
- |
|
| 1363 |
var ed=e.target.closest('[data-editlead]');
|
1424 |
var ed=e.target.closest('[data-editlead]');
|
| 1364 |
if(ed){ e.preventDefault(); e.stopPropagation();
|
1425 |
if(ed){ e.preventDefault(); e.stopPropagation();
|
| 1365 |
editLeadById(parseInt(ed.getAttribute('data-editlead'),10)); return; }
|
1426 |
editLeadById(parseInt(ed.getAttribute('data-editlead'),10)); return; }
|
| 1366 |
var cb=e.target.closest('[data-call]');
|
1427 |
var cb=e.target.closest('[data-call]');
|
| 1367 |
if(cb){ e.preventDefault(); e.stopPropagation();
|
1428 |
if(cb){ e.preventDefault(); e.stopPropagation();
|
| Line 1370... |
Line 1431... |
| 1370 |
if(la){ openAgentCalls(parseInt(la.getAttribute('data-liveagent'),10)); return; }
|
1431 |
if(la){ openAgentCalls(parseInt(la.getAttribute('data-liveagent'),10)); return; }
|
| 1371 |
var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
|
1432 |
var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
|
| 1372 |
var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
|
1433 |
var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
|
| 1373 |
drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
|
1434 |
drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
|
| 1374 |
var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
|
1435 |
var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
|
| 1375 |
drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }
|
1436 |
drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE_REACHED',st==='ALL'?'':st); return; }
|
| 1376 |
var att=e.target.closest('.att[data-att]'); if(att){ drill('Needs attention',att.getAttribute('data-attt'),'Attention queue','ATTENTION',att.getAttribute('data-att')); return; }
|
1437 |
var att=e.target.closest('.att[data-att]'); if(att){ drill('Needs attention',att.getAttribute('data-attt'),'Attention queue','ATTENTION',att.getAttribute('data-att')); return; }
|
| 1377 |
var reg=e.target.closest('tr[data-region]'); if(reg){ drill('State funnel',reg.getAttribute('data-rn'),'Region '+reg.getAttribute('data-region'),'REGION',reg.getAttribute('data-region')); return; }
|
1438 |
var reg=e.target.closest('tr[data-region]'); if(reg){ drill('State funnel',reg.getAttribute('data-rn'),'Region '+reg.getAttribute('data-region'),'REGION',reg.getAttribute('data-region')); return; }
|
| 1378 |
var rep=e.target.closest('tr[data-rep]'); if(rep){ openReport(rep.getAttribute('data-rep')); return; }
|
1439 |
var rep=e.target.closest('tr[data-rep]'); if(rep){ openReport(rep.getAttribute('data-rep')); return; }
|
| 1379 |
var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');
|
1440 |
var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');
|
| 1380 |
closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
|
1441 |
closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
|
| Line 1385... |
Line 1446... |
| 1385 |
function refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';
|
1446 |
function refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';
|
| 1386 |
getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();
|
1447 |
getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();
|
| 1387 |
getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);
|
1448 |
getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);
|
| 1388 |
getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);
|
1449 |
getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);
|
| 1389 |
getJSON('/lms/dashboard/ageing',renderAgeing);
|
1450 |
getJSON('/lms/dashboard/ageing',renderAgeing);
|
| 1390 |
byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||''); }
|
1451 |
setDashPeriod(); }
|
| 1391 |
['fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });
|
1452 |
['fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });
|
| 1392 |
|
1453 |
|
| 1393 |
// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================
|
1454 |
// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================
|
| 1394 |
var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],
|
1455 |
var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],
|
| 1395 |
['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];
|
1456 |
['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];
|
| Line 1405... |
Line 1466... |
| 1405 |
function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }
|
1466 |
function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }
|
| 1406 |
function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
1467 |
function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
| 1407 |
if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }
|
1468 |
if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }
|
| 1408 |
else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }
|
1469 |
else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }
|
| 1409 |
|
1470 |
|
| - |
|
1471 |
/** Trail type filter. '' is everything; the rest match LeadActivity.communicationType. */
|
| - |
|
1472 |
var TRAIL_TYPES=[['','All'],['TELEPHONIC','Telephonic'],['VISIT','Visit'],['UPDATE','Update']];
|
| - |
|
1473 |
var TRAIL={ filter:'' };
|
| - |
|
1474 |
|
| - |
|
1475 |
/** An entry with no communication type is a stage move or a note — shown as Update. */
|
| - |
|
1476 |
function trailTypeKey(t){ return t && t.type ? t.type : 'UPDATE'; }
|
| - |
|
1477 |
|
| - |
|
1478 |
function trailCounts(list){
|
| - |
|
1479 |
var c={};
|
| - |
|
1480 |
list.forEach(function(t){ var k=trailTypeKey(t); c[k]=(c[k]||0)+1; });
|
| - |
|
1481 |
return c;
|
| - |
|
1482 |
}
|
| - |
|
1483 |
|
| - |
|
1484 |
function setTrailFilter(type){
|
| - |
|
1485 |
TRAIL.filter=type||'';
|
| - |
|
1486 |
document.querySelectorAll('#trailFilter .trailf').forEach(function(b){
|
| - |
|
1487 |
b.classList.toggle('sel', b.getAttribute('data-trailtype')===TRAIL.filter); });
|
| - |
|
1488 |
renderTrailList();
|
| - |
|
1489 |
}
|
| - |
|
1490 |
|
| - |
|
1491 |
function renderTrailList(){
|
| - |
|
1492 |
var host=byId('trailHost'); if(!host || !REC) return;
|
| - |
|
1493 |
var all=REC.trail||[];
|
| - |
|
1494 |
var list=TRAIL.filter?all.filter(function(t){ return trailTypeKey(t)===TRAIL.filter; }):all;
|
| - |
|
1495 |
if(!list.length){
|
| - |
|
1496 |
host.innerHTML='<div class="ph">'+(all.length?'No entries of this type.':'No activity yet.')+'</div>';
|
| - |
|
1497 |
return;
|
| - |
|
1498 |
}
|
| - |
|
1499 |
var h='<div class="trail" id="trailScroll">';
|
| - |
|
1500 |
list.forEach(function(t){
|
| - |
|
1501 |
h+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+
|
| - |
|
1502 |
'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+
|
| - |
|
1503 |
'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+
|
| - |
|
1504 |
(t.body?('<div class="td">'+esc(t.body)+'</div>'):'');
|
| - |
|
1505 |
// The recording of the call this entry came from, so the agent's account of the conversation
|
| - |
|
1506 |
// and the conversation itself sit together instead of in two separate lists.
|
| - |
|
1507 |
if(t.callId){
|
| - |
|
1508 |
h+='<div class="trec"><audio controls preload="none" src="'+CTX+'/lms/recording/'+t.callId+'"></audio>'+
|
| - |
|
1509 |
(t.durationSeconds?('<span class="note">'+t.durationSeconds+'s</span>'):'')+
|
| - |
|
1510 |
(t.archived?'':'<span class="note">archiving…</span>')+'</div>';
|
| - |
|
1511 |
}
|
| - |
|
1512 |
h+='</div>';
|
| - |
|
1513 |
});
|
| - |
|
1514 |
host.innerHTML=h+'</div>';
|
| - |
|
1515 |
capTrail();
|
| - |
|
1516 |
}
|
| - |
|
1517 |
|
| 1410 |
/** Trail entries shown before the list starts scrolling. */
|
1518 |
/** Trail entries shown before the list starts scrolling. */
|
| 1411 |
var TRAIL_VISIBLE=5;
|
1519 |
var TRAIL_VISIBLE=5;
|
| 1412 |
|
1520 |
|
| 1413 |
/**
|
1521 |
/**
|
| 1414 |
* Cap the trail at TRAIL_VISIBLE entries and scroll the rest.
|
1522 |
* Cap the trail at TRAIL_VISIBLE entries and scroll the rest.
|
| Line 1426... |
Line 1534... |
| 1426 |
box.style.overflowY='auto';
|
1534 |
box.style.overflowY='auto';
|
| 1427 |
box.classList.add('scrolls');
|
1535 |
box.classList.add('scrolls');
|
| 1428 |
}
|
1536 |
}
|
| 1429 |
|
1537 |
|
| 1430 |
function renderRecord(d){
|
1538 |
function renderRecord(d){
|
| 1431 |
REC=d; CALL.id=null;
|
1539 |
REC=d; CALL.id=null; TRAIL.filter='';
|
| 1432 |
// header
|
1540 |
// header
|
| 1433 |
var head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+
|
1541 |
var head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+
|
| 1434 |
'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+
|
1542 |
'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+
|
| 1435 |
'<h1 class="page" style="margin-top:4px">'+esc(d.name||'—')+(d.outlet?' <span style="font-weight:500;color:#59668a;font-size:18px">· '+esc(d.outlet)+'</span>':'')+'</h1></div>'+
|
1543 |
'<h1 class="page" style="margin-top:4px">'+esc(d.name||'—')+(d.outlet?' <span style="font-weight:500;color:#59668a;font-size:18px">· '+esc(d.outlet)+'</span>':'')+'</h1></div>'+
|
| 1436 |
'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+
|
1544 |
'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+
|
| Line 1476... |
Line 1584... |
| 1476 |
var sla='<div class="card"><h2>First-contact SLA</h2><p class="desc">5-hr commitment from creation (SOP §11).</p>'+
|
1584 |
var sla='<div class="card"><h2>First-contact SLA</h2><p class="desc">5-hr commitment from creation (SOP §11).</p>'+
|
| 1477 |
'<div style="font-size:26px;font-weight:800;color:'+slaClr+'">'+esc(d.slaState||'NA')+'</div>'+
|
1585 |
'<div style="font-size:26px;font-weight:800;color:'+slaClr+'">'+esc(d.slaState||'NA')+'</div>'+
|
| 1478 |
'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+
|
1586 |
'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+
|
| 1479 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';
|
1587 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';
|
| 1480 |
// trail
|
1588 |
// trail
|
| 1481 |
var trail='<div class="card"><h2>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></h2>';
|
1589 |
// Trail card. The list itself is rendered by renderTrailList() so the type filter can redraw it
|
| 1482 |
if(!d.trail||!d.trail.length){ trail+='<div class="ph">No activity yet.</div>'; }
|
1590 |
// without refetching the record — the whole trail is already in hand.
|
| 1483 |
else { trail+='<div class="trail" id="trailScroll">'; d.trail.forEach(function(t){
|
1591 |
var counts=trailCounts(d.trail||[]);
|
| 1484 |
trail+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+
|
1592 |
var trail='<div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">'+
|
| 1485 |
'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+
|
1593 |
'<span>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></span>'+
|
| 1486 |
'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+
|
1594 |
(d.trail&&d.trail.length?('<span class="opts" id="trailFilter">'+
|
| 1487 |
(t.body?('<div class="td">'+esc(t.body)+'</div>'):'');
|
1595 |
TRAIL_TYPES.map(function(t){
|
| 1488 |
// The recording of the call this entry came from, so the agent's account of the conversation
|
1596 |
var n=counts[t[0]]||0;
|
| 1489 |
// and the conversation itself sit together instead of in two separate lists.
|
1597 |
// Offer only the types this lead actually has — a filter that returns nothing is noise.
|
| 1490 |
if(t.callId){
|
1598 |
if(t[0] && !n) return '';
|
| 1491 |
trail+='<div class="trec"><audio controls preload="none" src="'+CTX+'/lms/recording/'+t.callId+'"></audio>'+
|
1599 |
return '<button type="button" class="chkopt trailf'+(TRAIL.filter===t[0]?' sel':'')+'"'+
|
| 1492 |
(t.durationSeconds?('<span class="note">'+t.durationSeconds+'s</span>'):'')+
|
1600 |
' data-trailtype="'+t[0]+'">'+esc(t[1])+' <span class="note" style="display:inline">'+
|
| 1493 |
(t.archived?'':'<span class="note">archiving…</span>')+'</div>';
|
1601 |
(t[0]?n:(d.trail.length))+'</span></button>';
|
| - |
|
1602 |
}).join('')+'</span>'):'')+
|
| 1494 |
}
|
1603 |
'</h2>'+
|
| 1495 |
trail+='</div>'; }); trail+='</div>'; }
|
1604 |
'<div id="trailHost"></div>';
|
| 1496 |
trail+='</div>';
|
1605 |
trail+='</div>';
|
| 1497 |
|
1606 |
|
| 1498 |
// ---- Click-to-call (SOP §12). VBC places the call server-side: our own device rings first,
|
1607 |
// ---- Click-to-call (SOP §12). VBC places the call server-side: our own device rings first,
|
| 1499 |
// then the retailer. A blocked number never gets a button at all — the refusal belongs in
|
1608 |
// then the retailer. A blocked number never gets a button at all — the refusal belongs in
|
| 1500 |
// front of the agent, not in the response to a dial they already started.
|
1609 |
// front of the agent, not in the response to a dial they already started.
|
| Line 1538... |
Line 1647... |
| 1538 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">'+esc(d.stageLabel)+' is terminal — this lead cannot be moved on.</p></div>';
|
1647 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">'+esc(d.stageLabel)+' is terminal — this lead cannot be moved on.</p></div>';
|
| 1539 |
}
|
1648 |
}
|
| 1540 |
|
1649 |
|
| 1541 |
byId('recordHost').innerHTML=head+step+
|
1650 |
byId('recordHost').innerHTML=head+step+
|
| 1542 |
'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
|
1651 |
'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
|
| 1543 |
capTrail();
|
1652 |
renderTrailList();
|
| 1544 |
}
|
1653 |
}
|
| 1545 |
|
1654 |
|
| 1546 |
// ====================== LIVE CALL BOARD ===================================================
|
1655 |
// ====================== LIVE CALL BOARD ===================================================
|
| 1547 |
// Polls /lms/dashboard/live-calls. First paint builds the table; subsequent ticks PATCH the
|
1656 |
// Polls /lms/dashboard/live-calls. First paint builds the table; subsequent ticks PATCH the
|
| 1548 |
// volatile cells rather than re-rendering — re-drawing the whole table every 10s fights anyone
|
1657 |
// volatile cells rather than re-rendering — re-drawing the whole table every 10s fights anyone
|
| Line 2659... |
Line 2768... |
| 2659 |
var fh=''; flags.forEach(function(a){ fh+='<div class="att" data-att="'+a.k+'" data-attt="'+esc(a.t)+'"><div class="ic" style="background:'+a.bg+';color:'+a.fg+'">'+a.i+'</div>'+
|
2768 |
var fh=''; flags.forEach(function(a){ fh+='<div class="att" data-att="'+a.k+'" data-attt="'+esc(a.t)+'"><div class="ic" style="background:'+a.bg+';color:'+a.fg+'">'+a.i+'</div>'+
|
| 2660 |
'<div class="ab"><div class="atitle">'+esc(a.t)+'</div><div class="adet">'+esc(a.d)+'</div></div><div class="acount '+a.c+'">'+fmtInt(a.v)+'</div></div>'; });
|
2769 |
'<div class="ab"><div class="atitle">'+esc(a.t)+'</div><div class="adet">'+esc(a.d)+'</div></div><div class="acount '+a.c+'">'+fmtInt(a.v)+'</div></div>'; });
|
| 2661 |
byId('auditHygiene').innerHTML=fh; }
|
2770 |
byId('auditHygiene').innerHTML=fh; }
|
| 2662 |
function onboardedCount(){ var s=DATA.summary; if(!s||!s.funnel) return 0; var c=0; s.funnel.forEach(function(f){ if(f.stage==='ONBOARDED')c=f.count; }); return c; }
|
2771 |
function onboardedCount(){ var s=DATA.summary; if(!s||!s.funnel) return 0; var c=0; s.funnel.forEach(function(f){ if(f.stage==='ONBOARDED')c=f.count; }); return c; }
|
| 2663 |
|
2772 |
|
| - |
|
2773 |
// ---- generic table search + sort ---------------------------------------------------------
|
| - |
|
2774 |
// Every list table in the console gets a sortable header, and one with enough rows to be worth
|
| - |
|
2775 |
// filtering also gets a search box. Attached by scanning the DOM rather than by editing each of
|
| - |
|
2776 |
// the fifteen render functions: tables here are built as HTML strings in a dozen places, and a
|
| - |
|
2777 |
// per-call-site flag would have been missed by the next one written.
|
| - |
|
2778 |
//
|
| - |
|
2779 |
// Client-side, so it sorts and filters WHAT IS ON SCREEN. That is the whole population for the
|
| - |
|
2780 |
// report tables, and the loaded slice for the ones the server pages (the drawer stops at 100,
|
| - |
|
2781 |
// the call log at its page size) — the count reads "12 of 100 shown" so it never implies more.
|
| - |
|
2782 |
// The Lead pipeline is excluded: it sorts and searches server-side across every page, and that
|
| - |
|
2783 |
// is strictly better wherever it exists.
|
| - |
|
2784 |
|
| - |
|
2785 |
var TS_SEARCH_MIN_ROWS = 8; // below this a search box is clutter; the sort still attaches
|
| - |
|
2786 |
var tsBusy = false; // our own DOM writes must not re-trigger the observer
|
| - |
|
2787 |
|
| - |
|
2788 |
/** Tables that own their behaviour, or are not lists at all, opt out here. */
|
| - |
|
2789 |
function tsEligible(t){
|
| - |
|
2790 |
if(t.hasAttribute('data-ts') || t.hasAttribute('data-nots')) return false;
|
| - |
|
2791 |
if(t.classList.contains('matrix') || t.classList.contains('heat')) return false;
|
| - |
|
2792 |
if(t.querySelector('th.sortable')) return false; // the pipeline: server-side sort
|
| - |
|
2793 |
var head=t.tHead;
|
| - |
|
2794 |
if(!head || head.rows.length!==1) return false; // no header, or grouped headers:
|
| - |
|
2795 |
if(!t.tBodies.length) return false; // column index is not 1:1 with th
|
| - |
|
2796 |
return true;
|
| - |
|
2797 |
}
|
| - |
|
2798 |
|
| - |
|
2799 |
/** Cell text for comparison; a cell may override it with data-sv (e.g. a raw timestamp). */
|
| - |
|
2800 |
function tsText(td){
|
| - |
|
2801 |
if(!td) return '';
|
| - |
|
2802 |
var v = td.getAttribute && td.getAttribute('data-sv');
|
| - |
|
2803 |
return ((v!==null&&v!==undefined) ? v : td.textContent).replace(/\s+/g,' ').trim();
|
| - |
|
2804 |
}
|
| - |
|
2805 |
|
| - |
|
2806 |
/**
|
| - |
|
2807 |
* Comparable number for a rendered cell, or null when it is not a number.
|
| - |
|
2808 |
* Sorting on the printed text would put ₹8L above ₹10L and 2m 40s above 10m, which is the kind
|
| - |
|
2809 |
* of wrong that is hard to notice — so the formats this console actually prints are parsed.
|
| - |
|
2810 |
*/
|
| - |
|
2811 |
function tsValue(txt){
|
| - |
|
2812 |
if(!txt || txt==='—' || txt==='-') return null;
|
| - |
|
2813 |
// duration: "2m 40s", "1h 05m", "45s"
|
| - |
|
2814 |
if(/\d/.test(txt) && /^(\d+h\s*)?(\d+m\s*)?(\d+s)?$/.test(txt)){
|
| - |
|
2815 |
var h=/(\d+)h/.exec(txt), mn=/(\d+)m/.exec(txt), sc=/(\d+)s/.exec(txt);
|
| - |
|
2816 |
return (h?+h[1]*3600:0)+(mn?+mn[1]*60:0)+(sc?+sc[1]:0);
|
| - |
|
2817 |
}
|
| - |
|
2818 |
// number, with optional currency prefix, thousands separators and a K / L / Cr / % suffix
|
| - |
|
2819 |
var m=/^[₹$]?\s*(-?[\d,]+(?:\.\d+)?)\s*(K|L|Cr|%)?$/i.exec(txt);
|
| - |
|
2820 |
if(m){
|
| - |
|
2821 |
var n=parseFloat(m[1].replace(/,/g,''));
|
| - |
|
2822 |
if(isNaN(n)) return null;
|
| - |
|
2823 |
var suf=(m[2]||'').toLowerCase();
|
| - |
|
2824 |
if(suf==='k') n*=1e3; else if(suf==='l') n*=1e5; else if(suf==='cr') n*=1e7;
|
| - |
|
2825 |
return n;
|
| - |
|
2826 |
}
|
| - |
|
2827 |
// "21 Sep 2026", with or without a time after it
|
| - |
|
2828 |
if(/^\d{1,2} [A-Za-z]{3} \d{4}/.test(txt)){ var d=Date.parse(txt); if(!isNaN(d)) return d; }
|
| - |
|
2829 |
return null;
|
| - |
|
2830 |
}
|
| - |
|
2831 |
|
| - |
|
2832 |
/** Data rows only — a colspan placeholder ("Loading…", "No rows") is not one. */
|
| - |
|
2833 |
function tsRows(t){
|
| - |
|
2834 |
var out=[], b=t.tBodies[0], cols=t.tHead.rows[0].cells.length;
|
| - |
|
2835 |
if(!b) return out;
|
| - |
|
2836 |
for(var i=0;i<b.rows.length;i++){
|
| - |
|
2837 |
var r=b.rows[i];
|
| - |
|
2838 |
if(r.cells.length>=cols && !r.hasAttribute('data-tsempty')) out.push(r);
|
| - |
|
2839 |
}
|
| - |
|
2840 |
return out;
|
| - |
|
2841 |
}
|
| - |
|
2842 |
|
| - |
|
2843 |
function tsSort(t, idx){
|
| - |
|
2844 |
var ths=t.tHead.rows[0].cells;
|
| - |
|
2845 |
var cur=t.getAttribute('data-tscol'), dir=t.getAttribute('data-tsdir')||'desc';
|
| - |
|
2846 |
// Same column flips; a new column starts descending — what people expect of values and dates,
|
| - |
|
2847 |
// and harmless on text.
|
| - |
|
2848 |
dir = (String(idx)===cur) ? (dir==='asc'?'desc':'asc') : 'desc';
|
| - |
|
2849 |
t.setAttribute('data-tscol', String(idx));
|
| - |
|
2850 |
t.setAttribute('data-tsdir', dir);
|
| - |
|
2851 |
|
| - |
|
2852 |
var rows=tsRows(t), numeric=false, sawValue=false, allNumeric=true;
|
| - |
|
2853 |
rows.forEach(function(r){
|
| - |
|
2854 |
var txt=tsText(r.cells[idx]);
|
| - |
|
2855 |
if(txt==='' || txt==='—') return;
|
| - |
|
2856 |
sawValue=true;
|
| - |
|
2857 |
if(tsValue(txt)===null) allNumeric=false;
|
| - |
|
2858 |
});
|
| - |
|
2859 |
numeric = sawValue && allNumeric;
|
| - |
|
2860 |
|
| - |
|
2861 |
rows.sort(function(a,b){
|
| - |
|
2862 |
var ta=tsText(a.cells[idx]), tb=tsText(b.cells[idx]);
|
| - |
|
2863 |
var va = numeric ? tsValue(ta) : (ta===''||ta==='—' ? null : ta.toLowerCase());
|
| - |
|
2864 |
var vb = numeric ? tsValue(tb) : (tb===''||tb==='—' ? null : tb.toLowerCase());
|
| - |
|
2865 |
if(va===null && vb===null) return 0;
|
| - |
|
2866 |
if(va===null) return 1; // blanks sit last in BOTH directions, never at the top
|
| - |
|
2867 |
if(vb===null) return -1;
|
| - |
|
2868 |
var c = numeric ? (va-vb) : (va<vb ? -1 : (va>vb ? 1 : 0));
|
| - |
|
2869 |
return dir==='asc' ? c : -c;
|
| - |
|
2870 |
});
|
| - |
|
2871 |
|
| - |
|
2872 |
tsBusy=true;
|
| - |
|
2873 |
var body=t.tBodies[0];
|
| - |
|
2874 |
rows.forEach(function(r){ body.appendChild(r); });
|
| - |
|
2875 |
for(var i=0;i<ths.length;i++){
|
| - |
|
2876 |
var on=(i===idx), label=ths[i].getAttribute('data-tslabel');
|
| - |
|
2877 |
ths[i].classList.toggle('on',on);
|
| - |
|
2878 |
ths[i].innerHTML = esc(label||'') + (on ? '<span class="arrow '+(dir==='asc'?'up':'down')+'"></span>' : '');
|
| - |
|
2879 |
}
|
| - |
|
2880 |
tsBusy=false;
|
| - |
|
2881 |
}
|
| - |
|
2882 |
|
| - |
|
2883 |
function tsFilter(t){
|
| - |
|
2884 |
var bar=t.__tsBar, q=(bar && bar.__input ? bar.__input.value : '').trim().toLowerCase();
|
| - |
|
2885 |
var rows=tsRows(t), shown=0;
|
| - |
|
2886 |
tsBusy=true;
|
| - |
|
2887 |
rows.forEach(function(r){
|
| - |
|
2888 |
var hit = !q || r.textContent.toLowerCase().indexOf(q)>=0;
|
| - |
|
2889 |
r.style.display = hit ? '' : 'none';
|
| - |
|
2890 |
if(hit) shown++;
|
| - |
|
2891 |
});
|
| - |
|
2892 |
// One "no matches" row, reused, so a filter that hides everything does not look like a crash.
|
| - |
|
2893 |
var body=t.tBodies[0], empty=body.querySelector('tr[data-tsempty]');
|
| - |
|
2894 |
if(q && !shown){
|
| - |
|
2895 |
if(!empty){
|
| - |
|
2896 |
empty=document.createElement('tr');
|
| - |
|
2897 |
empty.setAttribute('data-tsempty','1');
|
| - |
|
2898 |
empty.innerHTML='<td colspan="'+t.tHead.rows[0].cells.length+'"><div class="tsempty">No rows match “'+esc(q)+'”.</div></td>';
|
| - |
|
2899 |
body.appendChild(empty);
|
| - |
|
2900 |
} else {
|
| - |
|
2901 |
empty.style.display='';
|
| - |
|
2902 |
empty.firstChild.firstChild.textContent='No rows match “'+q+'”.';
|
| - |
|
2903 |
}
|
| - |
|
2904 |
} else if(empty){ empty.style.display='none'; }
|
| - |
|
2905 |
if(bar && bar.__count){
|
| - |
|
2906 |
var txt = q ? (shown+' of '+rows.length+' shown') : (rows.length+(rows.length===1?' row':' rows'));
|
| - |
|
2907 |
if(bar.__count.textContent!==txt) bar.__count.textContent=txt;
|
| - |
|
2908 |
bar.style.display = (rows.length>=TS_SEARCH_MIN_ROWS || q) ? '' : 'none';
|
| - |
|
2909 |
}
|
| - |
|
2910 |
tsBusy=false;
|
| - |
|
2911 |
}
|
| - |
|
2912 |
|
| - |
|
2913 |
function tsEnhance(t){
|
| - |
|
2914 |
t.setAttribute('data-ts','1');
|
| - |
|
2915 |
var ths=t.tHead.rows[0].cells;
|
| - |
|
2916 |
for(var i=0;i<ths.length;i++){
|
| - |
|
2917 |
(function(th,idx){
|
| - |
|
2918 |
th.setAttribute('data-tslabel', th.textContent.trim());
|
| - |
|
2919 |
th.classList.add('tsort');
|
| - |
|
2920 |
th.addEventListener('click', function(){ tsSort(t, idx); });
|
| - |
|
2921 |
})(ths[i], i);
|
| - |
|
2922 |
}
|
| - |
|
2923 |
// The toolbar goes OUTSIDE .pipewrap — inside it, it would scroll sideways with the table.
|
| - |
|
2924 |
var host = (t.parentNode && t.parentNode.classList && t.parentNode.classList.contains('pipewrap'))
|
| - |
|
2925 |
? t.parentNode : t;
|
| - |
|
2926 |
if(!host.parentNode) return;
|
| - |
|
2927 |
// A drawer table's filter belongs in the drawer header, beside the title that says what the
|
| - |
|
2928 |
// list is — not floating above the first row, where it pushes the table down and reads as
|
| - |
|
2929 |
// part of the data. Only the first table claims the slot; a report drawer showing two keeps the
|
| - |
|
2930 |
// second one's filter inline, where it stays obvious which table it filters.
|
| - |
|
2931 |
var slot = t.closest('#drawer') ? byId('dTools') : null;
|
| - |
|
2932 |
if(slot && slot.childElementCount) slot=null;
|
| - |
|
2933 |
var bar=document.createElement('div');
|
| - |
|
2934 |
bar.className='tstools';
|
| - |
|
2935 |
var input=document.createElement('input');
|
| - |
|
2936 |
input.type='text'; input.className='inp'; input.placeholder='Filter these rows…';
|
| - |
|
2937 |
var count=document.createElement('span'); count.className='tscount';
|
| - |
|
2938 |
bar.appendChild(input); bar.appendChild(count);
|
| - |
|
2939 |
bar.__input=input; bar.__count=count; t.__tsBar=bar;
|
| - |
|
2940 |
input.addEventListener('input', function(){ tsFilter(t); });
|
| - |
|
2941 |
tsBusy=true;
|
| - |
|
2942 |
if(slot) slot.appendChild(bar); else host.parentNode.insertBefore(bar, host);
|
| - |
|
2943 |
tsBusy=false;
|
| - |
|
2944 |
tsFilter(t);
|
| - |
|
2945 |
}
|
| - |
|
2946 |
|
| - |
|
2947 |
/** Enhance any new table, and re-count / re-filter ones whose rows were replaced under us. */
|
| - |
|
2948 |
function tsScan(){
|
| - |
|
2949 |
var tables=document.querySelectorAll('table');
|
| - |
|
2950 |
for(var i=0;i<tables.length;i++){
|
| - |
|
2951 |
var t=tables[i];
|
| - |
|
2952 |
if(tsEligible(t)) tsEnhance(t);
|
| - |
|
2953 |
else if(t.hasAttribute('data-ts') && t.__tsBar) tsFilter(t);
|
| - |
|
2954 |
}
|
| - |
|
2955 |
}
|
| - |
|
2956 |
|
| - |
|
2957 |
(function(){
|
| - |
|
2958 |
var obs=null, queued=false;
|
| - |
|
2959 |
function run(){
|
| - |
|
2960 |
queued=false;
|
| - |
|
2961 |
tsScan();
|
| - |
|
2962 |
// A MutationObserver delivers asynchronously, so tsBusy alone cannot stop a scan from seeing
|
| - |
|
2963 |
// its own writes and scanning again. Dropping the records we just produced ends it here.
|
| - |
|
2964 |
if(obs) obs.takeRecords();
|
| - |
|
2965 |
}
|
| - |
|
2966 |
tsScan();
|
| - |
|
2967 |
if(typeof MutationObserver!=='function') return;
|
| - |
|
2968 |
obs=new MutationObserver(function(){
|
| - |
|
2969 |
if(queued) return; // at most one scan per frame
|
| - |
|
2970 |
queued=true;
|
| - |
|
2971 |
requestAnimationFrame(run);
|
| - |
|
2972 |
});
|
| - |
|
2973 |
obs.observe(document.body, {childList:true, subtree:true});
|
| - |
|
2974 |
})();
|
| - |
|
2975 |
|
| 2664 |
// ---- initial paint from seeds, pipeline loads live
|
2976 |
// ---- initial paint from seeds, pipeline loads live
|
| 2665 |
renderSummary(SEED.summary); renderState(SEED.state); renderHeat(SEED.heat); renderAgeing(SEED.ageing);
|
2977 |
renderSummary(SEED.summary); renderState(SEED.state); renderHeat(SEED.heat); renderAgeing(SEED.ageing);
|
| 2666 |
byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||'');
|
2978 |
setDashPeriod();
|
| 2667 |
loadPipeline();
|
2979 |
loadPipeline();
|
| 2668 |
|
2980 |
|
| 2669 |
// ---- deep-link: a URL hash such as dash or record opens that tab
|
2981 |
// ---- deep-link: a URL hash such as dash or record opens that tab
|
| 2670 |
(function(){ var TABIDX={cmd:0,record:1,create:2,assign:3,dash:4,audit:5};
|
2982 |
(function(){ var TABIDX={cmd:0,record:1,create:2,assign:3,dash:4,audit:5};
|
| 2671 |
var h=(location.hash||'').replace('#','').trim();
|
2983 |
var h=(location.hash||'').replace('#','').trim();
|