Subversion Repositories SmartDukaan

Rev

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

Rev 37692 Rev 37737
Line 155... Line 155...
155
  .tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
155
  .tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
156
  .tile .v{font-size:21px;font-weight:760;margin-top:5px}
156
  .tile .v{font-size:21px;font-weight:760;margin-top:5px}
157
  .tile .v small{font-size:12px;color:var(--soft);font-weight:600}
157
  .tile .v small{font-size:12px;color:var(--soft);font-weight:600}
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
-
 
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}
-
 
163
  .livedot{width:7px;height:7px;border-radius:50%;background:#2f9e44;flex:none}
-
 
164
  .livedot.beat{animation:livePulse .9s ease-out}
-
 
165
  .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)}}
-
 
167
  @media (prefers-reduced-motion:reduce){ .livedot.beat{animation:none} }
-
 
168
  .callbar{position:fixed;right:18px;bottom:18px;z-index:88;width:280px;background:#fff;border:1px solid var(--line);
-
 
169
    border-radius:14px;box-shadow:0 18px 44px rgba(10,15,40,.22);padding:14px 16px}
-
 
170
  .callbar .cb-head{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--brand)}
-
 
171
  .callbar .cb-dot{width:9px;height:9px;border-radius:50%;background:#e03131;flex:none;animation:cbPulse 1.4s infinite}
-
 
172
  .callbar .cb-time{margin-left:auto;color:var(--soft);font-weight:600;font-size:12.5px}
-
 
173
  .callbar .cb-state{margin-top:6px;font-size:12.5px;color:var(--soft);text-transform:capitalize}
-
 
174
  .callbar .cb-acts{margin-top:11px;display:flex;gap:7px}
-
 
175
  .callbar .cb-err{margin-top:8px;color:#e03131;font-size:12px}
-
 
176
  @keyframes cbPulse{0%{opacity:1}50%{opacity:.35}100%{opacity:1}}
-
 
177
  @media (prefers-reduced-motion:reduce){ .callbar .cb-dot{animation:none} }
-
 
178
  /* Call button on a pipeline row. */
-
 
179
  .chkq{padding:12px 0;border-bottom:1px solid var(--line2)}
-
 
180
  .chkq:last-child{border-bottom:0}
-
 
181
  .chkq .qp{font-size:13px;font-weight:600;color:var(--ink);margin-bottom:7px}
-
 
182
  .chkq .qp .req{color:#e03131;margin-left:3px}
-
 
183
  .chkq .opts{display:flex;gap:7px;flex-wrap:wrap}
-
 
184
  .chkopt{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 13px;font-size:12.5px;
-
 
185
    cursor:pointer;font-weight:600;color:var(--soft)}
-
 
186
  .chkopt.sel{background:#1f2a5e;border-color:#1f2a5e;color:#fff}
-
 
187
  .rowcall{border:1px solid var(--line);background:#fff;color:#e03131;border-radius:8px;padding:4px 9px;
-
 
188
    cursor:pointer;font-size:13px;line-height:1.1}
-
 
189
  .rowcall:hover{background:#fff0f0;border-color:#ffc9c9}
-
 
190
  .rowcall[disabled]{opacity:.45;cursor:not-allowed}
-
 
191
  .rowedit{border:1px solid var(--line);background:#fff;color:var(--brand2);border-radius:8px;padding:4px 9px;
-
 
192
    cursor:pointer;font-size:13px;line-height:1.1}
-
 
193
  .rowedit:hover{background:#eef1fb;border-color:#c7d2fe}
-
 
194
  .rowassign{border:1px solid var(--line);background:#fff;color:#0ca678;border-radius:8px;padding:4px 9px;
-
 
195
    cursor:pointer;font-size:13px;line-height:1.1}
-
 
196
  .rowassign:hover{background:#e6fcf5;border-color:#96f2d7}
-
 
197
  .tierpill{display:inline-block;min-width:26px;text-align:center;padding:2px 7px;border-radius:6px;
-
 
198
    background:#eef1fb;color:#3b5bdb;font-size:11px;font-weight:700;margin-right:8px}
-
 
199
  .asgrow{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);
-
 
200
    border-radius:10px;margin-bottom:6px;cursor:pointer}
-
 
201
  .asgrow:hover{background:#f8f9fc;border-color:var(--brand2)}
-
 
202
  .asgrow.covers{border-color:#96f2d7;background:#f4fffb}
-
 
203
  .asgrow .nm{font-weight:650;font-size:13px}
-
 
204
  .asgrow .rg{margin-left:auto;font-size:11px;color:var(--soft)}
-
 
205
  .lstat{display:inline-block;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.02em}
-
 
206
  .lstat.oncall{background:#e6fcf5;color:#0ca678}
-
 
207
  .lstat.wrap{background:#eef1f6;color:#69748c}
-
 
208
  .lstat.idle{background:#fff4e6;color:#e8590c}
-
 
209
  .lstat.avail{background:#eef1fb;color:#3b5bdb}
-
 
210
  .lstat.off{background:#f1f3f5;color:#97a1b5}
-
 
211
  .lstale{color:#e03131;font-size:11px;margin-left:6px}
160
  th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
212
  th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
161
  th.sortable:hover{color:var(--brand2)}
213
  th.sortable:hover{color:var(--brand2)}
162
  th.sortable.on{color:var(--brand2)}
214
  th.sortable.on{color:var(--brand2)}
163
  /* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
215
  /* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
164
  th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
216
  th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
Line 329... Line 381...
329
  <div class="inner">
381
  <div class="inner">
330
    <button class="tab active" onclick="go('cmd',this)">Command Center</button>
382
    <button class="tab active" onclick="go('cmd',this)">Command Center</button>
331
    <button class="tab" onclick="go('record',this)">Lead Record</button>
383
    <button class="tab" onclick="go('record',this)">Lead Record</button>
332
    <button class="tab" onclick="go('create',this)">Create Lead</button>
384
    <button class="tab" onclick="go('create',this)">Create Lead</button>
333
    <button class="tab" onclick="go('assign',this)">Auto-Assignment</button>
385
    <button class="tab" onclick="go('assign',this)">Auto-Assignment</button>
-
 
386
    <button class="tab" onclick="go('live',this)">Live Calls</button>
334
    <button class="tab" onclick="go('dash',this)">Dashboards</button>
387
    <button class="tab" onclick="go('dash',this)">Dashboards</button>
-
 
388
    <button class="tab" onclick="go('chk',this)">Checklist</button>
335
    <button class="tab" onclick="go('audit',this)">Recordings &amp; Audit</button>
389
    <button class="tab" onclick="go('audit',this)">Recordings &amp; Audit</button>
336
  </div>
390
  </div>
337
</div>
391
</div>
338
 
392
 
339
<div class="wrap">
393
<div class="wrap">
Line 382... Line 436...
382
        <div class="pipewrap">
436
        <div class="pipewrap">
383
        <table>
437
        <table>
384
          <colgroup>
438
          <colgroup>
385
            <col style="width:132px"><col><col style="width:64px"><col style="width:112px">
439
            <col style="width:132px"><col><col style="width:64px"><col style="width:112px">
386
            <col style="width:130px"><col style="width:130px"><col style="width:130px">
440
            <col style="width:130px"><col style="width:130px"><col style="width:130px">
387
            <col style="width:104px"><col style="width:84px">
441
            <col style="width:104px"><col style="width:84px"><col style="width:124px">
388
          </colgroup>
442
          </colgroup>
389
          <thead><tr>
443
          <thead><tr>
390
            <th class="sortable" data-sort="created">LMS ID</th>
444
            <th class="sortable" data-sort="created">LMS ID</th>
391
            <th class="sortable" data-sort="name">Business / Retailer</th>
445
            <th class="sortable" data-sort="name">Business / Retailer</th>
392
            <th>Path</th>
446
            <th>Path</th>
Line 394... Line 448...
394
            <th>Owner</th>
448
            <th>Owner</th>
395
            <th class="sortable" data-sort="city">City</th>
449
            <th class="sortable" data-sort="city">City</th>
396
            <th class="sortable" data-sort="createdBy">Created by</th>
450
            <th class="sortable" data-sort="createdBy">Created by</th>
397
            <th class="sortable" data-sort="sla">SLA</th>
451
            <th class="sortable" data-sort="sla">SLA</th>
398
            <th class="sortable num" data-sort="value">Value</th>
452
            <th class="sortable num" data-sort="value">Value</th>
-
 
453
            <th></th>
399
          </tr></thead>
454
          </tr></thead>
400
          <tbody id="ccPipeline"><tr><td colspan="9"><div class="ph">Loading…</div></td></tr></tbody>
455
          <tbody id="ccPipeline"><tr><td colspan="10"><div class="ph">Loading…</div></td></tr></tbody>
401
        </table>
456
        </table>
402
        </div>
457
        </div>
403
        <div id="pipeFoot" style="display:flex;align-items:center;justify-content:space-between;gap:10px;
458
        <div id="pipeFoot" style="display:flex;align-items:center;justify-content:space-between;gap:10px;
404
             margin-top:10px;flex-wrap:wrap">
459
             margin-top:10px;flex-wrap:wrap">
405
          <span class="note" id="pipeCount"></span>
460
          <span class="note" id="pipeCount"></span>
Line 548... Line 603...
548
      </div>
603
      </div>
549
    </div>
604
    </div>
550
  </div>
605
  </div>
551
 
606
 
552
  <!-- ======================= DASHBOARDS ========================= -->
607
  <!-- ======================= DASHBOARDS ========================= -->
-
 
608
  <!-- ======================= LIVE CALLS ========================= -->
-
 
609
  <div id="live" class="hide">
-
 
610
    <div class="pagehead">
-
 
611
      <div>
-
 
612
        <div class="crumb">Floor &middot; Live</div>
-
 
613
        <h1 class="page">Live Calls</h1>
-
 
614
        <div class="sub">Who is on a call right now, and how the day is going per agent &mdash; from the
-
 
615
          call log. Refreshes every 10 seconds.</div>
-
 
616
      </div>
-
 
617
      <div class="hd-right">
-
 
618
        <span class="liveclock"><span class="livedot" id="liveDot"></span><span id="liveAsOf">connecting&hellip;</span></span>
-
 
619
        <button class="btn ghost sm" onclick="exportLive()">Export CSV</button>
-
 
620
      </div>
-
 
621
    </div>
-
 
622
 
-
 
623
    <div class="kpis" id="liveKpis"></div>
-
 
624
 
-
 
625
    <div class="card">
-
 
626
      <h2>Agents</h2>
-
 
627
      <p class="desc">Roster is everyone with a dialer mapping &mdash; an agent without one cannot place
-
 
628
        a call. Click a row for their calls today.</p>
-
 
629
      <div class="pipewrap">
-
 
630
        <table>
-
 
631
          <colgroup>
-
 
632
            <col style="width:170px"><col style="width:104px"><col style="width:96px">
-
 
633
            <col style="width:70px"><col style="width:82px"><col style="width:84px">
-
 
634
            <col style="width:88px"><col style="width:90px"><col style="width:86px">
-
 
635
            <col style="width:92px"><col style="width:96px">
-
 
636
          </colgroup>
-
 
637
          <thead><tr>
-
 
638
            <th>Agent</th><th>Status</th><th>In status</th>
-
 
639
            <th class="num">Calls</th><th class="num">Conn.</th><th class="num">Conn%</th>
-
 
640
            <th class="num">Talk</th><th class="num">Longest</th>
-
 
641
            <th class="num">Assigned</th><th class="num">Touched</th><th class="num">SLA breach</th>
-
 
642
          </tr></thead>
-
 
643
          <tbody id="liveBody"><tr><td colspan="11"><div class="ph">Loading&hellip;</div></td></tr></tbody>
-
 
644
        </table>
-
 
645
      </div>
-
 
646
      <div class="note">Status is derived from call activity &mdash; the Vonage softphone reports no
-
 
647
        presence, so there is deliberately no Login or Break here rather than a guess.</div>
-
 
648
    </div>
-
 
649
 
-
 
650
    <div class="card">
-
 
651
      <h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Call log
-
 
652
        <span style="display:flex;gap:6px;align-items:center">
-
 
653
          <input type="date" class="ctl" id="clFrom">
-
 
654
          <input type="date" class="ctl" id="clTo">
-
 
655
          <button class="btn ghost sm" onclick="loadCallLog(1)">Apply</button>
-
 
656
          <button class="btn ghost sm" onclick="exportCallLog()">Export CSV</button>
-
 
657
        </span>
-
 
658
      </h2>
-
 
659
      <p class="desc">Every call placed in the window &mdash; ring time, talk time, outcome and recording.
-
 
660
        Click a row to open its lead.</p>
-
 
661
      <div class="pipewrap">
-
 
662
        <table>
-
 
663
          <colgroup>
-
 
664
            <col style="width:132px"><col style="width:140px"><col style="width:190px">
-
 
665
            <col style="width:124px"><col style="width:104px"><col style="width:74px">
-
 
666
            <col style="width:74px"><col style="width:120px"><col>
-
 
667
          </colgroup>
-
 
668
          <thead><tr>
-
 
669
            <th>Started</th><th>Agent</th><th>Lead</th><th>Number</th><th>Status</th>
-
 
670
            <th class="num">Ring</th><th class="num">Talk</th><th>Recording</th><th>Disposition</th>
-
 
671
          </tr></thead>
-
 
672
          <tbody id="clBody"><tr><td colspan="9"><div class="ph">Loading&hellip;</div></td></tr></tbody>
-
 
673
        </table>
-
 
674
      </div>
-
 
675
      <div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:10px;flex-wrap:wrap">
-
 
676
        <span class="note" id="clCount"></span>
-
 
677
        <span style="display:flex;gap:6px;align-items:center">
-
 
678
          <button class="btn ghost sm" id="clPrev" onclick="clPage(-1)">&larr; Prev</button>
-
 
679
          <span class="note" id="clPageLbl"></span>
-
 
680
          <button class="btn ghost sm" id="clNext" onclick="clPage(1)">Next &rarr;</button>
-
 
681
        </span>
-
 
682
      </div>
-
 
683
      <div class="note">Ring is dial &rarr; answer; Talk is answer &rarr; hangup. A call that was never
-
 
684
        answered has a ring time but no talk time.</div>
-
 
685
    </div>
-
 
686
  </div>
-
 
687
 
553
  <div id="dash" class="hide">
688
  <div id="dash" class="hide">
554
    <div class="pagehead">
689
    <div class="pagehead">
555
      <div>
690
      <div>
556
        <div class="crumb">Management · Cross-region roll-up</div>
691
        <div class="crumb">Management · Cross-region roll-up</div>
557
        <h1 class="page">Dashboards</h1>
692
        <h1 class="page">Dashboards</h1>
558
        <div class="sub">Funnel, SLA compliance, ageing &amp; servicing across all regions — derived from the lead record.</div>
693
        <div class="sub">Funnel, SLA compliance, ageing &amp; servicing across all regions — derived from the lead record.</div>
559
      </div>
694
      </div>
560
      <div class="hd-right">
695
      <div class="hd-right">
561
        <span class="ctl"><span class="lbl">Period</span><span id="dashPeriod">—</span></span>
696
        <span class="ctl"><span class="lbl">Period</span><span id="dashPeriod">—</span></span>
562
        <button class="btn ghost sm" onclick="toast('Export uses the app report suite',1)">Export</button>
697
        <button class="btn ghost sm" onclick="exportPipeline()">Export CSV</button>
563
      </div>
698
      </div>
564
    </div>
699
    </div>
565
 
700
 
566
    <div class="kpis" id="dashKpis"></div>
701
    <div class="kpis" id="dashKpis"></div>
567
 
702
 
Line 580... Line 715...
580
        <div id="dashHeat"></div>
715
        <div id="dashHeat"></div>
581
        <div class="note">SLA = first contact within 5 hrs of creation (SOP §11). Reminder T-1h; escalation to BM at T+5h.</div>
716
        <div class="note">SLA = first contact within 5 hrs of creation (SOP §11). Reminder T-1h; escalation to BM at T+5h.</div>
582
      </div>
717
      </div>
583
      <div class="card">
718
      <div class="card">
584
        <h2>Standard dashboards</h2>
719
        <h2>Standard dashboards</h2>
585
        <p class="desc">SOP §18.2 report suite — Live = from the lead record; Partial/Pending need their source system.</p>
720
        <p class="desc">SOP §18.2 report suite — click any row to open it. Live = measured from real data; Pending = its source system does not exist yet.</p>
586
        <table><tbody style="font-size:12.5px">
721
        <table><tbody style="font-size:12.5px">
587
          <tr class="clickrow" data-rep="State Funnel"><td>&#128200; <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New &rarr; onboarded by region, BM, ASM</td></tr>
722
          <tr class="clickrow" data-rep="State Funnel"><td>&#128200; <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New &rarr; onboarded by region, BM, ASM</td></tr>
588
          <tr class="clickrow" data-rep="SLA Compliance"><td>&#9878; <b>SLA Compliance</b><span class="rbadge live">Live</span></td><td style="color:#69748c">% within SLA, owner heat-map</td></tr>
723
          <tr class="clickrow" data-rep="SLA Compliance"><td>&#9878; <b>SLA Compliance</b><span class="rbadge live">Live</span></td><td style="color:#69748c">% within SLA, owner heat-map</td></tr>
589
          <tr class="clickrow" data-rep="Ageing"><td>&#8987; <b>Ageing</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Stuck by stage, days-since-action</td></tr>
724
          <tr class="clickrow" data-rep="Ageing"><td>&#8987; <b>Ageing</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Stuck by stage, days-since-action</td></tr>
590
          <tr class="clickrow" data-rep="Management Roll-up"><td>&#128081; <b>Management Roll-up</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Cross-region leads, onboardings, value</td></tr>
725
          <tr class="clickrow" data-rep="Management Roll-up"><td>&#128081; <b>Management Roll-up</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Cross-region leads, onboardings, value</td></tr>
591
          <tr class="clickrow" data-rep="BGC Daily"><td>&#128202; <b>BGC Daily</b><span class="rbadge partial">Partial</span></td><td style="color:#69748c">Created + dispositions real; calls are a proxy (no telephony)</td></tr>
726
          <tr class="clickrow" data-rep="BGC Daily"><td>&#128202; <b>BGC Daily</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Calls, connect rate &amp; talk time per agent per day</td></tr>
592
          <tr class="clickrow" data-rep="Audit Hygiene"><td>&#129529; <b>Audit Hygiene</b><span class="rbadge partial">Partial</span></td><td style="color:#69748c">Missing dispositions / HOLD; DND &amp; recordings pending</td></tr>
727
          <tr class="clickrow" data-rep="Audit Hygiene"><td>&#129529; <b>Audit Hygiene</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Dispositions, SLA, HOLD, DND breaches, recording access</td></tr>
593
          <tr class="clickrow" data-rep="RBM Servicing"><td>&#128260; <b>RBM Servicing</b><span class="rbadge soon">Pending data</span></td><td style="color:#69748c">Repeat-order / complaint / TAT need order data</td></tr>
728
          <tr class="clickrow" data-rep="RBM Servicing"><td>&#128260; <b>RBM Servicing</b><span class="rbadge soon">Pending data</span></td><td style="color:#69748c">Repeat-order / complaint / TAT need order data</td></tr>
594
        </tbody></table>
729
        </tbody></table>
595
      </div>
730
      </div>
596
    </div>
731
    </div>
597
 
732
 
Line 601... Line 736...
601
      <p class="desc">Each links to the section above.</p>
736
      <p class="desc">Each links to the section above.</p>
602
      <div id="dashFix"></div>
737
      <div id="dashFix"></div>
603
    </div>
738
    </div>
604
  </div>
739
  </div>
605
 
740
 
-
 
741
  <!-- ======================= CHECKLIST ADMIN ========================= -->
-
 
742
  <div id="chk" class="hide">
-
 
743
    <div class="pagehead">
-
 
744
      <div>
-
 
745
        <div class="crumb">Setup &middot; In-call script</div>
-
 
746
        <h1 class="page">Checklist questions</h1>
-
 
747
        <div class="sub">What agents are asked while a call is connected. Questions show at the stages
-
 
748
          you pick, and carry points towards a lead's score.</div>
-
 
749
      </div>
-
 
750
      <div class="hd-right">
-
 
751
        <button class="btn coral sm" onclick="editQuestion(null)">+ New question</button>
-
 
752
      </div>
-
 
753
    </div>
-
 
754
 
-
 
755
    <div class="card">
-
 
756
      <div class="pipewrap">
-
 
757
        <table>
-
 
758
          <colgroup>
-
 
759
            <col style="width:54px"><col><col style="width:96px"><col style="width:66px">
-
 
760
            <col style="width:70px"><col style="width:220px"><col style="width:74px"><col style="width:132px">
-
 
761
          </colgroup>
-
 
762
          <thead><tr>
-
 
763
            <th class="num">Order</th><th>Question</th><th>Type</th><th class="num">Points</th>
-
 
764
            <th>Required</th><th>Shows at</th><th>Status</th><th></th>
-
 
765
          </tr></thead>
-
 
766
          <tbody id="qBody"><tr><td colspan="8"><div class="ph">Loading&hellip;</div></td></tr></tbody>
-
 
767
        </table>
-
 
768
      </div>
-
 
769
      <div class="note">A question that has been answered can be switched off but not deleted &mdash;
-
 
770
        the answers reference it.</div>
-
 
771
    </div>
-
 
772
  </div>
-
 
773
 
606
  <!-- ======================= RECORDINGS & AUDIT ========================= -->
774
  <!-- ======================= RECORDINGS & AUDIT ========================= -->
607
  <div id="audit" class="hide">
775
  <div id="audit" class="hide">
608
    <div class="pagehead">
776
    <div class="pagehead">
609
      <div>
777
      <div>
610
        <div class="crumb">Audit · Access Control</div>
778
        <div class="crumb">Audit · Access Control</div>
Line 724... Line 892...
724
    <div class="mf"><button class="btn ghost" onclick="closeGeo()">Cancel</button>
892
    <div class="mf"><button class="btn ghost" onclick="closeGeo()">Cancel</button>
725
      <button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save &amp; verify</button></div>
893
      <button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save &amp; verify</button></div>
726
  </div>
894
  </div>
727
</div>
895
</div>
728
 
896
 
-
 
897
<!-- One call bar for the whole console. It used to live inside the lead-record card, which meant a
-
 
898
     call started from anywhere else had nowhere to report progress, and switching tabs mid-call
-
 
899
     hid the hang-up button. -->
-
 
900
<div class="callbar hide" id="gCallBar">
-
 
901
  <div class="cb-head">
-
 
902
    <span class="cb-dot"></span>
-
 
903
    <span id="gCallWho">&mdash;</span>
-
 
904
    <span class="cb-time mono" id="gCallTimer">00:00</span>
-
 
905
  </div>
-
 
906
  <div class="cb-state" id="gCallState">Starting&hellip;</div>
-
 
907
  <div class="cb-acts">
-
 
908
    <button class="btn ghost sm" onclick="endCall()">Hang up</button>
-
 
909
    <button class="btn coral sm" onclick="openDisp()">Disposition&hellip;</button>
-
 
910
  </div>
-
 
911
  <div class="cb-err hide" id="gCallErr"></div>
-
 
912
</div>
-
 
913
 
-
 
914
<!-- First-call checklist. Opens by itself the moment the first call to a lead is ANSWERED — the
-
 
915
     questions are worth asking while the retailer is on the line, not reconstructed afterwards. -->
-
 
916
<div class="modal hide" id="chkModal">
-
 
917
  <div class="box">
-
 
918
    <div class="mh"><h2>First call &middot; <span id="chkWho">&mdash;</span></h2>
-
 
919
      <p class="desc">Ask these while you have them on the line. Saved against the call and the trail.</p></div>
-
 
920
    <div class="mb" id="chkBody"></div>
-
 
921
    <div class="mf">
-
 
922
      <span class="note" id="chkNote" style="margin-right:auto"></span>
-
 
923
      <button class="btn ghost" onclick="closeChk()">Later</button>
-
 
924
      <button class="btn" style="background:#1f2a5e;color:#fff" id="chkSaveBtn" onclick="saveChk()">Save answers</button>
-
 
925
    </div>
-
 
926
  </div>
-
 
927
</div>
-
 
928
 
-
 
929
<div class="modal hide" id="qModal">
-
 
930
  <div class="box">
-
 
931
    <div class="mh"><h2 id="qTitle">New question</h2>
-
 
932
      <p class="desc">Shows while a call is connected, at the stages you choose.</p></div>
-
 
933
    <div class="mb">
-
 
934
      <label class="lbl">Question (required)</label>
-
 
935
      <input id="qPrompt" class="inp" style="width:100%" placeholder="What should the agent ask?">
-
 
936
      <div class="grid g2" style="gap:12px">
-
 
937
        <div>
-
 
938
          <label class="lbl">Answer type</label>
-
 
939
          <select id="qType" class="inp" style="width:100%" onchange="qTypeChanged()">
-
 
940
            <option value="TEXT">Text</option><option value="NUMBER">Number</option>
-
 
941
            <option value="YESNO">Yes / No</option><option value="CHOICE">Choice</option>
-
 
942
          </select>
-
 
943
        </div>
-
 
944
        <div>
-
 
945
          <label class="lbl">Points</label>
-
 
946
          <input id="qPoints" type="number" min="0" step="1" class="inp" style="width:100%" value="0">
-
 
947
        </div>
-
 
948
      </div>
-
 
949
      <div id="qChoicesWrap" class="hide">
-
 
950
        <label class="lbl">Options &mdash; separate with |</label>
-
 
951
        <input id="qChoices" class="inp" style="width:100%" placeholder="Yes|No|Not sure">
-
 
952
      </div>
-
 
953
      <label class="lbl">Show at these stages</label>
-
 
954
      <div class="opts" id="qStages"></div>
-
 
955
      <div class="note">None selected means every stage.</div>
-
 
956
      <div class="grid g2" style="gap:12px">
-
 
957
        <div>
-
 
958
          <label class="lbl">Sort order</label>
-
 
959
          <input id="qOrder" type="number" step="10" class="inp" style="width:100%" value="0">
-
 
960
        </div>
-
 
961
        <div>
-
 
962
          <label class="lbl">&nbsp;</label>
-
 
963
          <div style="display:flex;gap:14px;align-items:center;padding-top:8px">
-
 
964
            <label style="font-size:13px"><input type="checkbox" id="qRequired"> Required</label>
-
 
965
            <label style="font-size:13px"><input type="checkbox" id="qActive" checked> Active</label>
-
 
966
          </div>
-
 
967
        </div>
-
 
968
      </div>
-
 
969
      <div id="qErr" class="hide" style="margin-top:10px;color:#e03131;font-size:12.5px"></div>
-
 
970
    </div>
-
 
971
    <div class="mf">
-
 
972
      <button class="btn ghost" id="qDelBtn" onclick="deleteQuestion()" style="margin-right:auto">Delete</button>
-
 
973
      <button class="btn ghost" onclick="closeQ()">Cancel</button>
-
 
974
      <button class="btn" style="background:#1f2a5e;color:#fff" id="qSaveBtn" onclick="saveQuestion()">Save</button>
-
 
975
    </div>
-
 
976
  </div>
-
 
977
</div>
-
 
978
 
-
 
979
<div class="modal hide" id="asgModal">
-
 
980
  <div class="box">
-
 
981
    <div class="mh"><h2>Assign lead</h2>
-
 
982
      <p class="desc" id="asgWho">&mdash;</p></div>
-
 
983
    <div class="mb">
-
 
984
      <input id="asgSearch" class="inp" style="width:100%" placeholder="Filter by name or tier…"
-
 
985
             oninput="renderAssignList()">
-
 
986
      <div style="display:flex;gap:6px;margin:10px 0 4px;flex-wrap:wrap" id="asgTiers"></div>
-
 
987
      <div id="asgList" style="max-height:340px;overflow-y:auto;margin-top:6px"></div>
-
 
988
    </div>
-
 
989
    <div class="mf"><button class="btn ghost" onclick="closeAssign()">Cancel</button></div>
-
 
990
  </div>
-
 
991
</div>
-
 
992
 
729
<div class="toastwrap" id="toasts"></div>
993
<div class="toastwrap" id="toasts"></div>
730
 
994
 
731
<script>
995
<script>
732
  var CTX = "${rc.contextPath}";
996
  var CTX = "${rc.contextPath}";
733
  var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
997
  var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
Line 735... Line 999...
735
  var DATA = { summary:null, state:null, heat:null, ageing:null };
999
  var DATA = { summary:null, state:null, heat:null, ageing:null };
736
 
1000
 
737
  function byId(x){ return document.getElementById(x); }
1001
  function byId(x){ return document.getElementById(x); }
738
  function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
1002
  function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
739
  function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
1003
  function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
740
  function fmtInr(v){ if(v==null||v===''||isNaN(v)||Number(v)<=0) return '&#8377;0'; v=Number(v);
-
 
741
    if(v>=1e7) return '&#8377;'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '&#8377;'+(v/1e5).toFixed(1)+'<small> L</small>';
-
 
742
    if(v>=1e3) return '&#8377;'+Math.round(v/1e3)+'<small>K</small>'; return '&#8377;'+Math.round(v); }
-
 
743
  /**
1004
  /**
744
   * Business value in lakhs.
1005
   * Business value in lakhs.
745
   *
1006
   *
746
   * Two conventions live in user.lead.potential: the form captures LAKHS (rows hold 1–99), but
1007
   * Two conventions live in user.lead.potential: the form captures LAKHS (rows hold 1–99), but
747
   * ~1,355 older rows were entered in RUPEES (1,000 – 2,00,00,000). Nothing between 100 and 999
1008
   * ~1,355 older rows were entered in RUPEES (1,000 – 2,00,00,000). Nothing between 100 and 999
Line 756... Line 1017...
756
    // ₹0L — the same information loss the old scaling caused, just at the other end.
1017
    // ₹0L — the same information loss the old scaling caused, just at the other end.
757
    var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
1018
    var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
758
    return '&#8377;'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
1019
    return '&#8377;'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
759
 
1020
 
760
  // ---- tabs
1021
  // ---- tabs
761
  var SCREENS=['cmd','record','create','assign','dash','audit'];
1022
  var SCREENS=['cmd','record','create','assign','live','dash','chk','audit'];
762
  var LOADED={};
1023
  var LOADED={};
763
  function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
1024
  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.
-
 
1026
    if(id==='live'){ startLive(); } else { stopLive(); }
-
 
1027
    if(id==='chk') loadQuestions();
764
    document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');
1028
    document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');
765
    if(id==='dash') byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||'');
1029
    if(id==='dash'){ byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||''); loadBgc(); }
766
    if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }
1030
    if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }
767
    if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }
1031
    if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }
768
    if(id==='audit'){ buildAudit(); } }
1032
    if(id==='audit'){ buildAudit(); } }
769
 
1033
 
770
  // ---- toast
1034
  // ---- toast
Line 834... Line 1098...
834
  // one known retailer, and quietly scoping it to the selected month would report "not found" for a
1098
  // one known retailer, and quietly scoping it to the selected month would report "not found" for a
835
  // lead that exists. The caption says which mode is in force so that is never a surprise.
1099
  // lead that exists. The caption says which mode is in force so that is never a surprise.
836
  var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
1100
  var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
837
 
1101
 
838
  function loadPipeline(){
1102
  function loadPipeline(){
839
    byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">Loading…</div></td></tr>';
1103
    byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">Loading…</div></td></tr>';
840
    var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
1104
    var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
841
          '&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);
1105
          '&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);
842
    if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q);
1106
    if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q);
843
                if(STATE.regionId) p+='&regionId='+encodeURIComponent(STATE.regionId); }
1107
                if(STATE.regionId) p+='&regionId='+encodeURIComponent(STATE.regionId); }
844
    else { p+=qs(); }
1108
    else { p+=qs(); }
845
    getJSON(p,function(d){
1109
    getJSON(p,function(d){
846
      d=d||{}; var rows=d.rows||[];
1110
      d=d||{}; var rows=d.rows||[];
847
      PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
1111
      PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
848
      renderPipeHead();
1112
      renderPipeHead();
849
      if(!rows.length){
1113
      if(!rows.length){
850
        byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">'+
1114
        byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">'+
851
          (PIPE.q?('No lead matches “'+esc(PIPE.q)+'”.'):'No leads in scope.')+'</div></td></tr>';
1115
          (PIPE.q?('No lead matches “'+esc(PIPE.q)+'”.'):'No leads in scope.')+'</div></td></tr>';
852
      } else {
1116
      } else {
853
        var h='';
1117
        var h='';
854
        rows.forEach(function(l){
1118
        rows.forEach(function(l){
855
          var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');
1119
          var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');
Line 861... Line 1125...
861
            // title= so a name clipped by the fixed width is still readable on hover.
1125
            // title= so a name clipped by the fixed width is still readable on hover.
862
            '<td class="nowrap" title="'+esc(l.owner||'')+'">'+esc(l.owner||'—')+'</td>'+
1126
            '<td class="nowrap" title="'+esc(l.owner||'')+'">'+esc(l.owner||'—')+'</td>'+
863
            '<td class="nowrap" title="'+esc(l.city||'')+'">'+esc(l.city||'—')+'</td>'+
1127
            '<td class="nowrap" title="'+esc(l.city||'')+'">'+esc(l.city||'—')+'</td>'+
864
            '<td class="nowrap" title="'+esc(l.createdBy||'')+'">'+esc(l.createdBy||'—')+'</td>'+
1128
            '<td class="nowrap" title="'+esc(l.createdBy||'')+'">'+esc(l.createdBy||'—')+'</td>'+
865
            '<td class="nowrap">'+slaCell(l.slaState)+'</td>'+
1129
            '<td class="nowrap">'+slaCell(l.slaState)+'</td>'+
866
            '<td class="num nowrap">'+inrShort(l.value)+'</td></tr>';
1130
            '<td class="num nowrap">'+inrShort(l.value)+'</td>'+
-
 
1131
            // data-call carries the lead so the handler needs nothing from the row's other cells.
-
 
1132
            '<td class="nowrap" style="display:flex;gap:5px">'+
-
 
1133
              '<button class="rowcall" data-call="'+l.id+'" data-callname="'+esc(l.name||'')+'"'+
-
 
1134
                ' title="Call '+esc(l.name||('lead #'+l.id))+'">&#9742;</button>'+
-
 
1135
              '<button class="rowedit" data-editlead="'+l.id+'"'+
-
 
1136
                ' title="Edit '+esc(l.name||('lead #'+l.id))+'">&#9998;</button>'+
-
 
1137
              '<button class="rowassign" data-assign="'+l.id+'" data-assignname="'+esc(l.name||'')+'"'+
-
 
1138
                ' data-assignowner="'+esc(l.owner||'')+'" data-assignregion="'+esc(l.regionId||'')+'"'+
-
 
1139
                ' title="Assign '+esc(l.name||('lead #'+l.id))+'">&#128100;</button>'+
-
 
1140
            '</td></tr>';
867
        });
1141
        });
868
        byId('ccPipeline').innerHTML=h;
1142
        byId('ccPipeline').innerHTML=h;
869
      }
1143
      }
870
      // Say how many there actually are, not how many were drawn.
1144
      // Say how many there actually are, not how many were drawn.
871
      var shown=rows.length, first=(PIPE.page-1)*PIPE.pageSize+(shown?1:0), last=(PIPE.page-1)*PIPE.pageSize+shown;
1145
      var shown=rows.length, first=(PIPE.page-1)*PIPE.pageSize+(shown?1:0), last=(PIPE.page-1)*PIPE.pageSize+shown;
Line 953... Line 1227...
953
    h+='</tbody></table>'; byId('dashHeat').innerHTML=h;
1227
    h+='</tbody></table>'; byId('dashHeat').innerHTML=h;
954
  }
1228
  }
955
 
1229
 
956
  function renderAgeing(d){ DATA.ageing=d; buildDashKpis(); buildFix(); }
1230
  function renderAgeing(d){ DATA.ageing=d; buildDashKpis(); buildFix(); }
957
 
1231
 
-
 
1232
  /** Telephony totals for the calls/day tile. Failure leaves the tile honest rather than zeroed. */
-
 
1233
  function loadBgc(){
-
 
1234
    getJSON('/lms/dashboard/bgc-daily'+qsDates(), function(d){ DATA.bgc=d||null; buildDashKpis(); },
-
 
1235
      function(){ DATA.bgc=null; buildDashKpis(); });
-
 
1236
  }
-
 
1237
 
-
 
1238
  /** Days in the selected period — the calls/day tile divided by a hardcoded 90 regardless of it. */
-
 
1239
  function periodDays(){
-
 
1240
    if(!STATE.from || !STATE.to) return 1;
-
 
1241
    var a=new Date(STATE.from), b=new Date(STATE.to);
-
 
1242
    if(isNaN(a)||isNaN(b)) return 1;
-
 
1243
    return Math.max(1, Math.round((b-a)/86400000)+1);
-
 
1244
  }
-
 
1245
 
958
  function buildDashKpis(){ var s=DATA.summary; if(!s) return;
1246
  function buildDashKpis(){ var s=DATA.summary; if(!s) return;
959
    var contacted=0; (s.funnel||[]).forEach(function(f){ if(f.stage==='CONTACTED')contacted=f.count; });
-
 
960
    var tot=0,acc=0; ((DATA.state&&DATA.state.rows)||[]).forEach(function(r){ tot+=r.newCount; acc+=r.slaPct*r.newCount; });
1247
    var tot=0,acc=0; ((DATA.state&&DATA.state.rows)||[]).forEach(function(r){ tot+=r.newCount; acc+=r.slaPct*r.newCount; });
961
    var sla=tot>0?Math.round(acc/tot):0;
1248
    var sla=tot>0?Math.round(acc/tot):0;
-
 
1249
 
-
 
1250
    // Calls/day from the real call log. It used to be CONTACTED ÷ 90 labelled "proxy · no
-
 
1251
    // telephony" — a stage count divided by a constant that stopped matching the period the moment
-
 
1252
    // the default window became the current month. Now: calls placed ÷ days in the selected period,
-
 
1253
    // with the connect rate underneath, or an honest dash when the telephony data has not loaded.
-
 
1254
    var callsTile='—', callsFoot='no calls in period';
-
 
1255
    if(DATA.bgc){
-
 
1256
      var days=periodDays();
-
 
1257
      callsTile=fmtInt(Math.round((DATA.bgc.calls||0)/days));
-
 
1258
      callsFoot=(DATA.bgc.calls||0)+' calls · '+(DATA.bgc.connectPct||0)+'% connected';
-
 
1259
    }
-
 
1260
 
-
 
1261
    var ageDays=(DATA.ageing&&DATA.ageing.days)?DATA.ageing.days:7;
962
    var vals=[ {v:fmtInt(s.total),f:'in period'}, {v:s.conversionPct+'<small>%</small>',f:'new &rarr; onboarded'},
1262
    var vals=[ {v:fmtInt(s.total),f:'in period'}, {v:s.conversionPct+'<small>%</small>',f:'new &rarr; onboarded'},
963
      {v:sla+'<small>%</small>',f:'first contact ≤ 5h'}, {v:fmtInt(Math.round(contacted/90)),f:'proxy · no telephony'},
1263
      {v:sla+'<small>%</small>',f:'first contact ≤ 5h'}, {v:callsTile,f:callsFoot},
964
      {v:DATA.ageing?fmtInt(DATA.ageing.total):'—',f:'stuck by stage'}, {v:fmtInr(s.onboardedValue),f:'est. potential'} ];
1264
      {v:DATA.ageing?fmtInt(DATA.ageing.total):'—',f:'stuck by stage'},
-
 
1265
      // inrShort, not fmtInr: business value is stored in lakhs and every other figure on the page
-
 
1266
      // renders it that way. fmtInr printed a bare ₹139 for what is ₹139 lakh.
-
 
1267
      {v:inrShort(s.onboardedValue),f:'est. potential'} ];
965
    var labels=['LMS created','Conversion','SLA compliance','Calls / day (BGC)','Ageing &gt; 7 days','Onboarded value'];
1268
    var labels=['LMS created','Conversion','SLA compliance','Calls / day (BGC)',
-
 
1269
                'Ageing &gt; '+ageDays+' days','Onboarded value'];
966
    var h=''; vals.forEach(function(x,i){ var m=DASH_KPI[i];
1270
    var h=''; vals.forEach(function(x,i){ var m=DASH_KPI[i];
967
      h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+
1271
      h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+
968
        '<div class="label">'+labels[i]+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+x.v+'</div><div class="foot">'+x.f+'</div></div>'; });
1272
        '<div class="label">'+labels[i]+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+x.v+'</div><div class="foot">'+x.f+'</div></div>'; });
969
    byId('dashKpis').innerHTML=h;
1273
    byId('dashKpis').innerHTML=h;
970
  }
1274
  }
Line 1036... Line 1340...
1036
    'Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.',
1340
    'Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.',
1037
    'RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };
1341
    'RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };
1038
 
1342
 
1039
  // ---- delegated clicks
1343
  // ---- delegated clicks
1040
  document.addEventListener('click',function(e){
1344
  document.addEventListener('click',function(e){
-
 
1345
    var eq=e.target.closest('[data-editq]');
-
 
1346
    if(eq){ editQuestion(parseInt(eq.getAttribute('data-editq'),10)); return; }
-
 
1347
    // Stage chips are multi-select; the in-call answer chips are single-select.
-
 
1348
    var qs=e.target.closest('.qstage');
-
 
1349
    if(qs){ qs.classList.toggle('sel'); return; }
-
 
1350
    var co=e.target.closest('.chkopt');
-
 
1351
    if(co){ var wrap=co.parentNode;
-
 
1352
      wrap.querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
-
 
1353
      co.classList.add('sel'); return; }
-
 
1354
    var asg=e.target.closest('[data-assign]');
-
 
1355
    if(asg){ e.preventDefault(); e.stopPropagation();
-
 
1356
      openAssign(parseInt(asg.getAttribute('data-assign'),10), asg.getAttribute('data-assignname'),
-
 
1357
                 asg.getAttribute('data-assignowner'), asg.getAttribute('data-assignregion')); return; }
-
 
1358
    var pick=e.target.closest('[data-assignto]');
-
 
1359
    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]');
-
 
1364
    if(ed){ e.preventDefault(); e.stopPropagation();
-
 
1365
      editLeadById(parseInt(ed.getAttribute('data-editlead'),10)); return; }
-
 
1366
    var cb=e.target.closest('[data-call]');
-
 
1367
    if(cb){ e.preventDefault(); e.stopPropagation();
-
 
1368
      dialLead(parseInt(cb.getAttribute('data-call'),10), cb.getAttribute('data-callname'), cb); return; }
-
 
1369
    var la=e.target.closest('tr[data-liveagent]');
-
 
1370
    if(la){ openAgentCalls(parseInt(la.getAttribute('data-liveagent'),10)); return; }
1041
    var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
1371
    var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
1042
    var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
1372
    var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
1043
      drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
1373
      drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
1044
    var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
1374
    var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
1045
      drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }
1375
      drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }
1046
    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; }
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; }
1047
    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; }
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; }
1048
    var rep=e.target.closest('tr[data-rep]'); if(rep){ var nm=rep.getAttribute('data-rep');
1378
    var rep=e.target.closest('tr[data-rep]'); if(rep){ openReport(rep.getAttribute('data-rep')); return; }
1049
      openDrawer('Report suite',nm,'SOP §18.2','<div class="card"><div class="dnote">'+esc(REPORT_NOTE[nm]||'')+'</div></div>'); return; }
-
 
1050
    var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');
1379
    var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');
1051
      closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
1380
      closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
1052
      go('record',document.querySelectorAll('.tab')[1]); return; }
1381
      go('record',document.querySelectorAll('.tab')[1]); return; }
1053
  });
1382
  });
1054
 
1383
 
Line 1171... Line 1500...
1171
    // front of the agent, not in the response to a dial they already started.
1500
    // front of the agent, not in the response to a dial they already started.
1172
    var call='<div class="card"><h2>Click-to-call</h2>';
1501
    var call='<div class="card"><h2>Click-to-call</h2>';
1173
    if(d.dnd){ call+='<p class="desc" style="color:#e03131"><b>Do-not-call.</b> '+esc(d.dndReason||'This retailer asked not to be contacted.')+'</p>'; }
1502
    if(d.dnd){ call+='<p class="desc" style="color:#e03131"><b>Do-not-call.</b> '+esc(d.dndReason||'This retailer asked not to be contacted.')+'</p>'; }
1174
    else if(!d.contact){ call+='<p class="desc">No contact number on this lead.</p>'; }
1503
    else if(!d.contact){ call+='<p class="desc">No contact number on this lead.</p>'; }
1175
    else { call+='<p class="desc">Bridged call &middot; disposition required on disconnect.</p>'+
1504
    else { call+='<p class="desc">Bridged call &middot; disposition required on disconnect.</p>'+
1176
      '<button class="btn coral" id="callBtn" onclick="startCall()">&#9742; Call retailer (C2C)</button>'+
1505
      '<button class="btn coral" id="callBtn" onclick="startCall()">&#9742; Call retailer (C2C)</button>'; }
1177
      '<div id="callBar" class="hide" style="margin-top:12px;padding:12px;border:1px solid #e6e9f2;border-radius:10px;background:#f8f9fc">'+
-
 
1178
        '<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">'+
-
 
1179
          '<span id="callState" style="font-weight:700;color:#1f2a5e">Starting&hellip;</span>'+
-
 
1180
          '<span id="callTimer" class="mono" style="color:#69748c">00:00</span></div>'+
-
 
1181
        '<div style="margin-top:10px;display:flex;gap:8px">'+
-
 
1182
          '<button class="btn ghost sm" onclick="endCall()">Hang up</button>'+
-
 
1183
          '<button class="btn ghost sm" onclick="openDisp()">Disposition&hellip;</button></div>'+
-
 
1184
        '<div id="callErr" class="hide" style="margin-top:8px;color:#e03131;font-size:12px"></div></div>'; }
-
 
1185
    call+='<div class="note" style="margin-top:10px">A call is only worth what its disposition says — log one on every disconnect.</div></div>';
1506
    call+='<div class="note" style="margin-top:10px">A call is only worth what its disposition says — log one on every disconnect.</div></div>';
1186
 
1507
 
1187
    // ---- Beat plan. Read-only on purpose: adding a stop belongs to the beat planner, and the
1508
    // ---- Beat plan. Read-only on purpose: adding a stop belongs to the beat planner, and the
1188
    // gate that matters (an approved geo-pin) is what this card exists to report.
1509
    // gate that matters (an approved geo-pin) is what this card exists to report.
1189
    // The card used to state the geo gate and leave the agent to go and find the Leads screen.
1510
    // The card used to state the geo gate and leave the agent to go and find the Leads screen.
Line 1220... Line 1541...
1220
    byId('recordHost').innerHTML=head+step+
1541
    byId('recordHost').innerHTML=head+step+
1221
      '<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
1542
      '<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
1222
    capTrail();
1543
    capTrail();
1223
  }
1544
  }
1224
 
1545
 
-
 
1546
  // ====================== LIVE CALL BOARD ===================================================
-
 
1547
  // 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
-
 
1549
  // mid-read and throws away their scroll position. Same approach the RBM board takes.
-
 
1550
 
-
 
1551
  var LIVE = { timer:null, built:false, rows:[], failures:0, lastAt:null };
-
 
1552
  var LIVE_MS = 10000;
-
 
1553
 
-
 
1554
  var LSTAT={'On Call':'oncall','Wrap Up':'wrap','Idle':'idle','Available':'avail','—':'off'};
-
 
1555
  function lstat(s){ return '<span class="lstat '+(LSTAT[s]||'off')+'">'+esc(s)+'</span>'; }
-
 
1556
  /** hh:mm:ss, like the RBM board's time-in-status. */
-
 
1557
  function hms(sec){
-
 
1558
    if(sec==null) return '—';
-
 
1559
    sec=Math.max(0,Math.floor(sec));
-
 
1560
    var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s2=sec%60;
-
 
1561
    return (h?('0'+h).slice(-2)+':':'')+('0'+m).slice(-2)+':'+('0'+s2).slice(-2);
-
 
1562
  }
-
 
1563
 
-
 
1564
  /** Successful refresh: stamp the time, pulse the dot. Time only — the board is always today. */
-
 
1565
  function liveBeat(asOf){
-
 
1566
    LIVE.failures=0;
-
 
1567
    LIVE.lastAt=asOf?new Date(asOf):new Date();
-
 
1568
    var dot=byId('liveDot'); if(dot){ dot.classList.remove('stalled','beat');
-
 
1569
      void dot.offsetWidth;                  // restart the animation on a repeat refresh
-
 
1570
      dot.classList.add('beat'); }
-
 
1571
    byId('liveAsOf').textContent='updated '+clockOf(LIVE.lastAt);
-
 
1572
  }
-
 
1573
  function liveStalled(){
-
 
1574
    var dot=byId('liveDot'); if(dot){ dot.classList.remove('beat'); dot.classList.add('stalled'); }
-
 
1575
    byId('liveAsOf').textContent=LIVE.lastAt
-
 
1576
      ? 'not updating · last '+clockOf(LIVE.lastAt) : 'not updating';
-
 
1577
  }
-
 
1578
  function clockOf(d){
-
 
1579
    if(!d || isNaN(d)) return '—';
-
 
1580
    return ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2);
-
 
1581
  }
-
 
1582
 
-
 
1583
  function startLive(){
-
 
1584
    LIVE.failures=0;
-
 
1585
    // Default the log window to today; the board above is always "now" so this matches it.
-
 
1586
    if(!byId('clFrom').value){
-
 
1587
      var t=new Date().toISOString().slice(0,10);
-
 
1588
      byId('clFrom').value=t; byId('clTo').value=t;
-
 
1589
    }
-
 
1590
    loadCallLog(1);
-
 
1591
    loadLive();
-
 
1592
    stopLive();
-
 
1593
    LIVE.timer=setInterval(loadLive, LIVE_MS);
-
 
1594
  }
-
 
1595
  // Must be cleared on tab change, or every visit leaves another interval polling forever.
-
 
1596
  function stopLive(){ if(LIVE.timer){ clearInterval(LIVE.timer); LIVE.timer=null; } }
-
 
1597
 
-
 
1598
  function loadLive(){
-
 
1599
    getJSON('/lms/dashboard/live-calls', function(d){
-
 
1600
      d=d||{}; LIVE.rows=d.rows||[];
-
 
1601
      liveBeat(d.asOf);
-
 
1602
      byId('liveKpis').innerHTML=
-
 
1603
        kpiTile('On call now', fmtInt(d.onCallNow))+
-
 
1604
        kpiTile('Calls today', fmtInt(d.calls))+
-
 
1605
        kpiTile('Connect rate', (d.connectPct||0)+'%')+
-
 
1606
        kpiTile('Talk time', (d.talkMinutes||0)+' min')+
-
 
1607
        kpiTile('Agents', fmtInt(d.agents));
-
 
1608
      if(!LIVE.built){ buildLiveTable(); LIVE.built=true; } else { patchLiveTable(); }
-
 
1609
    }, function(){
-
 
1610
      // A dropped poll keeps the last good picture, but must not keep claiming to be live.
-
 
1611
      LIVE.failures++;
-
 
1612
      if(LIVE.failures>=2) liveStalled();
-
 
1613
    });
-
 
1614
  }
-
 
1615
 
-
 
1616
  function buildLiveTable(){
-
 
1617
    if(!LIVE.rows.length){
-
 
1618
      byId('liveBody').innerHTML='<tr><td colspan="11"><div class="ph">No agents mapped to the dialer yet.</div></td></tr>';
-
 
1619
      return;
-
 
1620
    }
-
 
1621
    byId('liveBody').innerHTML=LIVE.rows.map(function(r){
-
 
1622
      return '<tr class="clickrow" data-liveagent="'+r.authId+'">'+
-
 
1623
        '<td class="nowrap"><b>'+esc(r.agent)+'</b>'+(r.extension?'<br><span style="color:#97a1b5;font-size:11px">ext '+esc(r.extension)+'</span>':'')+'</td>'+
-
 
1624
        '<td class="nowrap" data-live="status" data-authid="'+r.authId+'">'+liveStatusCell(r)+'</td>'+
-
 
1625
        '<td class="nowrap mono" data-live="since" data-authid="'+r.authId+'">'+hms(r.sinceSeconds)+'</td>'+
-
 
1626
        '<td class="num" data-live="calls" data-authid="'+r.authId+'">'+fmtInt(r.calls)+'</td>'+
-
 
1627
        '<td class="num" data-live="connected" data-authid="'+r.authId+'">'+fmtInt(r.connected)+'</td>'+
-
 
1628
        '<td class="num" data-live="connectPct" data-authid="'+r.authId+'">'+pctCell(r.connectPct)+'</td>'+
-
 
1629
        '<td class="num" data-live="talk" data-authid="'+r.authId+'">'+secs(r.talkSeconds)+'</td>'+
-
 
1630
        '<td class="num" data-live="longest" data-authid="'+r.authId+'">'+secs(r.longestSeconds)+'</td>'+
-
 
1631
        '<td class="num">'+fmtInt(r.assigned)+'</td>'+
-
 
1632
        '<td class="num" data-live="touched" data-authid="'+r.authId+'">'+fmtInt(r.touched)+'</td>'+
-
 
1633
        '<td class="num" data-live="sla" data-authid="'+r.authId+'">'+slaBreachCell(r.slaBreached)+'</td></tr>';
-
 
1634
    }).join('');
-
 
1635
  }
-
 
1636
 
-
 
1637
  function liveStatusCell(r){
-
 
1638
    // A stale row means the agent closed the tab mid-call; say so rather than silently showing Idle.
-
 
1639
    return lstat(r.status)+(r.staleCall?'<span class="lstale" title="A call was never closed — the agent likely closed the tab mid-call">unclosed call</span>':'');
-
 
1640
  }
-
 
1641
  function slaBreachCell(n){
-
 
1642
    n=Number(n)||0;
-
 
1643
    return n>0?'<span style="font-weight:700;color:#e03131">'+fmtInt(n)+'</span>':'0';
-
 
1644
  }
-
 
1645
 
-
 
1646
  function patchLiveTable(){
-
 
1647
    // A new agent provisioned mid-session changes the row set — rebuild rather than patch.
-
 
1648
    if(LIVE.rows.length!==document.querySelectorAll('#liveBody tr[data-liveagent]').length){
-
 
1649
      buildLiveTable(); return;
-
 
1650
    }
-
 
1651
    LIVE.rows.forEach(function(r){
-
 
1652
      set(r.authId,'status',liveStatusCell(r));
-
 
1653
      set(r.authId,'since',hms(r.sinceSeconds));
-
 
1654
      set(r.authId,'calls',fmtInt(r.calls));
-
 
1655
      set(r.authId,'connected',fmtInt(r.connected));
-
 
1656
      set(r.authId,'connectPct',pctCell(r.connectPct));
-
 
1657
      set(r.authId,'talk',secs(r.talkSeconds));
-
 
1658
      set(r.authId,'longest',secs(r.longestSeconds));
-
 
1659
      set(r.authId,'touched',fmtInt(r.touched));
-
 
1660
      set(r.authId,'sla',slaBreachCell(r.slaBreached));
-
 
1661
    });
-
 
1662
    function set(authId,key,html){
-
 
1663
      var el=document.querySelector('#liveBody td[data-live="'+key+'"][data-authid="'+authId+'"]');
-
 
1664
      if(el && el.innerHTML!==html) el.innerHTML=html;
-
 
1665
    }
-
 
1666
  }
-
 
1667
 
-
 
1668
  function exportLive(){
-
 
1669
    if(!LIVE.rows.length){ toast('Nothing to export'); return; }
-
 
1670
    var cols=[['agent','Agent'],['extension','Extension'],['status','Status'],['sinceSeconds','Seconds in status'],
-
 
1671
              ['calls','Calls'],['connected','Connected'],['connectPct','Connect %'],['talkSeconds','Talk seconds'],
-
 
1672
              ['longestSeconds','Longest call seconds'],['assigned','Leads assigned'],['touched','Touched today'],
-
 
1673
              ['dispositioned','Dispositioned today'],['slaBreached','SLA breached']];
-
 
1674
    var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
-
 
1675
    LIVE.rows.forEach(function(r){
-
 
1676
      csv.push(cols.map(function(c){ var v=r[c[0]]; if(v==null) v='';
-
 
1677
        return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
-
 
1678
    });
-
 
1679
    var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
-
 
1680
    var a=document.createElement('a');
-
 
1681
    a.href=URL.createObjectURL(blob);
-
 
1682
    a.download='lms-live-calls-'+new Date().toISOString().slice(0,10)+'.csv';
-
 
1683
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
-
 
1684
    URL.revokeObjectURL(a.href);
-
 
1685
    toast('Exported '+LIVE.rows.length+' agent(s)',1);
-
 
1686
  }
-
 
1687
 
-
 
1688
  // ---- Call log -----------------------------------------------------------------------------
-
 
1689
  var CL = { page:1, pageSize:50, pages:1, total:0, rows:[] };
-
 
1690
 
-
 
1691
  var CLSTAT={COMPLETED:'oncall',ANSWERED:'oncall',RINGING:'avail',INITIATED:'avail',
-
 
1692
              UNANSWERED:'idle',BUSY:'idle',REJECTED:'off',FAILED:'off',CANCELLED:'off'};
-
 
1693
  function clStatus(s){ return '<span class="lstat '+(CLSTAT[s]||'off')+'">'+esc((s||'').replace(/_/g,' '))+'</span>'; }
-
 
1694
 
-
 
1695
  function clDates(){
-
 
1696
    var f=byId('clFrom').value, t=byId('clTo').value, q='?page='+CL.page+'&pageSize='+CL.pageSize;
-
 
1697
    if(f) q+='&from='+encodeURIComponent(f);
-
 
1698
    if(t) q+='&to='+encodeURIComponent(t);
-
 
1699
    return q;
-
 
1700
  }
-
 
1701
 
-
 
1702
  function loadCallLog(page){
-
 
1703
    if(page) CL.page=page;
-
 
1704
    byId('clBody').innerHTML='<tr><td colspan="9"><div class="ph">Loading&hellip;</div></td></tr>';
-
 
1705
    getJSON('/lms/dashboard/call-log'+clDates(), function(d){
-
 
1706
      d=d||{}; CL.rows=d.rows||[]; CL.total=d.total||0; CL.pages=d.pages||1; CL.page=d.page||1;
-
 
1707
      if(!CL.rows.length){
-
 
1708
        byId('clBody').innerHTML='<tr><td colspan="9"><div class="ph">No calls in this window.</div></td></tr>';
-
 
1709
      } else {
-
 
1710
        byId('clBody').innerHTML=CL.rows.map(function(r){
-
 
1711
          return '<tr class="clickrow"'+(r.leadId?' data-leadid="'+r.leadId+'"':'')+'>'+
-
 
1712
            '<td class="nowrap">'+fmtDT(r.startedAt)+'</td>'+
-
 
1713
            '<td class="nowrap" title="'+esc(r.agent||'')+'">'+esc(r.agent||'—')+
-
 
1714
              (r.extension?'<br><span style="color:#97a1b5;font-size:11px">ext '+esc(r.extension)+'</span>':'')+'</td>'+
-
 
1715
            '<td><b>'+esc(r.retailer||('#'+(r.leadId||'')))+'</b>'+
-
 
1716
              (r.lmsCode?'<br><span class="mono" style="color:#97a1b5;font-size:11px">'+esc(r.lmsCode)+'</span>':'')+'</td>'+
-
 
1717
            '<td class="nowrap mono">'+esc(r.toNumber||'—')+'</td>'+
-
 
1718
            '<td class="nowrap">'+clStatus(r.status)+'</td>'+
-
 
1719
            // A dash, not 0s: never answered is not the same as answered instantly.
-
 
1720
            '<td class="num nowrap">'+(r.ringSeconds==null?'—':secs(r.ringSeconds))+'</td>'+
-
 
1721
            '<td class="num nowrap">'+(r.talkSeconds?secs(r.talkSeconds):'—')+'</td>'+
-
 
1722
            '<td class="nowrap">'+clRec(r)+'</td>'+
-
 
1723
            '<td>'+esc(r.disposition||'—')+'</td></tr>';
-
 
1724
        }).join('');
-
 
1725
      }
-
 
1726
      var first=(CL.page-1)*CL.pageSize+(CL.rows.length?1:0), last=(CL.page-1)*CL.pageSize+CL.rows.length;
-
 
1727
      byId('clCount').textContent=CL.total?('Showing '+first+'–'+last+' of '+fmtInt(CL.total)+' calls'):'';
-
 
1728
      byId('clPageLbl').textContent='Page '+CL.page+' / '+Math.max(CL.pages,1);
-
 
1729
      byId('clPrev').disabled=(CL.page<=1);
-
 
1730
      byId('clNext').disabled=(CL.page>=CL.pages);
-
 
1731
    });
-
 
1732
  }
-
 
1733
 
-
 
1734
  function clRec(r){
-
 
1735
    if(r.recording==='none') return '<span style="color:#97a1b5">—</span>';
-
 
1736
    // Playback goes through the gated endpoint, never a vendor URL: it enforces the access matrix
-
 
1737
    // and logs the open (SOP §15).
-
 
1738
    return '<audio controls preload="none" style="height:28px;max-width:150px" src="'+CTX+'/lms/recording/'+r.callId+'"></audio>'+
-
 
1739
      (r.recording==='vendor'?'<span class="note" style="display:inline;margin-left:4px">archiving…</span>':'');
-
 
1740
  }
-
 
1741
 
-
 
1742
  function clPage(step){
-
 
1743
    var n=CL.page+step;
-
 
1744
    if(n<1||n>CL.pages) return;
-
 
1745
    loadCallLog(n);
-
 
1746
  }
-
 
1747
 
-
 
1748
  function exportCallLog(){
-
 
1749
    if(!CL.rows.length){ toast('Nothing to export'); return; }
-
 
1750
    var cols=[['startedAt','Started'],['answeredAt','Answered'],['endedAt','Ended'],['agent','Agent'],
-
 
1751
              ['extension','Extension'],['lmsCode','LMS ID'],['retailer','Retailer'],['outlet','Business'],
-
 
1752
              ['toNumber','Number'],['direction','Direction'],['status','Status'],
-
 
1753
              ['ringSeconds','Ring seconds'],['talkSeconds','Talk seconds'],['recording','Recording'],
-
 
1754
              ['disposition','Disposition']];
-
 
1755
    var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
-
 
1756
    CL.rows.forEach(function(r){
-
 
1757
      csv.push(cols.map(function(c){ var v=r[c[0]]; if(v==null) v='';
-
 
1758
        return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
-
 
1759
    });
-
 
1760
    var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
-
 
1761
    var a=document.createElement('a');
-
 
1762
    a.href=URL.createObjectURL(blob);
-
 
1763
    a.download='lms-call-log-'+(byId('clFrom').value||'all')+'.csv';
-
 
1764
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
-
 
1765
    URL.revokeObjectURL(a.href);
-
 
1766
    toast('Exported '+CL.rows.length+' call(s)',1);
-
 
1767
  }
-
 
1768
 
-
 
1769
  // ====================== STANDARD REPORT SUITE (SOP §18.2) =================================
-
 
1770
  // Each catalogue row opens the real report in the drawer rather than a sentence describing it.
-
 
1771
  // The four that were already "Live" reuse data this page has loaded; BGC Daily and Audit Hygiene
-
 
1772
  // call their own endpoints. RBM Servicing stays honest about needing order data it cannot reach.
-
 
1773
 
-
 
1774
  function secs(n){ n=Number(n)||0; var m=Math.floor(n/60); return m? (m+'m '+(n%60)+'s') : (n+'s'); }
-
 
1775
  function pctCell(v){ var c=v>=80?'#2f9e44':(v>=50?'#f08c00':'#e03131');
-
 
1776
    return '<span style="font-weight:700;color:'+c+'">'+v+'%</span>'; }
-
 
1777
  function tbl(head, rows){
-
 
1778
    if(!rows.length) return '<div class="ph">Nothing in the selected period.</div>';
-
 
1779
    return '<table><thead><tr>'+head.map(function(h){
-
 
1780
        return '<th'+(h.n?' class="num"':'')+'>'+esc(h.t)+'</th>'; }).join('')+'</tr></thead><tbody>'+
-
 
1781
      rows.join('')+'</tbody></table>';
-
 
1782
  }
-
 
1783
 
-
 
1784
  function openReport(name){
-
 
1785
    openDrawer('Report suite', name, 'SOP §18.2', '<div class="ph">Loading…</div>');
-
 
1786
    if(name==='BGC Daily')          return reportBgcDaily();
-
 
1787
    if(name==='Audit Hygiene')      return reportAuditHygiene();
-
 
1788
    if(name==='State Funnel')       return reportStateFunnel();
-
 
1789
    if(name==='SLA Compliance')     return reportSla();
-
 
1790
    if(name==='Ageing')             return reportAgeing();
-
 
1791
    if(name==='Management Roll-up') return reportRollup();
-
 
1792
    // RBM Servicing: repeat-order rate, complaint rate and resolution TAT all live in order and
-
 
1793
    // ticket data the lead record does not reach. Say so rather than showing an empty table.
-
 
1794
    openDrawer('Report suite', name, 'SOP §18.2',
-
 
1795
      '<div class="card"><div class="dnote">Repeat-order rate, complaint rate and resolution TAT need order '+
-
 
1796
      'and ticket data, which the LMS does not hold. Post-sales (SOP §14) is not built yet — the lead '+
-
 
1797
      'record stops at ONBOARDED.</div></div>');
-
 
1798
  }
-
 
1799
 
-
 
1800
  function reportBgcDaily(){
-
 
1801
    getJSON('/lms/dashboard/bgc-daily'+qsDates(),function(d){
-
 
1802
      d=d||{};
-
 
1803
      var head=[{t:'Day'},{t:'Agent'},{t:'Calls',n:1},{t:'Connected',n:1},{t:'Connect%',n:1},{t:'Avg talk',n:1},{t:'Longest',n:1}];
-
 
1804
      var rows=(d.rows||[]).map(function(r){
-
 
1805
        return '<tr><td class="nowrap">'+esc(r.day||'—')+'</td><td>'+esc(r.agent)+'</td>'+
-
 
1806
          '<td class="num">'+fmtInt(r.calls)+'</td><td class="num">'+fmtInt(r.connected)+'</td>'+
-
 
1807
          '<td class="num">'+pctCell(r.connectPct)+'</td><td class="num">'+secs(r.avgTalkSeconds)+'</td>'+
-
 
1808
          '<td class="num">'+secs(r.longestSeconds)+'</td></tr>'; });
-
 
1809
      var kpi='<div class="kpis" style="margin-bottom:12px">'+
-
 
1810
        kpiTile('Calls placed', fmtInt(d.calls))+kpiTile('Connected', fmtInt(d.connected))+
-
 
1811
        kpiTile('Connect rate', (d.connectPct||0)+'%')+kpiTile('Talk time', (d.talkMinutes||0)+' min')+'</div>';
-
 
1812
      openDrawer('Report suite','BGC Daily','Real telephony from the call log',
-
 
1813
        kpi+'<div class="card">'+tbl(head,rows)+
-
 
1814
        '<div class="note">Connect rate = answered ÷ placed. Average talk time counts connected calls only — '+
-
 
1815
        'averaging over every dial would make a day of unanswered ringing look like short conversations.</div></div>');
-
 
1816
    });
-
 
1817
  }
-
 
1818
 
-
 
1819
  function reportAuditHygiene(){
-
 
1820
    getJSON('/lms/dashboard/audit-hygiene'+qsDates(),function(d){
-
 
1821
      d=d||{};
-
 
1822
      var kpi='<div class="kpis" style="margin-bottom:12px">'+
-
 
1823
        kpiTile('Missing dispositions', fmtInt(d.missingDisposition))+
-
 
1824
        kpiTile('SLA breached', fmtInt(d.slaBreached))+
-
 
1825
        kpiTile('HOLD (no owner)', fmtInt(d.hold))+
-
 
1826
        kpiTile('DND register', fmtInt(d.dndRegister))+'</div>';
-
 
1827
      var head=[{t:'When'},{t:'Lead'},{t:'Number'},{t:'Call'}];
-
 
1828
      var rows=(d.dndBreaches||[]).map(function(b){
-
 
1829
        return '<tr class="clickrow" data-leadid="'+b.leadId+'"><td class="nowrap">'+fmtDT(b.when)+'</td>'+
-
 
1830
          '<td>#'+b.leadId+'</td><td class="mono">'+esc(b.mobile||'—')+'</td><td>#'+b.callId+'</td></tr>'; });
-
 
1831
      openDrawer('Report suite','Audit Hygiene','SOP §17',
-
 
1832
        kpi+
-
 
1833
        '<div class="card"><h2>DND breaches</h2><p class="desc">Calls placed to a number already on the '+
-
 
1834
        'do-not-call register. The pre-check blocks these, so a row here is a bypass worth asking about.</p>'+
-
 
1835
        tbl(head,rows)+'</div>'+
-
 
1836
        '<div class="card"><h2>Recording access</h2><div class="kv">'+
-
 
1837
        kvRow('Opens granted', fmtInt(d.recordingOpens))+
-
 
1838
        kvRow('Opens denied', fmtInt(d.recordingDenied))+'</div>'+
-
 
1839
        '<div class="note">Every open and every refusal is logged against the viewer (SOP §15). '+
-
 
1840
        'Repeated denials are what a scope probe looks like.</div></div>');
-
 
1841
    });
-
 
1842
  }
-
 
1843
 
-
 
1844
  function reportStateFunnel(){
-
 
1845
    var d=DATA.state||SEED.state||{}; var rows=(d.rows||[]).map(function(r){
-
 
1846
      return '<tr><td><b>'+esc(r.regionName||r.regionCode||'—')+'</b></td><td class="num">'+fmtInt(r.newCount)+'</td>'+
-
 
1847
        '<td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
-
 
1848
        '<td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
-
 
1849
        '<td class="num">'+pctCell(r.convPct||0)+'</td><td class="num">'+pctCell(r.slaPct||0)+'</td></tr>'; });
-
 
1850
    openDrawer('Report suite','State Funnel','NEW → ONBOARDED by region','<div class="card">'+
-
 
1851
      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>');
-
 
1852
  }
-
 
1853
 
-
 
1854
  function reportSla(){
-
 
1855
    var d=DATA.heat||SEED.heat||{}; var rows=(d.bms||[]).map(function(r){
-
 
1856
      var cells=(r.cells||[]).map(function(c){ return '<td class="num">'+(c.pct==null?'—':pctCell(c.pct))+'</td>'; }).join('');
-
 
1857
      return '<tr><td><b>'+esc(r.bmName||'—')+'</b></td>'+cells+'</tr>'; });
-
 
1858
    var head=[{t:'Owner'}].concat((d.weeks||[]).map(function(c){ return {t:c,n:1}; }));
-
 
1859
    openDrawer('Report suite','SLA Compliance','% first contact within 5 hrs','<div class="card">'+
-
 
1860
      tbl(head,rows)+'<div class="note">Blank cells mean no leads in that week — not 0% compliance.</div></div>');
-
 
1861
  }
-
 
1862
 
-
 
1863
  function reportAgeing(){
-
 
1864
    var d=DATA.ageing||SEED.ageing||{}; var rows=(d.rows||[]).map(function(r){
-
 
1865
      return '<tr><td><b>'+esc(r.label||r.stage||'—')+'</b></td>'+
-
 
1866
        '<td class="num">'+fmtInt(r.count)+'</td><td class="num">'+fmtInt(r.days)+'</td></tr>'; });
-
 
1867
    openDrawer('Report suite','Ageing','Open leads with no action for '+(d.days||7)+'+ days','<div class="card">'+
-
 
1868
      tbl([{t:'Stage'},{t:'Leads',n:1},{t:'Days idle',n:1}],rows)+
-
 
1869
      '<div class="note">Total stuck: '+fmtInt(d.total||0)+'</div></div>');
-
 
1870
  }
-
 
1871
 
-
 
1872
  function reportRollup(){
-
 
1873
    var s=DATA.summary||SEED.summary||{};
-
 
1874
    var kpi='<div class="kpis" style="margin-bottom:12px">'+
-
 
1875
      kpiTile('Leads in period', fmtInt(s.total))+
-
 
1876
      kpiTile('Onboarded', fmtInt(onboardedCount()))+
-
 
1877
      kpiTile('Conversion', (s.conversionPct||0)+'%')+
-
 
1878
      kpiTile('Onboarded value', inrShort(s.onboardedValue))+'</div>';
-
 
1879
    var rows=(s.funnel||[]).map(function(f){
-
 
1880
      return '<tr><td><b>'+esc(f.label||f.stage)+'</b></td><td class="num">'+fmtInt(f.count)+'</td>'+
-
 
1881
        '<td class="num">'+(f.pct==null?'—':pctCell(f.pct))+'</td></tr>'; });
-
 
1882
    openDrawer('Report suite','Management Roll-up','Cross-region',
-
 
1883
      kpi+'<div class="card">'+tbl([{t:'Stage'},{t:'Leads',n:1},{t:'% of top',n:1}],rows)+'</div>');
-
 
1884
  }
-
 
1885
 
-
 
1886
  function openAgentCalls(authId){
-
 
1887
    var r=null; LIVE.rows.forEach(function(x){ if(x.authId===authId) r=x; });
-
 
1888
    if(!r) return;
-
 
1889
    openDrawer('Live floor', r.agent, r.status+(r.extension?(' · ext '+r.extension):''),
-
 
1890
      '<div class="card"><div class="kv">'+
-
 
1891
        kvRow('Status', lstat(r.status))+
-
 
1892
        kvRow('In status', hms(r.sinceSeconds))+
-
 
1893
        kvRow('Calls today', fmtInt(r.calls)+' · '+fmtInt(r.connected)+' connected ('+(r.connectPct||0)+'%)')+
-
 
1894
        kvRow('Talk time', secs(r.talkSeconds)+' · longest '+secs(r.longestSeconds))+
-
 
1895
        kvRow('Last call', r.lastCallAt?fmtDT(r.lastCallAt):'—')+
-
 
1896
        kvRow('Leads assigned', fmtInt(r.assigned))+
-
 
1897
        kvRow('Touched today', fmtInt(r.touched)+' · '+fmtInt(r.dispositioned)+' dispositioned')+
-
 
1898
        kvRow('SLA breached', slaBreachCell(r.slaBreached))+
-
 
1899
      '</div>'+
-
 
1900
      (r.liveLeadId?('<div class="note">On a call now &mdash; <a href="#" data-leadid="'+r.liveLeadId+'">open lead #'+r.liveLeadId+'</a></div>'):'')+
-
 
1901
      (r.staleCall?'<div class="note" style="color:#e03131">An earlier call was never closed. Only the browser poll ends a VBC call, so a tab closed mid-call leaves the row open.</div>':'')+
-
 
1902
      '</div>');
-
 
1903
  }
-
 
1904
 
-
 
1905
  function kpiTile(label,val){
-
 
1906
    return '<div class="kpi"><div class="top"><div class="label">'+esc(label)+'</div></div>'+
-
 
1907
           '<div class="val">'+val+'</div></div>';
-
 
1908
  }
-
 
1909
  function qsDates(){ var q='?'; if(STATE.from)q+='from='+encodeURIComponent(STATE.from)+'&';
-
 
1910
    if(STATE.to)q+='to='+encodeURIComponent(STATE.to); return q; }
-
 
1911
 
-
 
1912
  // ---- Export (SOP §18.1) -------------------------------------------------------------------
-
 
1913
  // Exports what is on screen — the current filters, search and sort — rather than everything,
-
 
1914
  // because an export that ignores the filters is a different report than the one being looked at.
-
 
1915
  function exportPipeline(){
-
 
1916
    var p='/lms/dashboard/pipeline?page=1&pageSize=500&sort='+encodeURIComponent(PIPE.sort)+
-
 
1917
          '&dir='+encodeURIComponent(PIPE.dir);
-
 
1918
    if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q); if(STATE.regionId) p+='&regionId='+encodeURIComponent(STATE.regionId); }
-
 
1919
    else { p+=qs(); }
-
 
1920
    toast('Preparing export…');
-
 
1921
    getJSON(p,function(d){
-
 
1922
      var rows=(d&&d.rows)?d.rows:[];
-
 
1923
      if(!rows.length){ toast('Nothing to export'); return; }
-
 
1924
      var cols=[['lmsCode','LMS ID'],['name','Retailer'],['outlet','Business'],['path','Path'],
-
 
1925
                ['stageLabel','Stage'],['owner','Owner'],['city','City'],['createdBy','Created by'],
-
 
1926
                ['slaState','SLA'],['value','Business value (lakh)']];
-
 
1927
      var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
-
 
1928
      rows.forEach(function(r){
-
 
1929
        csv.push(cols.map(function(c){
-
 
1930
          var v=r[c[0]]; if(v==null) v='';
-
 
1931
          // Quote everything and double embedded quotes: names here contain commas routinely.
-
 
1932
          return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
-
 
1933
      });
-
 
1934
      var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
-
 
1935
      var a=document.createElement('a');
-
 
1936
      a.href=URL.createObjectURL(blob);
-
 
1937
      a.download='lms-pipeline-'+(STATE.from||'all')+'-to-'+(STATE.to||'now')+'.csv';
-
 
1938
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
-
 
1939
      URL.revokeObjectURL(a.href);
-
 
1940
      toast('Exported '+rows.length+' lead(s)',1);
-
 
1941
    });
-
 
1942
  }
-
 
1943
 
1225
  // ====================== RECORD ACTIONS (edit · call · disposition · stage) =================
1944
  // ====================== RECORD ACTIONS (edit · call · disposition · stage) =================
1226
  // Every control here drives the SAME endpoints the in-app record uses, so the two screens cannot
1945
  // Every control here drives the SAME endpoints the in-app record uses, so the two screens cannot
1227
  // drift into disagreeing about what a disposition or a stage move means.
1946
  // drift into disagreeing about what a disposition or a stage move means.
1228
 
1947
 
1229
  var REC = null;                 // the record currently rendered, for the action panels
1948
  var REC = null;                 // the record currently rendered, for the action panels
1230
  var CALL = { id:null, poll:null, tick:null, startedAt:0 };
1949
  var CALL = { id:null, leadId:null, btn:null, poll:null, tick:null, startedAt:0, checklistShown:false };
1231
 
1950
 
1232
  /** POST form-encoded (advanceStage / dialer take @RequestParam, not a JSON body). */
1951
  /** POST form-encoded (advanceStage / dialer take @RequestParam, not a JSON body). */
1233
  function postForm(path, params, cb, onErr){
1952
  function postForm(path, params, cb, onErr){
1234
    var body = Object.keys(params).map(function(k){
1953
    var body = Object.keys(params).map(function(k){
1235
      return encodeURIComponent(k)+'='+encodeURIComponent(params[k]==null?'':params[k]); }).join('&');
1954
      return encodeURIComponent(k)+'='+encodeURIComponent(params[k]==null?'':params[k]); }).join('&');
Line 1268... Line 1987...
1268
      toast('Stage advanced to '+to.replace('_',' '),1); reloadRecord(); refresh();
1987
      toast('Stage advanced to '+to.replace('_',' '),1); reloadRecord(); refresh();
1269
    });
1988
    });
1270
  }
1989
  }
1271
 
1990
 
1272
  // ---- edit ---------------------------------------------------------------------------------
1991
  // ---- edit ---------------------------------------------------------------------------------
-
 
1992
  // EDIT.lead is the record being edited. It is NOT always REC: the pipeline can edit any row while
-
 
1993
  // a different lead (or none) is open on the record tab, and binding the save to REC would have
-
 
1994
  // written one lead's form over another lead's row.
-
 
1995
  var EDIT = { lead:null };
-
 
1996
 
-
 
1997
  /** Edit straight from a list row — fetch that lead, then open the same form. */
-
 
1998
  function editLeadById(leadId){
-
 
1999
    if(!leadId) return;
-
 
2000
    getJSON('/lms/dashboard/record?leadId='+leadId, function(d){
-
 
2001
      if(!d || !d.id){ toast('Could not load lead #'+leadId); return; }
-
 
2002
      openEditFor(d);
-
 
2003
    }, function(){ toast('Could not load lead #'+leadId); });
-
 
2004
  }
-
 
2005
 
-
 
2006
  function openEdit(){ if(REC) openEditFor(REC); }
-
 
2007
 
1273
  function openEdit(){
2008
  function openEditFor(rec){
1274
    if(!REC) return;
2009
    if(!rec) return;
-
 
2010
    EDIT.lead=rec;
-
 
2011
    var REC=rec;                       // shadow, so the markup below reads the target record
1275
    var regionOpts='<option value="">&mdash; Not set &mdash;</option>';
2012
    var regionOpts='<option value="">&mdash; Not set &mdash;</option>';
1276
    // Same region list the Create tab renders, so the two forms cannot offer different regions.
2013
    // Same region list the Create tab renders, so the two forms cannot offer different regions.
1277
    byId('cRegion').querySelectorAll('option').forEach(function(o){
2014
    byId('cRegion').querySelectorAll('option').forEach(function(o){
1278
      if(!o.value) return;
2015
      if(!o.value) return;
1279
      regionOpts+='<option value="'+esc(o.value)+'"'+(String(REC.regionId)===String(o.value)?' selected':'')+'>'+esc(o.textContent)+'</option>';
2016
      regionOpts+='<option value="'+esc(o.value)+'"'+(String(REC.regionId)===String(o.value)?' selected':'')+'>'+esc(o.textContent)+'</option>';
Line 1298... Line 2035...
1298
  function fld(id,label,val){
2035
  function fld(id,label,val){
1299
    return '<div><label class="lbl">'+label+'</label><input id="'+id+'" class="inp" style="width:100%" value="'+esc(val==null?'':val)+'"></div>';
2036
    return '<div><label class="lbl">'+label+'</label><input id="'+id+'" class="inp" style="width:100%" value="'+esc(val==null?'':val)+'"></div>';
1300
  }
2037
  }
1301
  function closeEdit(){ byId('editModal').classList.add('hide'); }
2038
  function closeEdit(){ byId('editModal').classList.add('hide'); }
1302
  function saveEdit(){
2039
  function saveEdit(){
-
 
2040
    var target=EDIT.lead;
-
 
2041
    if(!target){ toast('Nothing to edit'); return; }
1303
    var name=(byId('eName').value||'').trim();
2042
    var name=(byId('eName').value||'').trim();
1304
    if(!name){ toast('Retailer name is required'); return; }
2043
    if(!name){ toast('Retailer name is required'); return; }
1305
    var mob=(byId('eContact').value||'').replace(/\D/g,'');
2044
    var mob=(byId('eContact').value||'').replace(/\D/g,'');
1306
    if(mob.length<10){ toast('A valid 10-digit contact number is required'); return; }
2045
    if(mob.length<10){ toast('A valid 10-digit contact number is required'); return; }
1307
    var v=parseFloat(byId('eValue').value), r=byId('eRegion').value;
2046
    var v=parseFloat(byId('eValue').value), r=byId('eRegion').value;
1308
    postJSON('/lms/updateLead',{leadId:REC.id,retailerName:name,businessName:byId('eBusiness').value,
2047
    postJSON('/lms/updateLead',{leadId:target.id,retailerName:name,businessName:byId('eBusiness').value,
1309
      mobile:mob,address:byId('eAddress').value,city:byId('eCity').value,state:byId('eState').value,
2048
      mobile:mob,address:byId('eAddress').value,city:byId('eCity').value,state:byId('eState').value,
1310
      regionId:r?parseInt(r,10):null,potential:isNaN(v)?null:v},function(res){
2049
      regionId:r?parseInt(r,10):null,potential:isNaN(v)?null:v},function(res){
1311
        var why=refusal(res); if(why){ toast(why); return; }
2050
        var why=refusal(res); if(why){ toast(why); return; }
-
 
2051
        closeEdit();
1312
        closeEdit(); toast('Record updated · '+(res&&res.changes||0)+' change(s) logged',1); reloadRecord();
2052
        toast('Record updated · '+(res&&res.changes||0)+' change(s) logged',1);
-
 
2053
        // Refresh whichever views are showing this lead.
-
 
2054
        if(REC && REC.id===target.id) reloadRecord();
-
 
2055
        if(!byId('cmd').classList.contains('hide')) loadPipeline();
1313
      });
2056
      });
1314
  }
2057
  }
1315
 
2058
 
1316
  // ---- call ---------------------------------------------------------------------------------
2059
  // ---- call ---------------------------------------------------------------------------------
-
 
2060
  // One flow for every entry point: the lead record's Call button and the phone icon on a pipeline
-
 
2061
  // row both come through dialLead(). The bar is global so a call started from the pipeline still
-
 
2062
  // has somewhere to report progress, and switching tabs mid-call does not hide the hang-up button.
1317
  function callErr(msg){ var e=byId('callErr'); if(!e) return; e.textContent=msg; e.classList.remove('hide'); }
2063
  function callErr(msg){ var e=byId('gCallErr'); if(!e) return; e.textContent=msg; e.classList.remove('hide'); }
1318
  function callState(t){ var e=byId('callState'); if(e) e.textContent=t; }
2064
  function callState(t){ var e=byId('gCallState'); if(e) e.textContent=t; }
1319
  function startCall(){
2065
  function showBar(who){
-
 
2066
    byId('gCallWho').textContent=who||'Calling…';
-
 
2067
    byId('gCallErr').classList.add('hide');
-
 
2068
    byId('gCallTimer').textContent='00:00';
-
 
2069
    byId('gCallBar').classList.remove('hide');
-
 
2070
  }
-
 
2071
  function hideBar(){ byId('gCallBar').classList.add('hide'); }
-
 
2072
 
-
 
2073
  function startCall(){ if(REC) dialLead(REC.id, REC.name, byId('callBtn')); }
-
 
2074
 
-
 
2075
  function dialLead(leadId, label, btn){
1320
    if(!REC || CALL.id) return;
2076
    if(!leadId) return;
-
 
2077
    // One call at a time: the dialer rings the agent's own device, so a second dial would collide.
-
 
2078
    if(CALL.id){ toast('Already on a call — hang up first'); return; }
1321
    byId('callBtn').disabled=true;
2079
    if(btn) btn.disabled=true;
1322
    // Pre-check server-side: DND and the 09:00-21:00 calling window. A check that only lives in
2080
    // Pre-check server-side: DND and the 09:00-21:00 calling window. A check that only lives in
1323
    // the browser is not a check, which is why this endpoint exists at all.
2081
    // the browser is not a check, which is why this endpoint exists at all.
1324
    postForm('/lms/dialer/precheck',{leadId:REC.id},function(res){
2082
    postForm('/lms/dialer/precheck',{leadId:leadId},function(res){
1325
      var why=refusal(res);
2083
      var why=refusal(res);
1326
      if(why || !res || res.ok!==true){ byId('callBtn').disabled=false; toast(why||(res&&res.reason)||'Cannot place the call'); return; }
2084
      if(why || !res || res.ok!==true){ if(btn) btn.disabled=false; toast(why||(res&&res.reason)||'Cannot place the call'); return; }
1327
      byId('callBar').classList.remove('hide'); callState('Ringing your device…');
2085
      showBar(label||('Lead #'+leadId)); callState('Ringing your device…');
1328
      postForm('/lms/dialer/dial',{leadId:REC.id},function(r2){
2086
      postForm('/lms/dialer/dial',{leadId:leadId},function(r2){
1329
        var w2=refusal(r2);
2087
        var w2=refusal(r2);
1330
        if(w2 || !r2 || r2.ok!==true){ byId('callBtn').disabled=false; byId('callBar').classList.add('hide');
2088
        if(w2 || !r2 || r2.ok!==true){ if(btn) btn.disabled=false; hideBar();
1331
          toast(w2||'Could not place the call'); return; }
2089
          toast(w2||'Could not place the call'); return; }
-
 
2090
        CALL.id=r2.leadCallId;
-
 
2091
        CALL.checklistShown=false;
-
 
2092
        // The disposition belongs to the lead that was DIALLED, which is not necessarily the record
-
 
2093
        // currently open — the pipeline can start a call while another lead is on screen.
-
 
2094
        CALL.leadId=leadId;
-
 
2095
        CALL.btn=btn||null;
1332
        CALL.id=r2.leadCallId; CALL.startedAt=Date.now();
2096
        CALL.startedAt=Date.now();
1333
        CALL.tick=setInterval(function(){ var s=Math.floor((Date.now()-CALL.startedAt)/1000);
2097
        CALL.tick=setInterval(function(){ var s=Math.floor((Date.now()-CALL.startedAt)/1000);
1334
          byId('callTimer').textContent=('0'+Math.floor(s/60)).slice(-2)+':'+('0'+(s%60)).slice(-2); },1000);
2098
          byId('gCallTimer').textContent=('0'+Math.floor(s/60)).slice(-2)+':'+('0'+(s%60)).slice(-2); },1000);
1335
        // click2dial has no event webhook, so the browser has to ask.
2099
        // click2dial has no event webhook, so the browser has to ask.
1336
        CALL.poll=setInterval(pollCall,3000);
2100
        CALL.poll=setInterval(pollCall,3000);
1337
      });
2101
      });
1338
    });
2102
    });
1339
  }
2103
  }
1340
  function pollCall(){
2104
  function pollCall(){
1341
    if(!CALL.id) return;
2105
    if(!CALL.id) return;
1342
    getJSON('/lms/dialer/state?leadCallId='+CALL.id,function(st){
2106
    getJSON('/lms/dialer/state?leadCallId='+CALL.id,function(st){
1343
      if(!st) return;
2107
      if(!st) return;
1344
      if(st.status) callState(String(st.status).replace(/_/g,' ').toLowerCase());
2108
      if(st.status) callState(String(st.status).replace(/_/g,' ').toLowerCase());
-
 
2109
      // Connected, not merely placed. The poll repeats every 3s while answered, so the latch stops
-
 
2110
      // the checklist reopening over the agent every tick.
-
 
2111
      if(st.answered && !CALL.checklistShown){
-
 
2112
        CALL.checklistShown=true;
-
 
2113
        maybeOpenChecklist(CALL.leadId, CALL.id);
-
 
2114
      }
1345
      if(st.terminal){ stopCall(); callState('Call ended'); openDisp(); }
2115
      if(st.terminal){ stopCall(); callState('Call ended'); openDisp(); }
1346
    },function(){ /* a dropped poll is not a dropped call — keep waiting */ });
2116
    },function(){ /* a dropped poll is not a dropped call — keep waiting */ });
1347
  }
2117
  }
1348
  function stopCall(){ if(CALL.poll) clearInterval(CALL.poll); if(CALL.tick) clearInterval(CALL.tick);
2118
  function stopCall(){ if(CALL.poll) clearInterval(CALL.poll); if(CALL.tick) clearInterval(CALL.tick);
-
 
2119
    CALL.poll=CALL.tick=null;
-
 
2120
    if(CALL.btn) CALL.btn.disabled=false;
1349
    CALL.poll=CALL.tick=null; var b=byId('callBtn'); if(b) b.disabled=false; }
2121
    var b=byId('callBtn'); if(b) b.disabled=false; }
1350
  function endCall(){
2122
  function endCall(){
1351
    if(!CALL.id){ stopCall(); return; }
2123
    if(!CALL.id){ stopCall(); return; }
1352
    postForm('/lms/dialer/hangup',{leadCallId:CALL.id},function(){ stopCall(); callState('Call ended'); openDisp(); });
2124
    postForm('/lms/dialer/hangup',{leadCallId:CALL.id},function(){ stopCall(); callState('Call ended'); openDisp(); });
1353
  }
2125
  }
1354
 
2126
 
-
 
2127
  // ---- assign a lead to a sales person --------------------------------------------------------
-
 
2128
  var ASG = { leadId:null, people:[], tiers:[], tier:'', regionId:null };
-
 
2129
 
-
 
2130
  function openAssign(leadId, name, currentOwner, regionId){
-
 
2131
    ASG.leadId=leadId; ASG.tier=''; ASG.regionId=regionId||null;
-
 
2132
    byId('asgWho').innerHTML='<b>'+esc(name||('Lead #'+leadId))+'</b>'+
-
 
2133
      (currentOwner?(' &middot; currently '+esc(currentOwner)):' &middot; unassigned');
-
 
2134
    byId('asgSearch').value='';
-
 
2135
    byId('asgList').innerHTML='<div class="ph">Loading…</div>';
-
 
2136
    byId('asgModal').classList.remove('hide');
-
 
2137
    getJSON('/lms/salespeople'+(regionId?('?regionId='+encodeURIComponent(regionId)):''), function(d){
-
 
2138
      d=d||{}; ASG.people=d.people||[]; ASG.tiers=d.tiers||[];
-
 
2139
      byId('asgTiers').innerHTML=['<button type="button" class="chkopt sel" data-tier="">All</button>']
-
 
2140
        .concat(ASG.tiers.map(function(t){
-
 
2141
          return '<button type="button" class="chkopt" data-tier="'+t+'">'+t+'</button>'; })).join('');
-
 
2142
      renderAssignList();
-
 
2143
    }, function(){ byId('asgList').innerHTML='<div class="ph">Could not load the sales list.</div>'; });
-
 
2144
  }
-
 
2145
  function closeAssign(){ byId('asgModal').classList.add('hide'); }
-
 
2146
 
-
 
2147
  function renderAssignList(){
-
 
2148
    var q=(byId('asgSearch').value||'').trim().toLowerCase();
-
 
2149
    var list=ASG.people.filter(function(p){
-
 
2150
      if(ASG.tier && p.tier!==ASG.tier) return false;
-
 
2151
      if(!q) return true;
-
 
2152
      return (p.name||'').toLowerCase().indexOf(q)>=0 || (p.tier||'').toLowerCase().indexOf(q)>=0
-
 
2153
          || (p.regions||[]).join(' ').toLowerCase().indexOf(q)>=0;
-
 
2154
    });
-
 
2155
    // Whoever covers this lead's region first — the likely right answer without forcing it.
-
 
2156
    list.sort(function(a,b){
-
 
2157
      if(a.coversRegion!==b.coversRegion) return a.coversRegion?-1:1;
-
 
2158
      if(a.tier!==b.tier) return a.tier<b.tier?-1:1;
-
 
2159
      return (a.name||'').localeCompare(b.name||'');
-
 
2160
    });
-
 
2161
    if(!list.length){ byId('asgList').innerHTML='<div class="ph">Nobody matches.</div>'; return; }
-
 
2162
    byId('asgList').innerHTML=list.map(function(p){
-
 
2163
      return '<div class="asgrow'+(p.coversRegion?' covers':'')+'" data-assignto="'+p.authId+'">'+
-
 
2164
        '<span class="tierpill">'+esc(p.tier)+'</span>'+
-
 
2165
        '<span class="nm">'+esc(p.name)+'</span>'+
-
 
2166
        '<span class="rg">'+(p.coversRegion?'covers this region · ':'')+esc((p.regions||[]).join(', ')||'no region')+'</span>'+
-
 
2167
      '</div>'; }).join('');
-
 
2168
  }
-
 
2169
 
-
 
2170
  function assignTo(authId){
-
 
2171
    if(!ASG.leadId) return;
-
 
2172
    postForm('/lms/assignLead',{leadId:ASG.leadId, authId:authId}, function(res){
-
 
2173
      var why=refusal(res); if(why){ toast(why); return; }
-
 
2174
      closeAssign();
-
 
2175
      toast('Assigned to '+(res&&res.owner||'owner')+' ('+(res&&res.tier||'')+')',1);
-
 
2176
      if(!byId('cmd').classList.contains('hide')) loadPipeline();
-
 
2177
      if(REC && REC.id===ASG.leadId) reloadRecord();
-
 
2178
      refresh();
-
 
2179
    });
-
 
2180
  }
-
 
2181
 
-
 
2182
  // ---- checklist question management (CRUD) --------------------------------------------------
-
 
2183
  var QADM = { questions:[], stages:[], editing:null };
-
 
2184
 
-
 
2185
  function loadQuestions(){
-
 
2186
    byId('qBody').innerHTML='<tr><td colspan="8"><div class="ph">Loading…</div></td></tr>';
-
 
2187
    getJSON('/lms/checklist/questions', function(d){
-
 
2188
      d=d||{}; QADM.questions=d.questions||[]; QADM.stages=d.stages||[];
-
 
2189
      if(!QADM.questions.length){
-
 
2190
        byId('qBody').innerHTML='<tr><td colspan="8"><div class="ph">No questions yet — add the first one.</div></td></tr>';
-
 
2191
        return;
-
 
2192
      }
-
 
2193
      byId('qBody').innerHTML=QADM.questions.map(function(q){
-
 
2194
        return '<tr'+(q.active?'':' style="opacity:.55"')+'>'+
-
 
2195
          '<td class="num mono">'+q.sortOrder+'</td>'+
-
 
2196
          '<td><b>'+esc(q.prompt)+'</b>'+
-
 
2197
            (q.choices?'<br><span style="color:#97a1b5;font-size:11px">'+esc(q.choices.replace(/\|/g,' · '))+'</span>':'')+'</td>'+
-
 
2198
          '<td class="nowrap">'+esc(q.answerType)+'</td>'+
-
 
2199
          '<td class="num">'+(q.points||0)+'</td>'+
-
 
2200
          '<td>'+(q.required?'<span class="lstat idle">Required</span>':'<span style="color:#97a1b5">—</span>')+'</td>'+
-
 
2201
          '<td>'+stageChips(q.stages)+'</td>'+
-
 
2202
          '<td>'+(q.active?'<span class="lstat oncall">Active</span>':'<span class="lstat off">Off</span>')+'</td>'+
-
 
2203
          '<td class="nowrap"><button class="btn ghost sm" data-editq="'+q.id+'">Edit</button>'+
-
 
2204
            (q.answerCount>0?'<span class="note" style="display:block;margin-top:3px">'+q.answerCount+' answered</span>':'')+
-
 
2205
          '</td></tr>';
-
 
2206
      }).join('');
-
 
2207
    });
-
 
2208
  }
-
 
2209
 
-
 
2210
  function stageChips(stages){
-
 
2211
    if(!stages || !stages.trim()) return '<span style="color:#97a1b5">every stage</span>';
-
 
2212
    return stages.split('|').map(function(x){
-
 
2213
      return '<span class="lstat avail" style="margin:1px 2px">'+esc(prettyStage(x))+'</span>'; }).join('');
-
 
2214
  }
-
 
2215
  function prettyStage(x){ return (x||'').replace(/_/g,' ').toLowerCase().replace(/\b\w/g,function(c){return c.toUpperCase();}); }
-
 
2216
 
-
 
2217
  function editQuestion(id){
-
 
2218
    var q=null;
-
 
2219
    QADM.questions.forEach(function(x){ if(x.id===id) q=x; });
-
 
2220
    QADM.editing=q;
-
 
2221
    byId('qTitle').textContent=q?'Edit question':'New question';
-
 
2222
    byId('qPrompt').value=q?q.prompt:'';
-
 
2223
    byId('qType').value=q?q.answerType:'TEXT';
-
 
2224
    byId('qPoints').value=q?(q.points||0):0;
-
 
2225
    byId('qChoices').value=q&&q.choices?q.choices:'';
-
 
2226
    byId('qOrder').value=q?q.sortOrder:nextOrder();
-
 
2227
    byId('qRequired').checked=q?!!q.required:false;
-
 
2228
    byId('qActive').checked=q?!!q.active:true;
-
 
2229
    byId('qErr').classList.add('hide');
-
 
2230
    // Delete is only offered when it is actually allowed; the server refuses it otherwise anyway.
-
 
2231
    byId('qDelBtn').style.display=(q && !q.answerCount)?'':'none';
-
 
2232
    var sel=(q&&q.stages)?q.stages.split('|'):[];
-
 
2233
    byId('qStages').innerHTML=QADM.stages.map(function(st){
-
 
2234
      return '<button type="button" class="chkopt qstage'+(sel.indexOf(st)>=0?' sel':'')+'" data-stage="'+st+'">'+
-
 
2235
        esc(prettyStage(st))+'</button>'; }).join('');
-
 
2236
    qTypeChanged();
-
 
2237
    byId('qModal').classList.remove('hide');
-
 
2238
  }
-
 
2239
  function nextOrder(){
-
 
2240
    var mx=0; QADM.questions.forEach(function(q){ if(q.sortOrder>mx) mx=q.sortOrder; });
-
 
2241
    return mx+10;
-
 
2242
  }
-
 
2243
  function closeQ(){ byId('qModal').classList.add('hide'); }
-
 
2244
  function qTypeChanged(){
-
 
2245
    byId('qChoicesWrap').classList.toggle('hide', byId('qType').value!=='CHOICE');
-
 
2246
  }
-
 
2247
  function qErr(m){ var e=byId('qErr'); e.textContent=m; e.classList.remove('hide'); }
-
 
2248
 
-
 
2249
  function saveQuestion(){
-
 
2250
    var prompt=(byId('qPrompt').value||'').trim();
-
 
2251
    if(!prompt){ qErr('The question text is required'); return; }
-
 
2252
    var type=byId('qType').value;
-
 
2253
    var choices=(byId('qChoices').value||'').trim();
-
 
2254
    if(type==='CHOICE' && !choices){ qErr('A choice question needs at least one option'); return; }
-
 
2255
    var stages=[];
-
 
2256
    document.querySelectorAll('#qStages .qstage.sel').forEach(function(b){ stages.push(b.getAttribute('data-stage')); });
-
 
2257
    var body={ id:QADM.editing?QADM.editing.id:0, prompt:prompt, answerType:type,
-
 
2258
      choices:choices, required:byId('qRequired').checked, active:byId('qActive').checked,
-
 
2259
      points:parseInt(byId('qPoints').value,10)||0, stages:stages.join('|'),
-
 
2260
      sortOrder:parseInt(byId('qOrder').value,10)||0 };
-
 
2261
    var b=byId('qSaveBtn'); b.disabled=true; b.textContent='Saving…';
-
 
2262
    postJSON('/lms/checklist/questions/save', body, function(res){
-
 
2263
      b.disabled=false; b.textContent='Save';
-
 
2264
      var why=refusal(res); if(why){ qErr(why); return; }
-
 
2265
      closeQ(); toast(res&&res.created?'Question added':'Question updated',1); loadQuestions();
-
 
2266
    }, function(){ b.disabled=false; b.textContent='Save'; qErr('Could not save the question'); });
-
 
2267
  }
-
 
2268
 
-
 
2269
  function deleteQuestion(){
-
 
2270
    if(!QADM.editing) return;
-
 
2271
    if(!confirm('Delete this question? Switching it off keeps it out of calls without losing anything.')) return;
-
 
2272
    postForm('/lms/checklist/questions/delete', {id:QADM.editing.id}, function(res){
-
 
2273
      var why=refusal(res); if(why){ qErr(why); return; }
-
 
2274
      closeQ(); toast('Question deleted',1); loadQuestions();
-
 
2275
    });
-
 
2276
  }
-
 
2277
 
-
 
2278
  // ---- first-call checklist -----------------------------------------------------------------
-
 
2279
  // Opened automatically on the first ANSWERED call to a lead. Deliberately not on dial: a question
-
 
2280
  // set that appears while the phone is still ringing is a question set that gets dismissed.
-
 
2281
  var CHK = { leadId:null, callId:null, questions:[] };
-
 
2282
 
-
 
2283
  function maybeOpenChecklist(leadId, callId){
-
 
2284
    if(!leadId) return;
-
 
2285
    getJSON('/lms/checklist?leadId='+leadId+(callId?('&leadCallId='+callId):''), function(d){
-
 
2286
      if(!d || !d.firstCall) return;              // not their first connected call — stay out of the way
-
 
2287
      if(!d.questions || !d.questions.length) return;
-
 
2288
      openChk(d, leadId, callId);
-
 
2289
    }, function(){ /* the checklist must never take the call down with it */ });
-
 
2290
  }
-
 
2291
 
-
 
2292
  function openChk(d, leadId, callId){
-
 
2293
    CHK.leadId=leadId; CHK.callId=callId; CHK.questions=d.questions;
-
 
2294
    byId('chkWho').textContent=d.retailer||('Lead #'+leadId);
-
 
2295
    byId('chkNote').textContent=d.answered?'Already answered once — saving will update.':'';
-
 
2296
    byId('chkBody').innerHTML=d.questions.map(function(q,i){
-
 
2297
      var body;
-
 
2298
      if(q.answerType==='CHOICE' && q.choices && q.choices.length){
-
 
2299
        body='<div class="opts" data-qid="'+q.id+'">'+q.choices.map(function(c){
-
 
2300
          return '<button type="button" class="chkopt'+(q.answer===c?' sel':'')+'" data-choice="'+esc(c)+'">'+esc(c)+'</button>';
-
 
2301
        }).join('')+'</div>';
-
 
2302
      } else if(q.answerType==='YESNO'){
-
 
2303
        body='<div class="opts" data-qid="'+q.id+'">'+['Yes','No'].map(function(c){
-
 
2304
          return '<button type="button" class="chkopt'+(q.answer===c?' sel':'')+'" data-choice="'+c+'">'+c+'</button>';
-
 
2305
        }).join('')+'</div>';
-
 
2306
      } else {
-
 
2307
        body='<input id="chkq'+q.id+'" class="inp" style="width:100%"'+
-
 
2308
             (q.answerType==='NUMBER'?' type="number" step="0.1" min="0"':'')+
-
 
2309
             ' value="'+esc(q.answer==null?'':q.answer)+'">';
-
 
2310
      }
-
 
2311
      return '<div class="chkq"><div class="qp">'+(i+1)+'. '+esc(q.prompt)+
-
 
2312
        (q.required?'<span class="req">*</span>':'')+
-
 
2313
        (q.points?'<span class="note" style="display:inline;margin-left:6px">'+q.points+' pts</span>':'')+
-
 
2314
        '</div>'+body+'</div>';
-
 
2315
    }).join('');
-
 
2316
    byId('chkModal').classList.remove('hide');
-
 
2317
  }
-
 
2318
  function closeChk(){ byId('chkModal').classList.add('hide'); }
-
 
2319
 
-
 
2320
  /** Read the current answer for a question, whichever control rendered it. */
-
 
2321
  function chkValue(q){
-
 
2322
    if(q.answerType==='CHOICE' || q.answerType==='YESNO'){
-
 
2323
      var sel=document.querySelector('#chkBody .opts[data-qid="'+q.id+'"] .chkopt.sel');
-
 
2324
      return sel?sel.getAttribute('data-choice'):'';
-
 
2325
    }
-
 
2326
    var el=byId('chkq'+q.id);
-
 
2327
    return el?(el.value||'').trim():'';
-
 
2328
  }
-
 
2329
 
-
 
2330
  function saveChk(){
-
 
2331
    var answers=[], missing=[];
-
 
2332
    CHK.questions.forEach(function(q){
-
 
2333
      var v=chkValue(q);
-
 
2334
      if(q.required && !v) missing.push(q.prompt);
-
 
2335
      if(v) answers.push({questionId:q.id, answer:v});
-
 
2336
    });
-
 
2337
    if(missing.length){ toast('Still needed: '+missing[0]); return; }
-
 
2338
    if(!answers.length){ toast('Nothing answered yet'); return; }
-
 
2339
    var b=byId('chkSaveBtn'); b.disabled=true; b.textContent='Saving…';
-
 
2340
    postJSON('/lms/checklist',{leadId:CHK.leadId, leadCallId:CHK.callId, answers:answers}, function(res){
-
 
2341
      b.disabled=false; b.textContent='Save answers';
-
 
2342
      var why=refusal(res); if(why){ toast(why); return; }
-
 
2343
      closeChk();
-
 
2344
      toast('Checklist saved ('+(res&&res.saved||0)+' answers)',1);
-
 
2345
      if(REC && REC.id===CHK.leadId) reloadRecord();
-
 
2346
    }, function(){ b.disabled=false; b.textContent='Save answers'; toast('Could not save the checklist'); });
-
 
2347
  }
-
 
2348
 
1355
  // ---- disposition --------------------------------------------------------------------------
2349
  // ---- disposition --------------------------------------------------------------------------
1356
  var DISPS=[['INTERESTED','Interested'],['CALLBACK','Callback'],['NOT_REACHABLE','Not reachable'],
2350
  var DISPS=[['INTERESTED','Interested'],['CALLBACK','Callback'],['NOT_REACHABLE','Not reachable'],
1357
             ['WRONG_NUMBER','Wrong number'],['FOLLOW_UP','Follow-up'],['NOT_INTERESTED','Not interested'],
2351
             ['WRONG_NUMBER','Wrong number'],['FOLLOW_UP','Follow-up'],['NOT_INTERESTED','Not interested'],
1358
             ['DO_NOT_CALL','Do not call']];
2352
             ['DO_NOT_CALL','Do not call']];
1359
  var DISP=null;
2353
  var DISP=null;
-
 
2354
  /** The lead this disposition is about: the one dialled, else the record on screen. */
-
 
2355
  function dispLeadId(){ return CALL.leadId || (REC ? REC.id : null); }
-
 
2356
 
1360
  function openDisp(){
2357
  function openDisp(){
1361
    if(!REC) return;
2358
    if(!dispLeadId()) return;
1362
    DISP=null;
2359
    DISP=null;
1363
    byId('dispGrid').innerHTML=DISPS.map(function(d){
2360
    byId('dispGrid').innerHTML=DISPS.map(function(d){
1364
      return '<button class="btn ghost sm dispopt" data-d="'+d[0]+'" onclick="pickDisp(\''+d[0]+'\')">'+d[1]+'</button>'; }).join('');
2361
      return '<button class="btn ghost sm dispopt" data-d="'+d[0]+'" onclick="pickDisp(\''+d[0]+'\')">'+d[1]+'</button>'; }).join('');
1365
    byId('dispExtra').innerHTML=''; byId('dispNote').value='';
2362
    byId('dispExtra').innerHTML=''; byId('dispNote').value='';
1366
    byId('dispModal').classList.remove('hide');
2363
    byId('dispModal').classList.remove('hide');
Line 1383... Line 2380...
1383
    else if(d==='DO_NOT_CALL')  x='<div class="note" style="color:#e03131">Blocks this number permanently across every lead, not just this one.</div>';
2380
    else if(d==='DO_NOT_CALL')  x='<div class="note" style="color:#e03131">Blocks this number permanently across every lead, not just this one.</div>';
1384
    byId('dispExtra').innerHTML=x;
2381
    byId('dispExtra').innerHTML=x;
1385
  }
2382
  }
1386
  function saveDisp(){
2383
  function saveDisp(){
1387
    if(!DISP){ toast('Pick a disposition'); return; }
2384
    if(!DISP){ toast('Pick a disposition'); return; }
-
 
2385
    var lid=dispLeadId();
-
 
2386
    if(!lid){ toast('No lead to disposition'); return; }
1388
    var p={leadId:REC.id,disposition:DISP,note:byId('dispNote').value,leadCallId:CALL.id||null};
2387
    var p={leadId:lid,disposition:DISP,note:byId('dispNote').value,leadCallId:CALL.id||null};
1389
    if(DISP==='INTERESTED'){ var v=parseFloat(byId('dxValue').value);
2388
    if(DISP==='INTERESTED'){ var v=parseFloat(byId('dxValue').value);
1390
      if(!v||v<=0){ toast('Business value is required'); return; } p.value=v; }
2389
      if(!v||v<=0){ toast('Business value is required'); return; } p.value=v; }
1391
    else if(DISP==='CALLBACK'){ if(!byId('dxCallback').value){ toast('Callback date/time is required'); return; }
2390
    else if(DISP==='CALLBACK'){ if(!byId('dxCallback').value){ toast('Callback date/time is required'); return; }
1392
      p.callbackAt=byId('dxCallback').value; }
2391
      p.callbackAt=byId('dxCallback').value; }
1393
    else if(DISP==='NOT_REACHABLE') p.retrySchedule=byId('dxRetry').value;
2392
    else if(DISP==='NOT_REACHABLE') p.retrySchedule=byId('dxRetry').value;
Line 1395... Line 2394...
1395
    else if(DISP==='FOLLOW_UP'){ if(!byId('dxFollowUp').value){ toast('Follow-up date/time is required'); return; }
2394
    else if(DISP==='FOLLOW_UP'){ if(!byId('dxFollowUp').value){ toast('Follow-up date/time is required'); return; }
1396
      p.followUpType=byId('dxFuType').value; p.followUpAt=byId('dxFollowUp').value; }
2395
      p.followUpType=byId('dxFuType').value; p.followUpAt=byId('dxFollowUp').value; }
1397
    else if(DISP==='NOT_INTERESTED') p.subReason=byId('dxSub').value;
2396
    else if(DISP==='NOT_INTERESTED') p.subReason=byId('dxSub').value;
1398
    postJSON('/lms/disposition',p,function(res){
2397
    postJSON('/lms/disposition',p,function(res){
1399
      var why=refusal(res); if(why){ toast(why); return; }
2398
      var why=refusal(res); if(why){ toast(why); return; }
-
 
2399
      closeDisp();
-
 
2400
      var wasDialled=CALL.leadId;
-
 
2401
      CALL.id=null; CALL.leadId=null; CALL.btn=null;
-
 
2402
      hideBar();
1400
      closeDisp(); CALL.id=null; toast('Disposition saved: '+DISP.replace(/_/g,' '),1); reloadRecord(); refresh();
2403
      toast('Disposition saved: '+DISP.replace(/_/g,' '),1);
-
 
2404
      // Refresh whichever view the user is actually looking at.
-
 
2405
      if(REC && (!wasDialled || REC.id===wasDialled)) reloadRecord();
-
 
2406
      if(!byId('cmd').classList.contains('hide')) loadPipeline();
-
 
2407
      refresh();
1401
    });
2408
    });
1402
  }
2409
  }
1403
 
2410
 
1404
  // ---- manual geo verification ---------------------------------------------------------------
2411
  // ---- manual geo verification ---------------------------------------------------------------
1405
  function geoErr(msg){ var e=byId('geoErr'); e.textContent=msg; e.classList.remove('hide'); }
2412
  function geoErr(msg){ var e=byId('geoErr'); e.textContent=msg; e.classList.remove('hide'); }