Subversion Repositories SmartDukaan

Rev

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

Rev 37737 Rev 37743
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 &amp; servicing across all regions — derived from the lead record.</div>
718
        <div class="sub">Funnel, SLA compliance, ageing &amp; 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">&mdash;</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()">&times;</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()">&times;</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 '&#8377;'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
1044
    return '&#8377;'+(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:'&#128229;',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'&#128200;',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
1244
  var DASH_KPI=[ {i:'&#128229;',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'&#128200;',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
1201
    {i:'&#9878;',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'&#128222;',bg:'#e3fafc',fg:'#0c8599'},
1245
    {i:'&#9878;',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'&#128222;',bg:'#e3fafc',fg:'#0c8599'},
1202
    {i:'&#8987;',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'&#128176;',bg:'#ebfbee',fg:'#2f9e44'} ];
1246
    {i:'&#8987;',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'&#128176;',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">&#9679;</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">&#9679;</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();