| Line 1... |
Line 1... |
| 1 |
## LMS Dashboard — STANDALONE full page. Faithful copy of sts-mockup.html design (app bar + tabs + hand-drawn
|
1 |
## LMS Dashboard — STANDALONE full page. Faithful copy of sts-mockup.html design (app bar + tabs + hand-drawn
|
| 2 |
## funnel/heat-map, NO charts), wired to REAL lead data. Rendered as a complete HTML doc (layout.vm = $screen_content).
|
2 |
## funnel/heat-map, NO charts), wired to REAL lead data. Rendered as a complete HTML doc (layout.vm = $screen_content).
|
| 3 |
## Served at GET /lms/dashboard. Seeds: regions[], summaryJson, stateFunnelJson, slaHeatmapJson, ageingJson, fromDate, toDate.
|
3 |
## Served at GET /lms/dashboard. Seeds: regions[], summaryJson, stateFunnelJson, slaHeatmapJson, ageingJson, fromDate, toDate,
|
| - |
|
4 |
## viewerName / viewerRole / viewerInitials (signed-in user for the app bar, pre-escaped).
|
| 4 |
<!DOCTYPE html>
|
5 |
<!DOCTYPE html>
|
| 5 |
<html lang="en">
|
6 |
<html lang="en">
|
| 6 |
<head>
|
7 |
<head>
|
| 7 |
<meta charset="utf-8">
|
8 |
<meta charset="utf-8">
|
| 8 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
9 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| Line 393... |
Line 394... |
| 393 |
<input id="lmsSearch" placeholder="Search LMS ID, business, phone, retailer… e.g. LMS-UPW-26-000123">
|
394 |
<input id="lmsSearch" placeholder="Search LMS ID, business, phone, retailer… e.g. LMS-UPW-26-000123">
|
| 394 |
<span class="hint">/ to focus</span>
|
395 |
<span class="hint">/ to focus</span>
|
| 395 |
</div>
|
396 |
</div>
|
| 396 |
<button class="abtn coral" onclick="window.open(CTX+'/getOpenLead','_blank')">+ New Lead</button>
|
397 |
<button class="abtn coral" onclick="window.open(CTX+'/getOpenLead','_blank')">+ New Lead</button>
|
| 397 |
<div class="role">
|
398 |
<div class="role">
|
| 398 |
<div class="av">SD</div>
|
399 |
<div class="av" title="Signed in as $!viewerName">$!viewerInitials</div>
|
| 399 |
<div class="who"><b>Lead Dashboard</b><br><span>live · real data</span></div>
|
400 |
<div class="who"><b>$!viewerName</b><br><span>$!viewerRole</span></div>
|
| 400 |
</div>
|
401 |
</div>
|
| 401 |
</div>
|
402 |
</div>
|
| 402 |
</div>
|
403 |
</div>
|
| 403 |
|
404 |
|
| 404 |
<!-- ============ TABS ============ -->
|
405 |
<!-- ============ TABS ============ -->
|
| Line 437... |
Line 438... |
| 437 |
</div>
|
438 |
</div>
|
| 438 |
</div>
|
439 |
</div>
|
| 439 |
|
440 |
|
| 440 |
<div class="kpis" id="ccKpis"></div>
|
441 |
<div class="kpis" id="ccKpis"></div>
|
| 441 |
|
442 |
|
| 442 |
<div class="sectionlabel">Lifecycle Funnel — selected period</div>
|
443 |
<div class="sectionlabel">Lifecycle Funnel — selected period · beat, visit & onboarding read from source tables</div>
|
| 443 |
<div class="card"><div class="funnel" id="ccFunnel"></div></div>
|
444 |
<div class="card"><div class="funnel" id="ccFunnel"></div></div>
|
| 444 |
|
445 |
|
| 445 |
<div class="card">
|
446 |
<div class="card">
|
| 446 |
<h2>Needs attention now</h2>
|
447 |
<h2>Needs attention now</h2>
|
| 447 |
<p class="desc">Actionable queues · click to work them.</p>
|
448 |
<p class="desc">Actionable queues for the selected period & region · click to work them.</p>
|
| 448 |
<div id="ccAttention" class="attstrip"></div>
|
449 |
<div id="ccAttention" class="attstrip"></div>
|
| 449 |
</div>
|
450 |
</div>
|
| 450 |
|
451 |
|
| 451 |
<div class="card">
|
452 |
<div class="card">
|
| 452 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
453 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
| Line 515... |
Line 516... |
| 515 |
<div id="create" class="hide">
|
516 |
<div id="create" class="hide">
|
| 516 |
<div class="pagehead">
|
517 |
<div class="pagehead">
|
| 517 |
<div>
|
518 |
<div>
|
| 518 |
<div class="crumb">Create · New Lead</div>
|
519 |
<div class="crumb">Create · New Lead</div>
|
| 519 |
<h1 class="page">Create Lead</h1>
|
520 |
<h1 class="page">Create Lead</h1>
|
| 520 |
<div class="sub">Only what an agent can know on a first call. LMS ID is generated on submit and the lead is auto-assigned from region. Address, store photo and geo-pin come from the capture link, which is created automatically and copied to your clipboard.</div>
|
521 |
<div class="sub">What an agent can know on a first call, plus the shop details the Leads screen captures. LMS ID is generated on submit and the lead is auto-assigned from region. Address, store photo and geo-pin come from the capture link, which is created automatically and copied to your clipboard.</div>
|
| 521 |
</div>
|
522 |
</div>
|
| 522 |
<div class="hd-right">
|
523 |
<div class="hd-right">
|
| 523 |
<div class="ctl" style="padding:4px;display:flex;gap:4px">
|
524 |
<div class="ctl" style="padding:4px;display:flex;gap:4px">
|
| 524 |
<button class="btn coral sm" id="pathA" style="border-radius:7px" onclick="setPath('A',this)">Path A · BGC outbound</button>
|
525 |
<button class="btn coral sm" id="pathA" style="border-radius:7px" onclick="setPath('A',this)">Path A · BGC outbound</button>
|
| 525 |
<button class="btn ghost sm" id="pathB" style="border-radius:7px" onclick="setPath('B',this)">Path B · Field encounter</button>
|
526 |
<button class="btn ghost sm" id="pathB" style="border-radius:7px" onclick="setPath('B',this)">Path B · Field encounter</button>
|
| Line 547... |
Line 548... |
| 547 |
#end
|
548 |
#end
|
| 548 |
</div>
|
549 |
</div>
|
| 549 |
<div class="hinttext">Type in rupees per month — 100000 or 1L, 50k both work. Leave a brand blank if it is not stocked.</div>
|
550 |
<div class="hinttext">Type in rupees per month — 100000 or 1L, 50k both work. Leave a brand blank if it is not stocked.</div>
|
| 550 |
</div>
|
551 |
</div>
|
| 551 |
<div class="fld"><label>Interest status</label><select class="inp" id="cInterest"><option>Interested</option><option>Callback</option><option>Not Interested</option></select></div>
|
552 |
<div class="fld"><label>Interest status</label><select class="inp" id="cInterest"><option>Interested</option><option>Callback</option><option>Not Interested</option></select></div>
|
| - |
|
553 |
<div class="fld"><label>Source</label>
|
| - |
|
554 |
<select class="inp" id="cSource">
|
| - |
|
555 |
<option value="LMS Dashboard" selected>LMS Dashboard</option>
|
| - |
|
556 |
<option value="SELF">SELF</option><option value="NEWSPAPER">NEWSPAPER</option><option value="INTERNET">INTERNET</option>
|
| - |
|
557 |
<option value="PARTNER">PARTNER</option><option value="WEBINAR">WEBINAR</option><option value="TRIAL">TRIAL</option>
|
| - |
|
558 |
<option value="WHATSAPP_CAMPAIGN">WHATSAPP CAMPAIGN</option><option value="RETAILER_MEET">RETAILER MEET</option>
|
| - |
|
559 |
<option value="AI Assistant">AI Assistant</option>
|
| - |
|
560 |
</select></div>
|
| - |
|
561 |
<div class="fld full"><label>Address</label><input class="inp" id="cAddress" placeholder="Shop address" maxlength="128"></div>
|
| - |
|
562 |
<div class="fld"><label>City</label><input class="inp" id="cCity" placeholder="City" maxlength="30"></div>
|
| - |
|
563 |
<div class="fld"><label>State</label>
|
| - |
|
564 |
<select class="inp" id="cState">
|
| - |
|
565 |
<option value="">Select a state…</option>
|
| - |
|
566 |
#foreach($sn in $stateNames)
|
| - |
|
567 |
<option value="$sn">$sn</option>
|
| - |
|
568 |
#end
|
| - |
|
569 |
</select></div>
|
| - |
|
570 |
<div class="fld"><label>Conversion probability</label>
|
| - |
|
571 |
<select class="inp" id="cConversion"><option value="false" selected>Low</option><option value="true">High</option></select></div>
|
| - |
|
572 |
<div class="fld"><label>Counter size</label><input class="inp mono" id="cCounter" placeholder="Number of counters" inputmode="numeric"></div>
|
| - |
|
573 |
<div class="fld full"><label>Remark</label><input class="inp" id="cRemark" placeholder="Anything the BM should know before the first call" maxlength="500"></div>
|
| - |
|
574 |
<div class="fld full">
|
| - |
|
575 |
<label>Store photos <span class="auto">optional — same five shots as the Leads screen</span></label>
|
| - |
|
576 |
<div class="brandgrid" id="cPhotoGrid">
|
| - |
|
577 |
<div class="brandcell"><span class="bname">Front</span><input type="file" accept="image/*" class="inp cphoto" data-photo="frontp"></div>
|
| - |
|
578 |
<div class="brandcell"><span class="bname">Internal with market</span><input type="file" accept="image/*" class="inp cphoto" data-photo="frontWithMarket"></div>
|
| - |
|
579 |
<div class="brandcell"><span class="bname">Internal long shot</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalLongShot"></div>
|
| - |
|
580 |
<div class="brandcell"><span class="bname">Internal left wall</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalLeftWall"></div>
|
| - |
|
581 |
<div class="brandcell"><span class="bname">Internal right wall</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalRightWall"></div>
|
| - |
|
582 |
</div>
|
| - |
|
583 |
<div class="hinttext">Photos upload when you submit. Skip them on a call — the capture link collects them from the retailer.</div>
|
| - |
|
584 |
</div>
|
| 552 |
<div class="fld full">
|
585 |
<div class="fld full">
|
| 553 |
<label>Region / State <span class="m">*</span> <span class="auto">drives auto-assignment</span></label>
|
586 |
<label>Region / State <span class="m">*</span> <span class="auto">drives auto-assignment</span></label>
|
| 554 |
<select class="inp" id="cRegion" onchange="resolveBM(this.value)">
|
587 |
<select class="inp" id="cRegion" onchange="resolveBM(this.value)">
|
| 555 |
<option value="">Select a region…</option>
|
588 |
<option value="">Select a region…</option>
|
| 556 |
#foreach($r in $regions)
|
589 |
#foreach($r in $regions)
|
| Line 726... |
Line 759... |
| 726 |
<div class="kpis" id="dashKpis"></div>
|
759 |
<div class="kpis" id="dashKpis"></div>
|
| 727 |
|
760 |
|
| 728 |
<div class="sectionlabel">State funnel — NEW → ONBOARDED by region</div>
|
761 |
<div class="sectionlabel">State funnel — NEW → ONBOARDED by region</div>
|
| 729 |
<div class="card">
|
762 |
<div class="card">
|
| 730 |
<table>
|
763 |
<table>
|
| 731 |
<thead><tr><th>Region</th><th class="num">New</th><th class="num">Contacted</th><th class="num">Qualified</th><th class="num">Visited</th><th class="num">Onboarded</th><th class="num">Conv%</th><th class="num">SLA%</th></tr></thead>
|
764 |
<thead><tr><th>Region</th><th class="num">Leads</th><th class="num">Contacted</th><th class="num">Qualified</th><th class="num">Beat</th><th class="num">Visited</th><th class="num">Onboarded</th><th class="num">Conv%</th><th class="num">SLA%</th></tr></thead>
|
| 732 |
<tbody id="dashState"></tbody>
|
765 |
<tbody id="dashState"></tbody>
|
| 733 |
</table>
|
766 |
</table>
|
| 734 |
</div>
|
767 |
</div>
|
| 735 |
|
768 |
|
| 736 |
<div class="grid g2">
|
769 |
<div class="grid g2">
|
| Line 1121... |
Line 1154... |
| 1121 |
buildDashKpis(); buildFix();
|
1154 |
buildDashKpis(); buildFix();
|
| 1122 |
}
|
1155 |
}
|
| 1123 |
|
1156 |
|
| 1124 |
function renderFunnel(funnel){
|
1157 |
function renderFunnel(funnel){
|
| 1125 |
var byStage={}; funnel.forEach(function(s){ byStage[s.stage]=s; });
|
1158 |
var byStage={}; funnel.forEach(function(s){ byStage[s.stage]=s; });
|
| - |
|
1159 |
// First box is every lead created in the period (server key ALL) -- not the NEW stage, which is
|
| - |
|
1160 |
// the "New" KPI card. BEAT_PLANNED / VISITED / ONBOARDED are evidence-derived on the server.
|
| 1126 |
var seq=[['ALL','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],['VISITED','Visited'],['ONBOARDED','Onboarded']];
|
1161 |
var seq=[['ALL','All leads'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],['BEAT_PLANNED','Beat planned'],['VISITED','Visited'],['ONBOARDED','Onboarded']];
|
| 1127 |
var h=''; var prev=null;
|
1162 |
var h=''; var prev=null;
|
| 1128 |
seq.forEach(function(p,idx){ var s=byStage[p[0]]; if(!s) return; var last=(idx===seq.length-1);
|
1163 |
seq.forEach(function(p,idx){ var s=byStage[p[0]]; if(!s) return; var last=(idx===seq.length-1);
|
| 1129 |
var cap; if(idx===0){ cap='<div class="keep">created</div>'; }
|
1164 |
var cap; if(idx===0){ cap='<div class="keep">created</div>'; }
|
| 1130 |
else if(last){ cap='<div class="keep">'+s.pct+'% conversion</div>'; }
|
1165 |
else if(last){ cap='<div class="keep">'+s.pct+'% conversion</div>'; }
|
| 1131 |
else { var drop=prev?prev.count-s.count:0; cap=drop>0?('<div class="drop">−'+fmtInt(drop)+'</div>'):('<div class="keep">'+s.pct+'%</div>'); }
|
1166 |
else { var drop=prev?prev.count-s.count:0; cap=drop>0?('<div class="drop">−'+fmtInt(drop)+'</div>'):('<div class="keep">'+s.pct+'%</div>'); }
|
| Line 1250... |
Line 1285... |
| 1250 |
function renderState(d){ DATA.state=d; var rows=(d&&d.rows)?d.rows:[];
|
1285 |
function renderState(d){ DATA.state=d; var rows=(d&&d.rows)?d.rows:[];
|
| 1251 |
var h=''; rows.forEach(function(r){ var cls=r.convPct<15?'rowbad':(r.slaPct<60?'rowwarn':'');
|
1286 |
var h=''; rows.forEach(function(r){ var cls=r.convPct<15?'rowbad':(r.slaPct<60?'rowwarn':'');
|
| 1252 |
h+='<tr class="clickrow '+cls+'" data-region="'+esc(r.regionCode)+'" data-rn="'+esc(r.regionName)+'">'+
|
1287 |
h+='<tr class="clickrow '+cls+'" data-region="'+esc(r.regionCode)+'" data-rn="'+esc(r.regionName)+'">'+
|
| 1253 |
'<td><b>'+esc(r.regionName)+'</b> <span class="idpill">'+esc(r.regionCode)+'</span></td>'+
|
1288 |
'<td><b>'+esc(r.regionName)+'</b> <span class="idpill">'+esc(r.regionCode)+'</span></td>'+
|
| 1254 |
'<td class="num">'+fmtInt(r.newCount)+'</td><td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
1289 |
'<td class="num">'+fmtInt(r.newCount)+'</td><td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
| 1255 |
'<td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
1290 |
'<td class="num">'+fmtInt(r.beatPlanned)+'</td><td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
| 1256 |
'<td class="num"><b style="color:'+pctColor(r.convPct)+'">'+r.convPct+'%</b></td>'+
|
1291 |
'<td class="num"><b style="color:'+pctColor(r.convPct)+'">'+r.convPct+'%</b></td>'+
|
| 1257 |
'<td class="num"><b style="color:'+slaColor(r.slaPct)+'">'+r.slaPct+'%</b></td></tr>'; });
|
1292 |
'<td class="num"><b style="color:'+slaColor(r.slaPct)+'">'+r.slaPct+'%</b></td></tr>'; });
|
| 1258 |
byId('dashState').innerHTML=h||'<tr><td colspan="8"><div class="ph">No regional data.</div></td></tr>';
|
1293 |
byId('dashState').innerHTML=h||'<tr><td colspan="9"><div class="ph">No regional data.</div></td></tr>';
|
| 1259 |
buildDashKpis(); buildFix();
|
1294 |
buildDashKpis(); buildFix();
|
| 1260 |
}
|
1295 |
}
|
| 1261 |
|
1296 |
|
| 1262 |
function renderHeat(d){ DATA.heat=d; var weeks=(d&&d.weeks)?d.weeks:[],bms=(d&&d.bms)?d.bms:[];
|
1297 |
function renderHeat(d){ DATA.heat=d; var weeks=(d&&d.weeks)?d.weeks:[],bms=(d&&d.bms)?d.bms:[];
|
| 1263 |
if(!bms.length){ byId('dashHeat').innerHTML='<div class="ph">No SLA data for the selected period.</div>'; return; }
|
1298 |
if(!bms.length){ byId('dashHeat').innerHTML='<div class="ph">No SLA data for the selected period.</div>'; return; }
|
| Line 1354... |
Line 1389... |
| 1354 |
leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
1389 |
leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
| 1355 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
1390 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
| 1356 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td>'+
|
1391 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td>'+
|
| 1357 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
1392 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
| 1358 |
return h+'</tbody></table></div>'; }
|
1393 |
return h+'</tbody></table></div>'; }
|
| - |
|
1394 |
// The drill drawer pages server-side (100 per page), so a bar reading 501 can be walked in full
|
| - |
|
1395 |
// instead of stopping at the first 100. The header filter box only filters the page on screen.
|
| - |
|
1396 |
var DRILL={crumb:'',title:'',sub:'',bt:'',bk:'',page:1,pageSize:100,pages:1,total:0};
|
| - |
|
1397 |
function drill(crumb,title,sub,bt,bk){ DRILL={crumb:crumb,title:title,sub:sub,bt:bt,bk:bk,page:1,pageSize:100,pages:1,total:0}; loadDrill(); }
|
| - |
|
1398 |
function drillPage(delta){ var n=DRILL.page+delta; if(n<1||n>DRILL.pages) return; DRILL.page=n; loadDrill(); }
|
| 1359 |
function drill(crumb,title,sub,bt,bk){ openDrawer(crumb,title,'Loading…','<div class="ph">Loading…</div>');
|
1399 |
function loadDrill(){ var D=DRILL; openDrawer(D.crumb,D.title,'Loading…','<div class="ph">Loading…</div>');
|
| 1360 |
var q='?bucketType='+encodeURIComponent(bt)+(bk?'&bucketKey='+encodeURIComponent(bk):'')+qs();
|
1400 |
var q='?bucketType='+encodeURIComponent(D.bt)+(D.bk?'&bucketKey='+encodeURIComponent(D.bk):'')+
|
| - |
|
1401 |
'&page='+D.page+'&pageSize='+D.pageSize+qs();
|
| 1361 |
getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];
|
1402 |
getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];
|
| - |
|
1403 |
D.total=(d&&d.total!=null)?d.total:leads.length; D.pages=Math.max((d&&d.pages)||1,1); D.page=(d&&d.page)||D.page;
|
| 1362 |
var cap=(d&&d.limit)?d.limit:0;
|
1404 |
var count=fmtInt(D.total)+' lead(s)';
|
| 1363 |
// The drawer is capped, so say so -- otherwise a bar reading 170 opens a list of 100 and the
|
1405 |
if(D.pages>1){ var first=(D.page-1)*D.pageSize+(leads.length?1:0), last=(D.page-1)*D.pageSize+leads.length;
|
| 1364 |
// reader has to guess which number is wrong.
|
1406 |
count+=' · showing '+first+'–'+last; }
|
| 1365 |
var count=(cap&&leads.length>=cap)?('first '+leads.length+' lead(s)'):(leads.length+' lead(s)');
|
1407 |
openDrawer(D.crumb,D.title,(D.sub||'')+' · '+count,drawerLeadTable(leads)+drillPager());
|
| - |
|
1408 |
byId('dBody').scrollTop=0; }); }
|
| - |
|
1409 |
function drillPager(){ if(DRILL.pages<=1) return '';
|
| - |
|
1410 |
return '<div style="display:flex;justify-content:flex-end;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">'+
|
| - |
|
1411 |
'<button class="btn ghost sm" onclick="drillPage(-1)"'+(DRILL.page<=1?' disabled':'')+'>← Prev</button>'+
|
| 1366 |
openDrawer(crumb,title,(sub||'')+' · '+count,drawerLeadTable(leads)); }); }
|
1412 |
'<span class="note">Page '+DRILL.page+' / '+DRILL.pages+'</span>'+
|
| - |
|
1413 |
'<button class="btn ghost sm" onclick="drillPage(1)"'+(DRILL.page>=DRILL.pages?' disabled':'')+'>Next →</button></div>'; }
|
| 1367 |
|
1414 |
|
| 1368 |
// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawer
|
1415 |
// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawer
|
| 1369 |
var searchTimer=null, searchSeq=0;
|
1416 |
var searchTimer=null, searchSeq=0;
|
| 1370 |
function runSearch(){ var q=(byId('lmsSearch').value||'').trim();
|
1417 |
function runSearch(){ var q=(byId('lmsSearch').value||'').trim();
|
| 1371 |
if(q.length<2){ toast('Type at least 2 characters'); return; }
|
1418 |
if(q.length<2){ toast('Type at least 2 characters'); return; }
|
| Line 1952... |
Line 1999... |
| 1952 |
|
1999 |
|
| 1953 |
function reportStateFunnel(){
|
2000 |
function reportStateFunnel(){
|
| 1954 |
var d=DATA.state||SEED.state||{}; var rows=(d.rows||[]).map(function(r){
|
2001 |
var d=DATA.state||SEED.state||{}; var rows=(d.rows||[]).map(function(r){
|
| 1955 |
return '<tr><td><b>'+esc(r.regionName||r.regionCode||'—')+'</b></td><td class="num">'+fmtInt(r.newCount)+'</td>'+
|
2002 |
return '<tr><td><b>'+esc(r.regionName||r.regionCode||'—')+'</b></td><td class="num">'+fmtInt(r.newCount)+'</td>'+
|
| 1956 |
'<td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
2003 |
'<td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
| 1957 |
'<td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
2004 |
'<td class="num">'+fmtInt(r.beatPlanned)+'</td><td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
| 1958 |
'<td class="num">'+pctCell(r.convPct||0)+'</td><td class="num">'+pctCell(r.slaPct||0)+'</td></tr>'; });
|
2005 |
'<td class="num">'+pctCell(r.convPct||0)+'</td><td class="num">'+pctCell(r.slaPct||0)+'</td></tr>'; });
|
| 1959 |
openDrawer('Report suite','State Funnel','NEW → ONBOARDED by region','<div class="card">'+
|
2006 |
openDrawer('Report suite','State Funnel','NEW → ONBOARDED by region','<div class="card">'+
|
| 1960 |
tbl([{t:'Region'},{t:'New',n:1},{t:'Contacted',n:1},{t:'Qualified',n:1},{t:'Visited',n:1},{t:'Onboarded',n:1},{t:'Conv%',n:1},{t:'SLA%',n:1}],rows)+'</div>');
|
2007 |
tbl([{t:'Region'},{t:'Leads',n:1},{t:'Contacted',n:1},{t:'Qualified',n:1},{t:'Beat',n:1},{t:'Visited',n:1},{t:'Onboarded',n:1},{t:'Conv%',n:1},{t:'SLA%',n:1}],rows)+'</div>');
|
| 1961 |
}
|
2008 |
}
|
| 1962 |
|
2009 |
|
| 1963 |
function reportSla(){
|
2010 |
function reportSla(){
|
| 1964 |
var d=DATA.heat||SEED.heat||{}; var rows=(d.bms||[]).map(function(r){
|
2011 |
var d=DATA.heat||SEED.heat||{}; var rows=(d.bms||[]).map(function(r){
|
| 1965 |
var cells=(r.cells||[]).map(function(c){ return '<td class="num">'+(c.pct==null?'—':pctCell(c.pct))+'</td>'; }).join('');
|
2012 |
var cells=(r.cells||[]).map(function(c){ return '<td class="num">'+(c.pct==null?'—':pctCell(c.pct))+'</td>'; }).join('');
|
| Line 2657... |
Line 2704... |
| 2657 |
if(n>=1000) return '₹'+(n/1000).toFixed(n%1000?1:0)+'K';
|
2704 |
if(n>=1000) return '₹'+(n/1000).toFixed(n%1000?1:0)+'K';
|
| 2658 |
return '₹'+n;
|
2705 |
return '₹'+n;
|
| 2659 |
}
|
2706 |
}
|
| 2660 |
|
2707 |
|
| 2661 |
function val(id){ var e=byId(id); return e?e.value:''; }
|
2708 |
function val(id){ var e=byId(id); return e?e.value:''; }
|
| - |
|
2709 |
// Store photos go through the same /document-upload the Leads screen uses (multipart "file" ->
|
| - |
|
2710 |
// response.document_id) and the ids ride along in the create payload as lead_detail columns.
|
| - |
|
2711 |
function uploadPhoto(input){
|
| - |
|
2712 |
var f=input&&input.files&&input.files[0]; if(!f) return Promise.resolve(0);
|
| - |
|
2713 |
var fd=new FormData(); fd.append('file',f);
|
| - |
|
2714 |
return fetch(CTX+'/document-upload',{method:'POST',credentials:'same-origin',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}})
|
| - |
|
2715 |
.then(function(r){ return r.json(); })
|
| - |
|
2716 |
.then(function(d){ var id=d&&d.response&&d.response.document_id; if(!id) throw new Error('upload'); return parseInt(id,10); });
|
| - |
|
2717 |
}
|
| - |
|
2718 |
function uploadPhotos(){
|
| - |
|
2719 |
var inputs=Array.prototype.slice.call(document.querySelectorAll('#cPhotoGrid .cphoto'));
|
| - |
|
2720 |
var ids={};
|
| - |
|
2721 |
return inputs.reduce(function(chain,inp){ return chain.then(function(){
|
| - |
|
2722 |
return uploadPhoto(inp).then(function(id){ if(id) ids[inp.getAttribute('data-photo')]=id; }); }); }, Promise.resolve())
|
| - |
|
2723 |
.then(function(){ return ids; });
|
| - |
|
2724 |
}
|
| - |
|
2725 |
var CREATE_BUSY=false;
|
| - |
|
2726 |
function submitLead(){ if(CREATE_BUSY) return;
|
| 2662 |
function submitLead(){ var fn=val('cRetailer').trim(), biz=val('cBusiness').trim();
|
2727 |
var fn=val('cRetailer').trim(), biz=val('cBusiness').trim();
|
| 2663 |
var phone=(val('cContact')||'').replace(/\D/g,'').slice(-10);
|
2728 |
var phone=(val('cContact')||'').replace(/\D/g,'').slice(-10);
|
| 2664 |
var brands=collectBrands(); var value=brandTotal();
|
2729 |
var brands=collectBrands(); var value=brandTotal();
|
| 2665 |
var region=val('cRegion'); var path=CREATE_PATH;
|
2730 |
var region=val('cRegion'); var path=CREATE_PATH;
|
| 2666 |
if(!fn){ toast('Retailer name is required'); return; }
|
2731 |
if(!fn){ toast('Retailer name is required'); return; }
|
| 2667 |
if(phone.length!==10){ toast('A valid 10-digit contact is required'); return; }
|
2732 |
if(phone.length!==10){ toast('A valid 10-digit contact is required'); return; }
|
| 2668 |
if(!region){ toast('Pick a region'); return; }
|
2733 |
if(!region){ toast('Pick a region'); return; }
|
| - |
|
2734 |
var address=val('cAddress').trim(), city=val('cCity').trim(), state=val('cState');
|
| - |
|
2735 |
if(address.length>128){ toast('Address is too long (128 max)'); return; }
|
| - |
|
2736 |
if(city.length>30){ toast('City is too long (30 max)'); return; }
|
| - |
|
2737 |
var counter=parseInt((val('cCounter')||'').replace(/\D/g,''),10);
|
| 2669 |
// No address check on either path any more: the capture link is what establishes location,
|
2738 |
// No address check on either path: the capture link is what establishes location, and it is
|
| 2670 |
// and it is generated for every lead regardless of path.
|
2739 |
// generated for every lead regardless of path. Address / city / state are kept when typed.
|
| 2671 |
var payload={ firstName:fn, mobile:phone, outletName:biz||'', source:'LMS Dashboard',
|
2740 |
var payload={ firstName:fn, mobile:phone, outletName:biz||'', source:val('cSource')||'LMS Dashboard',
|
| 2672 |
regionId:parseInt(region,10), creationPath:path };
|
2741 |
regionId:parseInt(region,10), creationPath:path,
|
| - |
|
2742 |
address:address, city:city, state:state||'',
|
| - |
|
2743 |
conversionHigh:(val('cConversion')==='true'), remark:val('cRemark').trim() };
|
| - |
|
2744 |
if(!isNaN(counter)&&counter>0) payload.counterSize=counter;
|
| 2673 |
// potential is the sum, so the funnel's value column keeps working, while the per-brand split
|
2745 |
// potential is the sum, so the funnel's value column keeps working, while the per-brand split
|
| 2674 |
// is what actually tells a BM what the counter is worth.
|
2746 |
// is what actually tells a BM what the counter is worth.
|
| 2675 |
if(value && value>0) payload.potential=value;
|
2747 |
if(value && value>0) payload.potential=value;
|
| 2676 |
if(brands.length) payload.leadBrands=brands;
|
2748 |
if(brands.length) payload.leadBrands=brands;
|
| - |
|
2749 |
CREATE_BUSY=true;
|
| - |
|
2750 |
var anyPhoto=Array.prototype.some.call(document.querySelectorAll('#cPhotoGrid .cphoto'),function(i){ return i.files&&i.files.length; });
|
| - |
|
2751 |
if(anyPhoto) toast('Uploading photos…');
|
| - |
|
2752 |
uploadPhotos().then(function(ids){
|
| - |
|
2753 |
for(var k in ids){ if(ids.hasOwnProperty(k)) payload[k]=ids[k]; }
|
| 2677 |
// /lms/createLead, NOT the shared /createLead: this one honours regionId and creationPath, so
|
2754 |
// /lms/createLead, NOT the shared /createLead: this one honours regionId and creationPath, so
|
| 2678 |
// the lead actually gets a stage, an LMS code, an owner and an SLA clock.
|
2755 |
// the lead actually gets a stage, an LMS code, an owner and an SLA clock.
|
| 2679 |
fetch(CTX+'/lms/createLead',{method:'POST',credentials:'same-origin',
|
2756 |
return fetch(CTX+'/lms/createLead',{method:'POST',credentials:'same-origin',
|
| 2680 |
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},
|
2757 |
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},
|
| 2681 |
body:JSON.stringify(payload)})
|
- |
|
| 2682 |
.then(function(r){ return r.text(); })
|
2758 |
body:JSON.stringify(payload)}).then(function(r){ return r.text(); }); })
|
| 2683 |
.then(function(t){
|
2759 |
.then(function(t){ CREATE_BUSY=false;
|
| 2684 |
var d=null; try{ d=JSON.parse(t); }catch(e){ d=null; }
|
2760 |
var d=null; try{ d=JSON.parse(t); }catch(e){ d=null; }
|
| 2685 |
var res=(d&&d.response)?d.response:d;
|
2761 |
var res=(d&&d.response)?d.response:d;
|
| 2686 |
if(!res || res.ok!==true){
|
2762 |
if(!res || res.ok!==true){
|
| 2687 |
// A refusal carries the reason as a bare string under `response` (badRequest("...")),
|
2763 |
// A refusal carries the reason as a bare string under `response` (badRequest("...")),
|
| 2688 |
// whereas success is an object. Surface the reason, not a generic failure.
|
2764 |
// whereas success is an object. Surface the reason, not a generic failure.
|
| Line 2690... |
Line 2766... |
| 2690 |
return;
|
2766 |
return;
|
| 2691 |
}
|
2767 |
}
|
| 2692 |
var held=(res.assignmentStatus==='HOLD');
|
2768 |
var held=(res.assignmentStatus==='HOLD');
|
| 2693 |
toast(held ? ('Lead '+res.lmsCode+' created · no BM for that region, parked in HOLD')
|
2769 |
toast(held ? ('Lead '+res.lmsCode+' created · no BM for that region, parked in HOLD')
|
| 2694 |
: ('Lead '+res.lmsCode+' created · assigned to '+(res.ownerName||'the region BM')+' · SLA started'), 1);
|
2770 |
: ('Lead '+res.lmsCode+' created · assigned to '+(res.ownerName||'the region BM')+' · SLA started'), 1);
|
| - |
|
2771 |
['cRetailer','cBusiness','cContact','cAddress','cCity','cCounter','cRemark'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });
|
| 2695 |
['cRetailer','cBusiness','cContact'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });
|
2772 |
['cState'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });
|
| - |
|
2773 |
var cs=byId('cSource'); if(cs) cs.value='LMS Dashboard'; var cc=byId('cConversion'); if(cc) cc.value='false';
|
| 2696 |
document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){ el.value=''; });
|
2774 |
document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){ el.value=''; });
|
| - |
|
2775 |
document.querySelectorAll('#cPhotoGrid .cphoto').forEach(function(el){ el.value=''; });
|
| 2697 |
brandTotal();
|
2776 |
brandTotal();
|
| 2698 |
|
2777 |
|
| 2699 |
// Hand the capture link over immediately — the whole point of generating it on create is
|
2778 |
// Hand the capture link over immediately — the whole point of generating it on create is
|
| 2700 |
// that the agent is still on the call with the retailer.
|
2779 |
// that the agent is still on the call with the retailer.
|
| 2701 |
if(res.geoLink){
|
2780 |
if(res.geoLink){
|
| Line 2708... |
Line 2787... |
| 2708 |
}
|
2787 |
}
|
| 2709 |
}
|
2788 |
}
|
| 2710 |
|
2789 |
|
| 2711 |
byId('recLookup').value=res.leadId; injectRecord(res.leadId);
|
2790 |
byId('recLookup').value=res.leadId; injectRecord(res.leadId);
|
| 2712 |
go('record',document.querySelectorAll('.tab')[1]); refresh(); })
|
2791 |
go('record',document.querySelectorAll('.tab')[1]); refresh(); })
|
| 2713 |
.catch(function(e){ toast('Create failed'); }); }
|
2792 |
.catch(function(e){ CREATE_BUSY=false; toast(e&&e.message==='upload'?'A photo failed to upload — the lead was not created':'Create failed'); }); }
|
| 2714 |
|
2793 |
|
| 2715 |
// ====================== AUTO-ASSIGNMENT (real resolve per region) ======================
|
2794 |
// ====================== AUTO-ASSIGNMENT (real resolve per region) ======================
|
| 2716 |
function buildAssign(){ var opts=byId('cRegion').querySelectorAll('option'); var holdCount=0, assignedCount=0, pending=0;
|
2795 |
function buildAssign(){ var opts=byId('cRegion').querySelectorAll('option'); var holdCount=0, assignedCount=0, pending=0;
|
| 2717 |
var body=byId('assignMap'); body.innerHTML='';
|
2796 |
var body=byId('assignMap'); body.innerHTML='';
|
| 2718 |
// Rows are created up-front in region order, then filled in as each resolve lands — otherwise
|
2797 |
// Rows are created up-front in region order, then filled in as each resolve lands — otherwise
|