| 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 & Audit</button>
|
389 |
<button class="tab" onclick="go('audit',this)">Recordings & 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 · 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 — 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…</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 — 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…</div></td></tr></tbody>
|
| - |
|
644 |
</table>
|
| - |
|
645 |
</div>
|
| - |
|
646 |
<div class="note">Status is derived from call activity — 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 — 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…</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)">← Prev</button>
|
| - |
|
679 |
<span class="note" id="clPageLbl"></span>
|
| - |
|
680 |
<button class="btn ghost sm" id="clNext" onclick="clPage(1)">Next →</button>
|
| - |
|
681 |
</span>
|
| - |
|
682 |
</div>
|
| - |
|
683 |
<div class="note">Ring is dial → answer; Talk is answer → 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 & servicing across all regions — derived from the lead record.</div>
|
693 |
<div class="sub">Funnel, SLA compliance, ageing & 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>📈 <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New → onboarded by region, BM, ASM</td></tr>
|
722 |
<tr class="clickrow" data-rep="State Funnel"><td>📈 <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New → onboarded by region, BM, ASM</td></tr>
|
| 588 |
<tr class="clickrow" data-rep="SLA Compliance"><td>⚖ <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>⚖ <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>⌛ <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>⌛ <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>👑 <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>👑 <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>📊 <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>📊 <b>BGC Daily</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Calls, connect rate & talk time per agent per day</td></tr>
|
| 592 |
<tr class="clickrow" data-rep="Audit Hygiene"><td>🧹 <b>Audit Hygiene</b><span class="rbadge partial">Partial</span></td><td style="color:#69748c">Missing dispositions / HOLD; DND & recordings pending</td></tr>
|
727 |
<tr class="clickrow" data-rep="Audit Hygiene"><td>🧹 <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>🔄 <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>🔄 <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 · 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…</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 —
|
| - |
|
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 & verify</button></div>
|
893 |
<button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save & 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">—</span>
|
| - |
|
904 |
<span class="cb-time mono" id="gCallTimer">00:00</span>
|
| - |
|
905 |
</div>
|
| - |
|
906 |
<div class="cb-state" id="gCallState">Starting…</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…</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 · <span id="chkWho">—</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 — 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"> </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">—</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 {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
1002 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[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 '₹0'; v=Number(v);
|
- |
|
| 741 |
if(v>=1e7) return '₹'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '₹'+(v/1e5).toFixed(1)+'<small> L</small>';
|
- |
|
| 742 |
if(v>=1e3) return '₹'+Math.round(v/1e3)+'<small>K</small>'; return '₹'+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 '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
|
1019 |
return '₹'+(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+='®ionId='+encodeURIComponent(STATE.regionId); }
|
1107 |
if(STATE.regionId) p+='®ionId='+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))+'">☎</button>'+
|
| - |
|
1135 |
'<button class="rowedit" data-editlead="'+l.id+'"'+
|
| - |
|
1136 |
' title="Edit '+esc(l.name||('lead #'+l.id))+'">✎</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))+'">👤</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 → onboarded'},
|
1262 |
var vals=[ {v:fmtInt(s.total),f:'in period'}, {v:s.conversionPct+'<small>%</small>',f:'new → 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 > 7 days','Onboarded value'];
|
1268 |
var labels=['LMS created','Conversion','SLA compliance','Calls / day (BGC)',
|
| - |
|
1269 |
'Ageing > '+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 · disposition required on disconnect.</p>'+
|
1504 |
else { call+='<p class="desc">Bridged call · disposition required on disconnect.</p>'+
|
| 1176 |
'<button class="btn coral" id="callBtn" onclick="startCall()">☎ Call retailer (C2C)</button>'+
|
1505 |
'<button class="btn coral" id="callBtn" onclick="startCall()">☎ 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…</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…</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…</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 — <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+='®ionId='+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="">— Not set —</option>';
|
2012 |
var regionOpts='<option value="">— Not set —</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?(' · currently '+esc(currentOwner)):' · 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'); }
|