| Line 51... |
Line 51... |
| 51 |
.tab.active{color:var(--navy);border-bottom-color:var(--coral)}
|
51 |
.tab.active{color:var(--navy);border-bottom-color:var(--coral)}
|
| 52 |
|
52 |
|
| 53 |
/* ===== generic ===== */
|
53 |
/* ===== generic ===== */
|
| 54 |
.crumb{font-size:12px;color:var(--soft);font-weight:600}
|
54 |
.crumb{font-size:12px;color:var(--soft);font-weight:600}
|
| 55 |
h1.page{margin:2px 0 2px;font-size:23px;font-weight:760;letter-spacing:-.01em}
|
55 |
h1.page{margin:2px 0 2px;font-size:23px;font-weight:760;letter-spacing:-.01em}
|
| - |
|
56 |
/* Title block and filters share one line: the title block shrinks (its subtitle wraps) rather than
|
| - |
|
57 |
pushing the filters onto a second row. Only a narrow viewport is allowed to wrap. */
|
| 56 |
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
58 |
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:nowrap;margin-bottom:18px}
|
| - |
|
59 |
.pagehead > div:first-child{min-width:0;flex:1 1 auto}
|
| - |
|
60 |
.pagehead .hd-right{flex:0 0 auto}
|
| - |
|
61 |
.pagehead .hd-right select.ctl{max-width:200px;text-overflow:ellipsis}
|
| - |
|
62 |
@media (max-width:1100px){ .pagehead{flex-wrap:wrap} }
|
| 57 |
.pagehead .sub{color:var(--muted);font-size:13px;margin-top:2px}
|
63 |
.pagehead .sub{color:var(--muted);font-size:13px;margin-top:2px}
|
| - |
|
64 |
/* When the filter row wraps under the title it must still hug the right edge, not restart at the left. */
|
| 58 |
.hd-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
65 |
.hd-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto}
|
| 59 |
.ctl{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;font-size:13px;color:var(--ink);font-weight:600}
|
66 |
.ctl{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;font-size:13px;color:var(--ink);font-weight:600}
|
| 60 |
.ctl .lbl{color:var(--soft);font-weight:500;margin-right:5px}
|
67 |
.ctl .lbl{color:var(--soft);font-weight:500;margin-right:5px}
|
| 61 |
select.ctl{appearance:none;cursor:pointer;padding-right:26px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 10px center}
|
68 |
select.ctl{appearance:none;cursor:pointer;padding-right:26px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 10px center}
|
| 62 |
.btn{background:var(--navy);border:0;color:#fff;font-weight:650;padding:9px 15px;border-radius:9px;cursor:pointer;font-size:13px}
|
69 |
.btn{background:var(--navy);border:0;color:#fff;font-weight:650;padding:9px 15px;border-radius:9px;cursor:pointer;font-size:13px}
|
| 63 |
.btn.coral{background:var(--coral)}
|
70 |
.btn.coral{background:var(--coral)}
|
| Line 431... |
Line 438... |
| 431 |
<div class="crumb">Sales · Lead Pipeline</div>
|
438 |
<div class="crumb">Sales · Lead Pipeline</div>
|
| 432 |
<h1 class="page">Command Center</h1>
|
439 |
<h1 class="page">Command Center</h1>
|
| 433 |
<div class="sub">Every lead you own or oversee — from creation to onboarding. SLA clock runs on all NEW records.</div>
|
440 |
<div class="sub">Every lead you own or oversee — from creation to onboarding. SLA clock runs on all NEW records.</div>
|
| 434 |
</div>
|
441 |
</div>
|
| 435 |
<div class="hd-right">
|
442 |
<div class="hd-right">
|
| - |
|
443 |
<select class="ctl" id="fAgent" title="Call Center user — every number below narrows to leads they created or currently own">
|
| - |
|
444 |
<option value="">All Call Center users</option>
|
| - |
|
445 |
</select>
|
| 436 |
<select class="ctl" id="fRegion">
|
446 |
<select class="ctl" id="fRegion">
|
| 437 |
<option value="">All regions</option>
|
447 |
<option value="">All regions</option>
|
| 438 |
#foreach($r in $regions)
|
448 |
#foreach($r in $regions)
|
| 439 |
<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>
|
449 |
<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>
|
| 440 |
#end
|
450 |
#end
|
| Line 456... |
Line 466... |
| 456 |
</div>
|
466 |
</div>
|
| 457 |
|
467 |
|
| 458 |
<div class="card">
|
468 |
<div class="card">
|
| 459 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
469 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
| 460 |
<span style="display:flex;gap:6px;align-items:center">
|
470 |
<span style="display:flex;gap:6px;align-items:center">
|
| 461 |
<select id="pipeAgent" class="inp" style="font-size:12.5px;padding:7px 28px 7px 10px;width:190px" onchange="pipeAgentChanged()" title="Call Center user — leads they created or currently own">
|
- |
|
| 462 |
<option value="">Call Center user…</option>
|
- |
|
| 463 |
</select>
|
- |
|
| 464 |
<input id="pipeQ" class="inp" style="width:210px;font-size:12.5px;padding:7px 10px"
|
471 |
<input id="pipeQ" class="inp" style="width:210px;font-size:12.5px;padding:7px 10px"
|
| 465 |
placeholder="Name, outlet, mobile, city, created by">
|
472 |
placeholder="Name, outlet, mobile, city, created by">
|
| 466 |
<button class="btn ghost sm" onclick="pipeSearch()">Search</button>
|
473 |
<button class="btn ghost sm" onclick="pipeSearch()">Search</button>
|
| 467 |
<button class="btn ghost sm hide" id="pipeClear" onclick="pipeClearSearch()">Clear</button>
|
474 |
<button class="btn ghost sm hide" id="pipeClear" onclick="pipeClearSearch()">Clear</button>
|
| 468 |
</span>
|
475 |
</span>
|
| Line 1074... |
Line 1081... |
| 1074 |
<div class="toastwrap" id="toasts"></div>
|
1081 |
<div class="toastwrap" id="toasts"></div>
|
| 1075 |
|
1082 |
|
| 1076 |
<script>
|
1083 |
<script>
|
| 1077 |
var CTX = "${rc.contextPath}";
|
1084 |
var CTX = "${rc.contextPath}";
|
| 1078 |
var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
|
1085 |
var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
|
| 1079 |
var STATE = { regionId:'', from:'$!fromDate', to:'$!toDate' };
|
1086 |
var STATE = { regionId:'', agentId:'', from:'$!fromDate', to:'$!toDate' };
|
| 1080 |
var DATA = { summary:null, state:null, heat:null, ageing:null };
|
1087 |
var DATA = { summary:null, state:null, heat:null, ageing:null };
|
| 1081 |
|
1088 |
|
| 1082 |
function byId(x){ return document.getElementById(x); }
|
1089 |
function byId(x){ return document.getElementById(x); }
|
| 1083 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
1090 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
| 1084 |
function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
|
1091 |
function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
|
| Line 1134... |
Line 1141... |
| 1134 |
t.innerHTML=(ok?'<span class="ok">✓</span>':'')+esc(msg); w.appendChild(t);
|
1141 |
t.innerHTML=(ok?'<span class="ok">✓</span>':'')+esc(msg); w.appendChild(t);
|
| 1135 |
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity .3s'; setTimeout(function(){ t.remove(); },300); },2600); }
|
1142 |
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity .3s'; setTimeout(function(){ t.remove(); },300); },2600); }
|
| 1136 |
|
1143 |
|
| 1137 |
// ---- filters + fetch
|
1144 |
// ---- filters + fetch
|
| 1138 |
function qs(){ var q=''; if(STATE.regionId)q+='®ionId='+encodeURIComponent(STATE.regionId);
|
1145 |
function qs(){ var q=''; if(STATE.regionId)q+='®ionId='+encodeURIComponent(STATE.regionId);
|
| - |
|
1146 |
if(STATE.agentId)q+='&agentId='+encodeURIComponent(STATE.agentId);
|
| 1139 |
if(STATE.from)q+='&from='+encodeURIComponent(STATE.from); if(STATE.to)q+='&to='+encodeURIComponent(STATE.to); return q; }
|
1147 |
if(STATE.from)q+='&from='+encodeURIComponent(STATE.from); if(STATE.to)q+='&to='+encodeURIComponent(STATE.to); return q; }
|
| 1140 |
function getJSON(path,cb,onErr){ fetch(CTX+path,{credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'}})
|
1148 |
function getJSON(path,cb,onErr){ fetch(CTX+path,{credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'}})
|
| 1141 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
1149 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
| 1142 |
.then(function(j){ cb((j&&j.response!==undefined)?j.response:j); })
|
1150 |
.then(function(j){ cb((j&&j.response!==undefined)?j.response:j); })
|
| 1143 |
.catch(function(e){ console.error('LMS fetch failed',path,e);
|
1151 |
.catch(function(e){ console.error('LMS fetch failed',path,e);
|
| Line 1203... |
Line 1211... |
| 1203 |
|
1211 |
|
| 1204 |
// ---- Lead pipeline: filtered, sorted and paged on the server ------------------------------
|
1212 |
// ---- Lead pipeline: filtered, sorted and paged on the server ------------------------------
|
| 1205 |
// PIPE.q is the search term. While it is set the date filter does not apply — a search hunts for
|
1213 |
// PIPE.q is the search term. While it is set the date filter does not apply — a search hunts for
|
| 1206 |
// one known retailer, and quietly scoping it to the selected month would report "not found" for a
|
1214 |
// one known retailer, and quietly scoping it to the selected month would report "not found" for a
|
| 1207 |
// lead that exists. The caption says which mode is in force so that is never a surprise.
|
1215 |
// lead that exists. The caption says which mode is in force so that is never a surprise.
|
| 1208 |
var PIPE = { q:'', agentId:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
|
1216 |
var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
|
| 1209 |
// Call Center users for the pipeline filter. "Their" leads = created by them OR owned by them.
|
1217 |
// Call Center users for the page-level agent filter (header). "Their" leads = created by them OR
|
| - |
|
1218 |
// owned by them; qs() carries it, so cards, funnel, attention queue, pipeline and every drawer
|
| - |
|
1219 |
// narrow together.
|
| 1210 |
function loadCallCenterUsers(){
|
1220 |
function loadCallCenterUsers(){
|
| 1211 |
getJSON('/lms/dashboard/callcenter-users', function(d){
|
1221 |
getJSON('/lms/dashboard/callcenter-users', function(d){
|
| 1212 |
var sel=byId('pipeAgent'); if(!sel) return; var keep=sel.value;
|
1222 |
var sel=byId('fAgent'); if(!sel) return; var keep=sel.value;
|
| 1213 |
var users=(d&&d.users)||[];
|
1223 |
var users=(d&&d.users)||[];
|
| 1214 |
var h='<option value="">'+(users.length?'Call Center user…':'No Call Center users found')+'</option>';
|
1224 |
var h='<option value="">'+(users.length?'All Call Center users':'No Call Center users found')+'</option>';
|
| 1215 |
users.forEach(function(u){ h+='<option value="'+u.authId+'">'+esc(u.name)+'</option>'; });
|
1225 |
users.forEach(function(u){ h+='<option value="'+u.authId+'">'+esc(u.name)+'</option>'; });
|
| 1216 |
sel.innerHTML=h; sel.value=keep||'';
|
1226 |
sel.innerHTML=h; sel.value=keep||'';
|
| 1217 |
}, function(){ var sel=byId('pipeAgent'); if(sel) sel.innerHTML='<option value="">Call Center list unavailable</option>'; });
|
1227 |
}, function(){ var sel=byId('fAgent'); if(sel) sel.innerHTML='<option value="">Call Center list unavailable</option>'; });
|
| 1218 |
}
|
1228 |
}
|
| - |
|
1229 |
// Search mode ignores the date window but keeps the scope filters (region + agent).
|
| 1219 |
function pipeAgentChanged(){ PIPE.agentId=byId('pipeAgent').value||''; PIPE.page=1; loadPipeline(); }
|
1230 |
function scopeQs(){ var q=''; if(STATE.regionId) q+='®ionId='+encodeURIComponent(STATE.regionId);
|
| 1220 |
function agentQs(){ return PIPE.agentId ? ('&agentId='+encodeURIComponent(PIPE.agentId)) : ''; }
|
1231 |
if(STATE.agentId) q+='&agentId='+encodeURIComponent(STATE.agentId); return q; }
|
| 1221 |
|
1232 |
|
| 1222 |
function loadPipeline(){
|
1233 |
function loadPipeline(){
|
| 1223 |
byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">Loading…</div></td></tr>';
|
1234 |
byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">Loading…</div></td></tr>';
|
| 1224 |
var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
|
1235 |
var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
|
| 1225 |
'&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir)+agentQs();
|
1236 |
'&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);
|
| 1226 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q);
|
1237 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q)+scopeQs(); }
|
| 1227 |
if(STATE.regionId) p+='®ionId='+encodeURIComponent(STATE.regionId); }
|
- |
|
| 1228 |
else { p+=qs(); }
|
1238 |
else { p+=qs(); }
|
| 1229 |
getJSON(p,function(d){
|
1239 |
getJSON(p,function(d){
|
| 1230 |
d=d||{}; var rows=d.rows||[];
|
1240 |
d=d||{}; var rows=d.rows||[];
|
| 1231 |
PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
|
1241 |
PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
|
| 1232 |
renderPipeHead();
|
1242 |
renderPipeHead();
|
| Line 1538... |
Line 1548... |
| 1538 |
closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
|
1548 |
closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
|
| 1539 |
go('record',document.querySelector('.tab[data-screen="record"]')); return; }
|
1549 |
go('record',document.querySelector('.tab[data-screen="record"]')); return; }
|
| 1540 |
});
|
1550 |
});
|
| 1541 |
|
1551 |
|
| 1542 |
// ---- filters
|
1552 |
// ---- filters
|
| 1543 |
function refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';
|
1553 |
function refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.agentId=byId('fAgent').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';
|
| 1544 |
getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();
|
1554 |
getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();
|
| 1545 |
getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);
|
1555 |
getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);
|
| 1546 |
getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);
|
1556 |
getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);
|
| 1547 |
getJSON('/lms/dashboard/ageing',renderAgeing);
|
1557 |
getJSON('/lms/dashboard/ageing',renderAgeing);
|
| 1548 |
setDashPeriod(); }
|
1558 |
setDashPeriod(); }
|
| 1549 |
['fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });
|
1559 |
['fAgent','fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });
|
| 1550 |
|
1560 |
|
| 1551 |
// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================
|
1561 |
// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================
|
| 1552 |
var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],
|
1562 |
var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],
|
| 1553 |
['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];
|
1563 |
['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];
|
| 1554 |
function fmtDT(s){ if(!s) return '—'; var d=new Date(s); if(isNaN(d)) return esc(s);
|
1564 |
function fmtDT(s){ if(!s) return '—'; var d=new Date(s); if(isNaN(d)) return esc(s);
|
| Line 2118... |
Line 2128... |
| 2118 |
// ---- Export (SOP §18.1) -------------------------------------------------------------------
|
2128 |
// ---- Export (SOP §18.1) -------------------------------------------------------------------
|
| 2119 |
// Exports what is on screen — the current filters, search and sort — rather than everything,
|
2129 |
// Exports what is on screen — the current filters, search and sort — rather than everything,
|
| 2120 |
// because an export that ignores the filters is a different report than the one being looked at.
|
2130 |
// because an export that ignores the filters is a different report than the one being looked at.
|
| 2121 |
function exportPipeline(){
|
2131 |
function exportPipeline(){
|
| 2122 |
var p='/lms/dashboard/pipeline?page=1&pageSize=500&sort='+encodeURIComponent(PIPE.sort)+
|
2132 |
var p='/lms/dashboard/pipeline?page=1&pageSize=500&sort='+encodeURIComponent(PIPE.sort)+
|
| 2123 |
'&dir='+encodeURIComponent(PIPE.dir)+agentQs();
|
2133 |
'&dir='+encodeURIComponent(PIPE.dir);
|
| 2124 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q); if(STATE.regionId) p+='®ionId='+encodeURIComponent(STATE.regionId); }
|
2134 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q)+scopeQs(); }
|
| 2125 |
else { p+=qs(); }
|
2135 |
else { p+=qs(); }
|
| 2126 |
toast('Preparing export…');
|
2136 |
toast('Preparing export…');
|
| 2127 |
getJSON(p,function(d){
|
2137 |
getJSON(p,function(d){
|
| 2128 |
var rows=(d&&d.rows)?d.rows:[];
|
2138 |
var rows=(d&&d.rows)?d.rows:[];
|
| 2129 |
if(!rows.length){ toast('Nothing to export'); return; }
|
2139 |
if(!rows.length){ toast('Nothing to export'); return; }
|