Subversion Repositories SmartDukaan

Rev

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

Rev 37743 Rev 37749
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 &amp; 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 &amp; 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 &rarr; ONBOARDED by region</div>
761
    <div class="sectionlabel">State funnel — NEW &rarr; 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">&minus;'+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">&minus;'+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':'')+'>&larr; 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 &rarr;</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