| 37651 |
vikas |
1 |
## LMS Lead Record — full-page record (SOP §6/§8/§12). Loaded into #main-content by the row "Record" link.
|
|
|
2 |
## Model: lead, effectiveStage, stageIndex, terminal, stages[], dispositions[], trail[], authUserMap, owner, bm, geo, slaState
|
|
|
3 |
|
|
|
4 |
<style>
|
|
|
5 |
.lms-record { --navy:#1f2a5e; --coral:#f0483e; --amber:#f6a609; --ink:#26324d; --muted:#7683a4; --line:#e5e9f2; }
|
|
|
6 |
.lms-record { color:var(--ink); }
|
|
|
7 |
.lms-record .lms-top { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
|
|
8 |
.lms-record .lms-code { font-family:monospace; font-size:13px; color:var(--muted); letter-spacing:.5px; }
|
|
|
9 |
.lms-record h2 { margin:2px 0 0; font-size:22px; color:var(--navy); font-weight:700; }
|
|
|
10 |
.lms-record .lms-pill { display:inline-block; padding:3px 11px; border-radius:20px; font-size:12px; font-weight:600; }
|
|
|
11 |
.lms-record .pill-stage { background:var(--navy); color:#fff; }
|
|
|
12 |
.lms-record .pill-term { background:#8894ad; color:#fff; }
|
|
|
13 |
.lms-record .sla-MET { background:#e5f6ec; color:#1a8f4c; }
|
|
|
14 |
.lms-record .sla-BREACHED { background:#fde7e5; color:var(--coral); }
|
|
|
15 |
.lms-record .sla-AT_RISK { background:#fff3dc; color:#a9720a; }
|
|
|
16 |
.lms-record .sla-RUNNING { background:#eef1fb; color:var(--navy); }
|
|
|
17 |
.lms-record .sla-NA { background:#eef0f5; color:var(--muted); }
|
|
|
18 |
.lms-record .lms-hold { background:#fde7e5; color:var(--coral); }
|
|
|
19 |
/* stepper */
|
|
|
20 |
.lms-steps { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0 20px; }
|
|
|
21 |
.lms-step { flex:1 1 0; min-width:92px; text-align:center; font-size:11px; padding:8px 4px 10px; border-radius:8px; background:#f4f6fb; color:var(--muted); position:relative; border:1px solid var(--line); }
|
|
|
22 |
.lms-step.done { background:#eaf7ef; color:#1a8f4c; border-color:#c9ebd6; }
|
|
|
23 |
.lms-step.cur { background:var(--navy); color:#fff; border-color:var(--navy); font-weight:700; }
|
|
|
24 |
.lms-step .dot { display:block; width:18px; height:18px; line-height:18px; border-radius:50%; margin:0 auto 5px; background:#fff; color:var(--muted); font-weight:700; border:1px solid var(--line); }
|
|
|
25 |
.lms-step.done .dot { background:#1a8f4c; color:#fff; border-color:#1a8f4c; }
|
|
|
26 |
.lms-step.cur .dot { background:#fff; color:var(--navy); }
|
|
|
27 |
/* cards */
|
|
|
28 |
.lms-card { border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:16px; background:#fff; }
|
|
|
29 |
.lms-card h4 { margin:0 0 12px; font-size:14px; color:var(--navy); font-weight:700; text-transform:uppercase; letter-spacing:.4px; }
|
|
|
30 |
.lms-kv { width:100%; font-size:13px; }
|
|
|
31 |
.lms-kv td { padding:5px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
|
32 |
.lms-kv td.k { color:var(--muted); width:42%; }
|
|
|
33 |
.lms-note { font-size:11px; color:var(--muted); margin-top:8px; }
|
|
|
34 |
/* trail */
|
|
|
35 |
.lms-trail { list-style:none; padding:0; margin:0; }
|
|
|
36 |
.lms-trail li { position:relative; padding:0 0 14px 20px; border-left:2px solid var(--line); }
|
|
|
37 |
.lms-trail li:last-child { border-left-color:transparent; }
|
|
|
38 |
.lms-trail li:before { content:''; position:absolute; left:-6px; top:2px; width:10px; height:10px; border-radius:50%; background:var(--navy); }
|
|
|
39 |
.lms-trail .t-meta { font-size:11px; color:var(--muted); }
|
|
|
40 |
.lms-trail .t-body { font-size:13px; }
|
|
|
41 |
/* disposition modal */
|
|
|
42 |
.disp-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
|
|
43 |
.disp-opt { border:1px solid var(--line); border-radius:9px; padding:9px 11px; cursor:pointer; font-size:13px; background:#fff; text-align:left; }
|
|
|
44 |
.disp-opt.sel { border-color:var(--navy); background:#eef1fb; font-weight:600; }
|
|
|
45 |
/* in-call bar */
|
|
|
46 |
.lms-callbar { border:1px solid var(--line); border-radius:10px; padding:12px 14px; margin-top:12px; background:#f8fafd; }
|
|
|
47 |
.lms-callbar .cb-top { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
|
|
48 |
.lms-callbar .cb-state { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600; color:var(--navy); }
|
|
|
49 |
.lms-callbar .cb-dot { width:9px; height:9px; border-radius:50%; background:#8894ad; flex:none; }
|
|
|
50 |
.lms-callbar.is-ringing .cb-dot { background:var(--amber); animation:cbPulse 1s infinite; }
|
|
|
51 |
.lms-callbar.is-active .cb-dot { background:#1a8f4c; animation:cbPulse 2s infinite; }
|
|
|
52 |
.lms-callbar.is-ended .cb-dot { background:var(--coral); }
|
|
|
53 |
@keyframes cbPulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
|
|
54 |
.lms-callbar .cb-timer { font-family:monospace; font-size:15px; color:var(--ink); }
|
|
|
55 |
.lms-callbar .cb-actions { display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; }
|
|
|
56 |
.lms-callbar .cb-keys { display:grid; grid-template-columns:repeat(3,1fr); gap:5px; margin-top:10px; max-width:190px; }
|
|
|
57 |
.lms-callbar .cb-keys button { padding:7px 0; font-family:monospace; font-size:14px; border:1px solid var(--line); background:#fff; border-radius:6px; cursor:pointer; }
|
|
|
58 |
.lms-callbar .cb-err { font-size:12px; color:var(--coral); margin-top:8px; }
|
|
|
59 |
.lms-record .btn-coral { background:var(--coral); border-color:var(--coral); color:#fff; }
|
|
|
60 |
.lms-record .btn-navy { background:var(--navy); border-color:var(--navy); color:#fff; }
|
|
|
61 |
</style>
|
|
|
62 |
|
|
|
63 |
<section class="wrapper lms-record">
|
|
|
64 |
|
|
|
65 |
<ol class="breadcrumb">
|
|
|
66 |
<li><i class="fa fa-home"></i><a href="${rc.contextPath}/dashboard">Home</a></li>
|
|
|
67 |
<li><a href="#" class="lead">Leads</a></li>
|
|
|
68 |
<li>Lead Record</li>
|
|
|
69 |
</ol>
|
|
|
70 |
|
|
|
71 |
<div class="lms-top">
|
|
|
72 |
<div>
|
|
|
73 |
#if($lead.getLmsCode())<div class="lms-code">$lead.getLmsCode()</div>#else<div class="lms-code">Lead #$lead.getId()</div>#end
|
|
|
74 |
<h2>$!lead.getFirstName() $!lead.getLastName()#if($lead.getOutLetName()) · <span style="font-weight:500;color:#59668a;">$lead.getOutLetName()</span>#end</h2>
|
|
|
75 |
</div>
|
|
|
76 |
<div style="text-align:right;">
|
|
|
77 |
#if($terminal)
|
|
|
78 |
<span class="lms-pill pill-term">$effectiveStage.name().replace("_"," ")</span>
|
|
|
79 |
#else
|
|
|
80 |
<span class="lms-pill pill-stage">$effectiveStage.name().replace("_"," ")</span>
|
|
|
81 |
#end
|
|
|
82 |
<span class="lms-pill sla-$slaState" title="First-contact SLA (3–5 hrs)">SLA: $slaState.replace("_"," ")</span>
|
|
|
83 |
#if($lead.getAssignmentStatus() == "HOLD")<span class="lms-pill lms-hold" title="No active BM/RSM mapped for this region">HOLD queue</span>#end
|
|
|
84 |
</div>
|
|
|
85 |
</div>
|
|
|
86 |
|
|
|
87 |
## ---- Stepper ----
|
|
|
88 |
<div class="lms-steps">
|
|
|
89 |
#set($i = 0)
|
|
|
90 |
#foreach($st in $stages)
|
|
|
91 |
#if($i < $stageIndex)#set($cls = "done")#elseif($i == $stageIndex)#set($cls = "cur")#else#set($cls = "todo")#end
|
|
|
92 |
<div class="lms-step $cls">
|
|
|
93 |
<span class="dot">#if($i < $stageIndex)✓#else#set($n = $i + 1)$n#end</span>
|
|
|
94 |
$st.name().replace("_"," ")
|
|
|
95 |
</div>
|
|
|
96 |
#set($i = $i + 1)
|
|
|
97 |
#end
|
|
|
98 |
</div>
|
|
|
99 |
|
|
|
100 |
<div class="row">
|
|
|
101 |
## ================= LEFT: details + trail =================
|
|
|
102 |
<div class="col-md-7">
|
|
|
103 |
<div class="lms-card">
|
|
|
104 |
<h4 style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
|
|
105 |
<span>Record</span>
|
|
|
106 |
<button type="button" class="btn btn-default btn-xs" id="lmsEditBtn"><i class="fa fa-pencil"></i> Edit</button>
|
|
|
107 |
</h4>
|
|
|
108 |
<table class="lms-kv">
|
|
|
109 |
<tr><td class="k">LMS ID</td><td>#if($lead.getLmsCode())<span style="font-family:monospace">$lead.getLmsCode()</span>#else—#end</td></tr>
|
|
|
110 |
<tr><td class="k">Created</td><td>#if($lead.getCreatedTimestamp())$lead.getCreatedTimestamp().format($dateTimeFormatter)#end · by $!lead.getCreatedBy()</td></tr>
|
|
|
111 |
<tr><td class="k">Creation path</td><td>#if($lead.getCreationPath() == "B")B · Field encounter#elseif($lead.getCreationPath() == "A")A · BGC outbound#else—#end</td></tr>
|
|
|
112 |
<tr><td class="k">Retailer</td><td>$!lead.getFirstName() $!lead.getLastName()</td></tr>
|
|
|
113 |
<tr><td class="k">Business</td><td>#if("$!lead.getOutLetName()" != "")$lead.getOutLetName()#else—#end</td></tr>
|
|
|
114 |
<tr><td class="k">Contact</td><td>$!lead.getLeadMobile()</td></tr>
|
|
|
115 |
## Empty address/city/state are written as "" (NOT NULL columns), so each part is
|
|
|
116 |
## tested before it is joined — otherwise an uncaptured location prints as ", ,".
|
|
|
117 |
#set($loc = "")
|
|
|
118 |
#if("$!lead.getAddress()" != "")#set($loc = "$!lead.getAddress()")#end
|
|
|
119 |
#if("$!lead.getCity()" != "")#if($loc != "")#set($loc = "$loc, ")#end#set($loc = "$loc$!lead.getCity()")#end
|
|
|
120 |
#if("$!lead.getState()" != "")#if($loc != "")#set($loc = "$loc, ")#end#set($loc = "$loc$!lead.getState()")#end
|
|
|
121 |
<tr><td class="k">Location</td><td>#if($loc != "")$loc#else—#end</td></tr>
|
|
|
122 |
<tr><td class="k">Region</td><td>#if($lead.getRegionCode())$lead.getRegionCode()#else—#end</td></tr>
|
| 37692 |
vikas |
123 |
## Formatted by the controller: potential holds lakhs on new rows and rupees on
|
|
|
124 |
## older ones, and normalising that in Velocity would mean rounding in a template.
|
|
|
125 |
<tr><td class="k">Business value</td><td>#if($businessValue)₹${businessValue}/mo#else—#end</td></tr>
|
| 37651 |
vikas |
126 |
<tr><td class="k">Source</td><td>$!lead.getSource()</td></tr>
|
|
|
127 |
<tr><td class="k">Last disposition</td><td>#if($lead.getDisposition())$lead.getDisposition().name().replace("_"," ")#if($lead.getDispositionSubReason()) · $lead.getDispositionSubReason()#end#else—#end</td></tr>
|
|
|
128 |
<tr><td class="k">Owner BM/RSM</td><td>#if($bm)$bm.getFullName() <span class="lms-note" style="display:inline">(auto)</span>#else—#end</td></tr>
|
|
|
129 |
<tr><td class="k">Working owner</td><td>#if($owner)$owner.getFullName()#else—#end</td></tr>
|
|
|
130 |
</table>
|
|
|
131 |
<div class="lms-note">LMS ID, dates & auto-assignment are system-set and immutable.</div>
|
|
|
132 |
</div>
|
|
|
133 |
|
|
|
134 |
<div class="lms-card">
|
|
|
135 |
<h4>Trail</h4>
|
|
|
136 |
<ul class="lms-trail">
|
|
|
137 |
#if($trail.isEmpty())
|
|
|
138 |
<li><div class="t-body">No activity yet.</div></li>
|
|
|
139 |
#else
|
|
|
140 |
#foreach($act in $trail)
|
|
|
141 |
<li>
|
|
|
142 |
<div class="t-meta">
|
|
|
143 |
#if($act.getCreatedTimestamp())$act.getCreatedTimestamp().format($dateTimeFormatter)#end
|
|
|
144 |
#set($actor = $authUserMap.get($act.getAuthId()))
|
|
|
145 |
#if($actor) · $actor.getFullName()#end
|
|
|
146 |
#if($act.getCommunicationType()) · $act.getCommunicationType()#end
|
|
|
147 |
</div>
|
|
|
148 |
<div class="t-body">$!act.getRemark()#if($act.getSchelduleTimestamp()) <span class="t-meta">(scheduled $act.getSchelduleTimestamp().format($dateTimeFormatter))</span>#end</div>
|
|
|
149 |
## The recording of the call this disposition came from, so the
|
|
|
150 |
## agent's account of the conversation and the conversation itself
|
|
|
151 |
## sit together rather than in two separate lists.
|
|
|
152 |
#if($act.getLeadCallId())
|
|
|
153 |
#set($actCall = $callById.get($act.getLeadCallId()))
|
|
|
154 |
#if($actCall && ($actCall.getRecordingObjectKey() || $actCall.getRecordingUuid()))
|
|
|
155 |
<div style="margin-top:4px;">
|
|
|
156 |
<audio controls preload="none" style="height:26px;max-width:220px;vertical-align:middle;">
|
|
|
157 |
<source src="${rc.contextPath}/lms/recording/$actCall.getId()" type="audio/mpeg">
|
|
|
158 |
</audio>
|
|
|
159 |
#if($actCall.getDurationSeconds())<span class="t-meta" style="margin-left:6px;">${actCall.getDurationSeconds()}s</span>#end
|
|
|
160 |
</div>
|
|
|
161 |
#end
|
|
|
162 |
#end
|
|
|
163 |
</li>
|
|
|
164 |
#end
|
|
|
165 |
#end
|
|
|
166 |
</ul>
|
|
|
167 |
<div class="lms-note">State changes require a comment; calls attach a disposition.</div>
|
|
|
168 |
</div>
|
|
|
169 |
</div>
|
|
|
170 |
|
|
|
171 |
## ================= RIGHT: SLA + call + beat + advance =================
|
|
|
172 |
<div class="col-md-5">
|
|
|
173 |
<div class="lms-card">
|
|
|
174 |
<h4>First-contact SLA</h4>
|
|
|
175 |
<p style="font-size:13px;margin:0 0 6px;">
|
|
|
176 |
<span class="lms-pill sla-$slaState">$slaState.replace("_"," ")</span>
|
|
|
177 |
</p>
|
|
|
178 |
<table class="lms-kv">
|
|
|
179 |
<tr><td class="k">Due by</td><td>#if($lead.getFirstContactDue())$lead.getFirstContactDue().format($dateTimeFormatter)#else—#end</td></tr>
|
|
|
180 |
<tr><td class="k">First contact</td><td>#if($lead.getFirstContactedAt())$lead.getFirstContactedAt().format($dateTimeFormatter)#else<span style="color:#a9720a">not yet</span>#end</td></tr>
|
|
|
181 |
<tr><td class="k">Unreachable tries</td><td>#if($lead.getUnreachableCount())$lead.getUnreachableCount()#else 0#end</td></tr>
|
|
|
182 |
</table>
|
|
|
183 |
</div>
|
|
|
184 |
|
|
|
185 |
<div class="lms-card">
|
|
|
186 |
<h4>Click-to-Call</h4>
|
|
|
187 |
#if($dnd)
|
|
|
188 |
<p style="font-size:13px;margin:0 0 10px;">
|
|
|
189 |
<span class="lms-pill lms-hold">Do not call</span>
|
|
|
190 |
</p>
|
|
|
191 |
<p style="font-size:12px;color:#7683a4;margin:0 0 10px;">
|
|
|
192 |
Blocked#if($dnd.getCreatedTimestamp()) on $dnd.getCreatedTimestamp().format($dateTimeFormatter)#end#if($dnd.getReason()) · $dnd.getReason()#end.
|
|
|
193 |
Only the audit team can lift a DND.
|
|
|
194 |
</p>
|
|
|
195 |
<button class="btn btn-default" id="lmsCallBtn" disabled
|
|
|
196 |
title="This retailer is on the do-not-call register">
|
|
|
197 |
<i class="fa fa-phone-slash"></i> Calling blocked
|
|
|
198 |
</button>
|
|
|
199 |
<button class="btn btn-default btn-sm" id="lmsLogOutcomeBtn" style="margin-left:6px;"
|
|
|
200 |
title="Record an outcome without calling">
|
|
|
201 |
<i class="fa fa-pencil"></i> Log outcome
|
|
|
202 |
</button>
|
|
|
203 |
#else
|
|
|
204 |
<p style="font-size:13px;color:#59668a;margin:0 0 10px;">Bridged call · disposition required on disconnect.</p>
|
|
|
205 |
<button class="btn btn-coral" id="lmsCallBtn" data-mobile="$!lead.getLeadMobile()">
|
|
|
206 |
<i class="fa fa-phone"></i> Call retailer (C2C)
|
|
|
207 |
</button>
|
|
|
208 |
#end
|
|
|
209 |
#if($lead.getRecordingUrl())
|
|
|
210 |
<a class="btn btn-default btn-sm" href="$lead.getRecordingUrl()" target="_blank" style="margin-left:6px;"><i class="fa fa-play"></i> Recording</a>
|
|
|
211 |
#end
|
|
|
212 |
<div class="lms-callbar" id="lmsCallBar" style="display:none;">
|
|
|
213 |
<div class="cb-top">
|
|
|
214 |
<div class="cb-state"><span class="cb-dot"></span><span id="cbState">Starting…</span></div>
|
|
|
215 |
<div class="cb-timer" id="cbTimer">00:00</div>
|
|
|
216 |
</div>
|
|
|
217 |
<div class="cb-actions">
|
|
|
218 |
<button type="button" class="btn btn-default btn-sm" id="cbMute"><i class="fa fa-microphone"></i> Mute</button>
|
|
|
219 |
<button type="button" class="btn btn-default btn-sm" id="cbKeys"><i class="fa fa-th"></i> Keypad</button>
|
|
|
220 |
<button type="button" class="btn btn-coral btn-sm" id="cbHangup"><i class="fa fa-phone"></i> Hang up</button>
|
|
|
221 |
</div>
|
|
|
222 |
<div class="cb-keys" id="cbKeypad" style="display:none;"></div>
|
|
|
223 |
<div class="cb-err" id="cbError" style="display:none;"></div>
|
|
|
224 |
</div>
|
|
|
225 |
|
|
|
226 |
#if($calls && !$calls.isEmpty())
|
|
|
227 |
<div class="lms-note" style="margin-top:12px;">
|
|
|
228 |
$calls.size() call#if($calls.size() != 1)s#end logged
|
|
|
229 |
</div>
|
|
|
230 |
<table class="lms-kv" style="margin-top:4px;">
|
|
|
231 |
#foreach($c in $calls)
|
|
|
232 |
<tr>
|
|
|
233 |
<td class="k" style="width:auto;white-space:nowrap;">
|
|
|
234 |
#if($c.getStartedAt())$c.getStartedAt().format($dateTimeFormatter)#else—#end
|
|
|
235 |
</td>
|
|
|
236 |
<td>
|
|
|
237 |
$c.getStatus()#if($c.getDurationSeconds()) · ${c.getDurationSeconds()}s#end
|
|
|
238 |
## Playback goes through the gated endpoint, never a raw URL - it
|
|
|
239 |
## enforces the SOP 15 matrix and logs the open.
|
|
|
240 |
## Either handle is enough to play: the endpoint serves our archived
|
|
|
241 |
## copy when there is one and proxies the vendor's otherwise, so an
|
|
|
242 |
## agent is not made to wait on the archival sweep to hear a call.
|
|
|
243 |
#if($c.getRecordingObjectKey() || $c.getRecordingUuid())
|
|
|
244 |
<audio controls preload="none" style="height:28px;vertical-align:middle;margin-left:8px;max-width:190px;">
|
|
|
245 |
<source src="${rc.contextPath}/lms/recording/$c.getId()" type="audio/mpeg">
|
|
|
246 |
</audio>
|
|
|
247 |
#if(!$c.getRecordingObjectKey())
|
|
|
248 |
<span class="lms-note" style="display:inline;margin-left:6px;">(archiving…)</span>
|
|
|
249 |
#end
|
|
|
250 |
#end
|
|
|
251 |
</td>
|
|
|
252 |
</tr>
|
|
|
253 |
#end
|
|
|
254 |
</table>
|
|
|
255 |
#end
|
|
|
256 |
</div>
|
|
|
257 |
|
|
|
258 |
<div class="lms-card">
|
|
|
259 |
<h4>Beat plan</h4>
|
|
|
260 |
#if($geo && $geo.getStatus() && $geo.getStatus().toString() == "APPROVED")
|
|
|
261 |
<p style="font-size:13px;margin:0 0 8px;"><span class="lms-pill sla-MET">Geo verified</span></p>
|
|
|
262 |
<a href="#" class="lead" style="font-size:12px;">Add to a beat from the Leads screen →</a>
|
|
|
263 |
#else
|
|
|
264 |
<p style="font-size:13px;color:#a9720a;margin:0;">Geo-pin not yet approved. Approve the lead's geo-location (Leads screen) before adding it to a beat.</p>
|
|
|
265 |
#end
|
|
|
266 |
<div class="lms-note">Check-in at the retailer moves the stage to Visited.</div>
|
|
|
267 |
</div>
|
|
|
268 |
|
|
|
269 |
#if(!$terminal)
|
|
|
270 |
<div class="lms-card">
|
|
|
271 |
<h4>Advance stage</h4>
|
|
|
272 |
<select id="advStageSel" class="form-control input-sm" style="margin-bottom:8px;">
|
|
|
273 |
<option value="" disabled selected>Move to…</option>
|
|
|
274 |
#set($j = 0)
|
|
|
275 |
#foreach($st in $stages)
|
|
|
276 |
#if($j > $stageIndex)<option value="$st.name()">$st.name().replace("_"," ")</option>#end
|
|
|
277 |
#set($j = $j + 1)
|
|
|
278 |
#end
|
|
|
279 |
</select>
|
|
|
280 |
<input id="advComment" class="form-control input-sm" placeholder="Comment (required)" style="margin-bottom:8px;">
|
|
|
281 |
<button class="btn btn-navy btn-sm" id="advStageBtn" data-leadid="$lead.getId()">Advance</button>
|
|
|
282 |
</div>
|
|
|
283 |
#end
|
|
|
284 |
</div>
|
|
|
285 |
</div>
|
|
|
286 |
|
|
|
287 |
## ================= Edit record modal =================
|
|
|
288 |
## Only the fields the SOP lets a user correct. The id, the created stamp, the creation path and
|
|
|
289 |
## the SLA clock are shown here read-only so it is obvious they are not up for editing (SOP 7.1).
|
|
|
290 |
<div class="modal" id="editLeadModal" role="dialog">
|
|
|
291 |
<div class="modal-dialog">
|
|
|
292 |
<div class="modal-content lms-record">
|
|
|
293 |
<div class="modal-header">
|
|
|
294 |
<button type="button" class="close" data-dismiss="modal">×</button>
|
|
|
295 |
<h4 class="modal-title" style="color:#1f2a5e;">Edit record</h4>
|
|
|
296 |
<div class="lms-note">Every change is written to the trail against your name.</div>
|
|
|
297 |
</div>
|
|
|
298 |
<div class="modal-body">
|
|
|
299 |
<table class="lms-kv" style="margin-bottom:14px;">
|
|
|
300 |
<tr><td class="k">LMS ID</td><td><span style="font-family:monospace">#if($lead.getLmsCode())$lead.getLmsCode()#else Lead #$lead.getId()#end</span> <span class="lms-note" style="display:inline">locked</span></td></tr>
|
|
|
301 |
<tr><td class="k">Created</td><td>#if($lead.getCreatedTimestamp())$lead.getCreatedTimestamp().format($dateTimeFormatter)#end <span class="lms-note" style="display:inline">locked</span></td></tr>
|
|
|
302 |
<tr><td class="k">Creation path</td><td>#if($lead.getCreationPath() == "B")B · Field encounter#elseif($lead.getCreationPath() == "A")A · BGC outbound#else—#end <span class="lms-note" style="display:inline">locked</span></td></tr>
|
|
|
303 |
</table>
|
|
|
304 |
|
|
|
305 |
<div class="row">
|
|
|
306 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
|
|
307 |
<label style="font-size:12px;color:#7683a4;">Retailer name (required)</label>
|
|
|
308 |
<input id="ed_name" class="form-control input-sm" value="$!lead.getFirstName() $!lead.getLastName()">
|
|
|
309 |
</div>
|
|
|
310 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
|
|
311 |
<label style="font-size:12px;color:#7683a4;">Business name</label>
|
|
|
312 |
<input id="ed_business" class="form-control input-sm" value="$!lead.getOutLetName()" placeholder="Shop / trade name">
|
|
|
313 |
</div>
|
|
|
314 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
|
|
315 |
<label style="font-size:12px;color:#7683a4;">Contact number (required)</label>
|
|
|
316 |
<input id="ed_mobile" class="form-control input-sm" value="$!lead.getLeadMobile()" maxlength="10">
|
|
|
317 |
</div>
|
|
|
318 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
| 37692 |
vikas |
319 |
<label style="font-size:12px;color:#7683a4;">Business value (₹ lakh / month)</label>
|
|
|
320 |
<input id="ed_potential" type="number" min="0" step="0.5" class="form-control input-sm" value="$!businessValueInput">
|
| 37651 |
vikas |
321 |
</div>
|
|
|
322 |
<div class="col-sm-12" style="margin-bottom:10px;">
|
|
|
323 |
<label style="font-size:12px;color:#7683a4;">Address</label>
|
|
|
324 |
<input id="ed_address" class="form-control input-sm" value="$!lead.getAddress()" placeholder="Shop address">
|
|
|
325 |
</div>
|
|
|
326 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
|
|
327 |
<label style="font-size:12px;color:#7683a4;">City</label>
|
|
|
328 |
<input id="ed_city" class="form-control input-sm" value="$!lead.getCity()">
|
|
|
329 |
</div>
|
|
|
330 |
<div class="col-sm-6" style="margin-bottom:10px;">
|
|
|
331 |
<label style="font-size:12px;color:#7683a4;">State</label>
|
|
|
332 |
<input id="ed_state" class="form-control input-sm" value="$!lead.getState()">
|
|
|
333 |
</div>
|
|
|
334 |
<div class="col-sm-12">
|
|
|
335 |
<label style="font-size:12px;color:#7683a4;">Region</label>
|
|
|
336 |
<select id="ed_region" class="form-control input-sm">
|
|
|
337 |
<option value="">— Not set —</option>
|
|
|
338 |
#foreach($r in $regions)
|
|
|
339 |
<option value="$r.getId()"#if($lead.getRegionId() && $lead.getRegionId() == $r.getId()) selected#end>#if($r.getRegionCode())$r.getRegionCode() · #end$r.getName()</option>
|
|
|
340 |
#end
|
|
|
341 |
</select>
|
|
|
342 |
<div class="lms-note">Changing the region re-runs auto-assignment and moves the lead to that region's BM/RSM. A lead already mapped to a named owner keeps that owner.</div>
|
|
|
343 |
</div>
|
|
|
344 |
</div>
|
|
|
345 |
</div>
|
|
|
346 |
<div class="modal-footer">
|
|
|
347 |
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
|
|
348 |
<button type="button" class="btn btn-navy" id="editSaveBtn" data-leadid="$lead.getId()">Save changes</button>
|
|
|
349 |
</div>
|
|
|
350 |
</div>
|
|
|
351 |
</div>
|
|
|
352 |
</div>
|
|
|
353 |
|
|
|
354 |
## ================= Disposition modal =================
|
|
|
355 |
<div class="modal" id="dispositionModal" role="dialog">
|
|
|
356 |
<div class="modal-dialog">
|
|
|
357 |
<div class="modal-content lms-record">
|
|
|
358 |
<div class="modal-header">
|
|
|
359 |
<button type="button" class="close" data-dismiss="modal">×</button>
|
|
|
360 |
<h4 class="modal-title" style="color:#1f2a5e;">Call disposition</h4>
|
|
|
361 |
<div class="lms-note">Live call is bridged through C2C; recording auto-links to the trail.</div>
|
|
|
362 |
</div>
|
|
|
363 |
<div class="modal-body">
|
|
|
364 |
<label style="font-size:12px;color:#7683a4;">Outcome (required)</label>
|
|
|
365 |
<div class="disp-grid" id="dispGrid">
|
|
|
366 |
#foreach($d in $dispositions)
|
|
|
367 |
<div class="disp-opt" data-disp="$d.name()">$d.name().replace("_"," ")</div>
|
|
|
368 |
#end
|
|
|
369 |
</div>
|
|
|
370 |
<div id="dispExtra" style="margin-top:12px;"></div>
|
|
|
371 |
<div style="margin-top:12px;">
|
|
|
372 |
<label style="font-size:12px;color:#7683a4;">Note (attaches to trail)</label>
|
|
|
373 |
<textarea id="dispNote" class="form-control" rows="2"></textarea>
|
|
|
374 |
</div>
|
|
|
375 |
<div style="margin-top:10px;">
|
|
|
376 |
<label style="font-size:12px;color:#7683a4;">Recording URL (optional)</label>
|
|
|
377 |
<input id="dispRecording" class="form-control input-sm" placeholder="https://…">
|
|
|
378 |
</div>
|
|
|
379 |
</div>
|
|
|
380 |
<div class="modal-footer">
|
|
|
381 |
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
|
|
382 |
<button type="button" class="btn btn-coral" id="dispSaveBtn" data-leadid="$lead.getId()">Save disposition</button>
|
|
|
383 |
</div>
|
|
|
384 |
</div>
|
|
|
385 |
</div>
|
|
|
386 |
</div>
|
|
|
387 |
</section>
|
|
|
388 |
|
|
|
389 |
<script type="text/javascript">
|
|
|
390 |
(function () {
|
|
|
391 |
var leadId = $lead.getId();
|
|
|
392 |
var selectedDisp = null;
|
|
|
393 |
// Set by the dialer when it places a call; binds this disposition to that call's row so the
|
|
|
394 |
// trail can show duration and a recording. Null when the agent dialled from their own handset.
|
|
|
395 |
var leadCallId = null;
|
|
|
396 |
|
|
|
397 |
// Contextual sub-fields per disposition (SOP §12.2), mirroring the mockup.
|
|
|
398 |
function renderDispExtra(d) {
|
|
|
399 |
var h = '';
|
|
|
400 |
if (d === 'INTERESTED') {
|
|
|
401 |
h = fld('Confirm monthly business value (₹) *', '<input id="dx_value" type="number" class="form-control input-sm" placeholder="e.g. 85000">');
|
|
|
402 |
} else if (d === 'CALLBACK') {
|
|
|
403 |
h = fld('Callback date & time *', '<input id="dx_callback" type="datetime-local" class="form-control input-sm">');
|
|
|
404 |
} else if (d === 'NOT_REACHABLE') {
|
|
|
405 |
h = fld('Retry schedule', '<select id="dx_retry" class="form-control input-sm"><option value="24h">In 24 hours</option><option value="4h">In 4 hours</option><option value="tomorrow">Tomorrow morning</option></select>')
|
|
|
406 |
+ '<div class="lms-note">After 3 unreachable attempts the lead auto-flags as Dropped.</div>';
|
|
|
407 |
} else if (d === 'WRONG_NUMBER') {
|
|
|
408 |
h = fld('Corrected number (optional)', '<input id="dx_corrected" class="form-control input-sm" placeholder="10-digit; blank closes the lead">');
|
|
|
409 |
} else if (d === 'FOLLOW_UP') {
|
|
|
410 |
h = fld('Type *', '<select id="dx_futype" class="form-control input-sm"><option value="CALL">Call</option><option value="MEETING">Meeting (field visit)</option></select>')
|
|
|
411 |
+ fld('Follow-up date & time *', '<input id="dx_followup" type="datetime-local" class="form-control input-sm">');
|
|
|
412 |
} else if (d === 'NOT_INTERESTED') {
|
|
|
413 |
h = fld('Sub-reason *', '<select id="dx_subreason" class="form-control input-sm"><option value="Price">Price</option><option value="Terms">Terms</option><option value="Already partnered">Already partnered</option><option value="Category mismatch">Category mismatch</option><option value="Other">Other</option></select>');
|
|
|
414 |
} else if (d === 'DO_NOT_CALL') {
|
|
|
415 |
h = '<div class="lms-note" style="color:#f0483e;">Retailer will be permanently blocked and added to the DND list.</div>';
|
|
|
416 |
}
|
|
|
417 |
$('#dispExtra').html(h);
|
|
|
418 |
}
|
|
|
419 |
function fld(label, input) {
|
|
|
420 |
return '<div style="margin-bottom:8px;"><label style="font-size:12px;color:#7683a4;">' + label + '</label>' + input + '</div>';
|
|
|
421 |
}
|
|
|
422 |
|
|
|
423 |
function openDispositionModal() {
|
|
|
424 |
selectedDisp = null;
|
|
|
425 |
$('#dispGrid .disp-opt').removeClass('sel');
|
|
|
426 |
$('#dispExtra').html('');
|
|
|
427 |
$('#dispNote').val('');
|
|
|
428 |
$('#dispositionModal').modal({ backdrop: false });
|
|
|
429 |
}
|
|
|
430 |
|
|
|
431 |
// DND leads have no call button, but an outcome can still arrive by email or in person.
|
|
|
432 |
$('#lmsLogOutcomeBtn').on('click', openDispositionModal);
|
|
|
433 |
|
|
|
434 |
/* ===================== Web dialer =====================
|
|
|
435 |
The agent's audio runs through the browser over WebRTC; Vonage bridges it to the retailer's
|
|
|
436 |
phone using the NCCO our answer webhook returns. This page only drives the call — it never
|
|
|
437 |
decides who gets dialled, which is settled server-side from the lead record.
|
|
|
438 |
|
|
|
439 |
Degrades on purpose: if the dialer is not configured, or the agent is not provisioned, the
|
|
|
440 |
call button falls back to opening the disposition modal exactly as it did before, so an
|
|
|
441 |
unconfigured environment loses calling but not the ability to record an outcome. */
|
|
|
442 |
|
|
|
443 |
var SDK_URL = 'https://cdn.jsdelivr.net/npm/@vonage/client-sdk@2.6.3/dist/vonageClientSDK.min.js';
|
|
|
444 |
var sdkPromise = null;
|
|
|
445 |
var vonageClient = null;
|
|
|
446 |
var activeCallId = null;
|
|
|
447 |
var muted = false;
|
|
|
448 |
var timerHandle = null;
|
|
|
449 |
var callStartedAt = null;
|
|
|
450 |
var serverCallActive = false;
|
|
|
451 |
var pollHandle = null;
|
|
|
452 |
// Whether the LAST call was server-placed, and whether it was ever actually answered. Kept
|
|
|
453 |
// separately from serverCallActive because endCall() runs after that has been cleared, and it
|
|
|
454 |
// needs to know what kind of call just ended.
|
|
|
455 |
var lastCallServerPlaced = false;
|
|
|
456 |
var lastCallAnswered = false;
|
|
|
457 |
|
|
|
458 |
// 1.36 MB of SDK. Loaded on first dial rather than on every record view, so agents who never
|
|
|
459 |
// call never pay for it.
|
|
|
460 |
function loadSdk() {
|
|
|
461 |
if (sdkPromise) { return sdkPromise; }
|
|
|
462 |
sdkPromise = new Promise(function (resolve, reject) {
|
|
|
463 |
if (window.vonageClientSDK) { resolve(window.vonageClientSDK); return; }
|
|
|
464 |
var el = document.createElement('script');
|
|
|
465 |
el.src = SDK_URL;
|
|
|
466 |
el.async = true;
|
|
|
467 |
el.onload = function () {
|
|
|
468 |
if (window.vonageClientSDK) { resolve(window.vonageClientSDK); }
|
|
|
469 |
else { reject(new Error('Dialer library loaded but did not register')); }
|
|
|
470 |
};
|
|
|
471 |
el.onerror = function () { reject(new Error('Could not load the dialer library')); };
|
|
|
472 |
document.head.appendChild(el);
|
|
|
473 |
});
|
|
|
474 |
return sdkPromise;
|
|
|
475 |
}
|
|
|
476 |
|
|
|
477 |
function bar() { return $('#lmsCallBar'); }
|
|
|
478 |
function setState(text, cls) {
|
|
|
479 |
$('#cbState').text(text);
|
|
|
480 |
bar().removeClass('is-ringing is-active is-ended');
|
|
|
481 |
if (cls) { bar().addClass(cls); }
|
|
|
482 |
}
|
|
|
483 |
function showBar() { bar().show(); $('#cbError').hide().text(''); }
|
|
|
484 |
function hideBar() { bar().hide(); $('#cbKeypad').hide(); }
|
|
|
485 |
function barError(msg) { $('#cbError').text(msg).show(); }
|
|
|
486 |
|
|
|
487 |
function startTimer() {
|
|
|
488 |
callStartedAt = Date.now();
|
|
|
489 |
stopTimer();
|
|
|
490 |
timerHandle = setInterval(function () {
|
|
|
491 |
var s = Math.floor((Date.now() - callStartedAt) / 1000);
|
|
|
492 |
var m = Math.floor(s / 60);
|
|
|
493 |
$('#cbTimer').text(('0' + m).slice(-2) + ':' + ('0' + (s % 60)).slice(-2));
|
|
|
494 |
}, 500);
|
|
|
495 |
}
|
|
|
496 |
function stopTimer() {
|
|
|
497 |
if (timerHandle) { clearInterval(timerHandle); timerHandle = null; }
|
|
|
498 |
}
|
|
|
499 |
|
|
|
500 |
function jsonFetch(url, options) {
|
|
|
501 |
// Raw fetch rather than the common.js helpers: those attach a global loader modal and an
|
|
|
502 |
// alert-on-error to every request, which would flash over the page mid-call.
|
|
|
503 |
return fetch(context + url, $.extend({ credentials: 'same-origin',
|
|
|
504 |
headers: { 'X-Requested-With': 'XMLHttpRequest' } }, options || {}))
|
|
|
505 |
.then(function (r) { return r.json(); })
|
|
|
506 |
.then(function (j) { return (j && j.response !== undefined) ? j.response : j; });
|
|
|
507 |
}
|
|
|
508 |
|
|
|
509 |
$('#lmsCallBtn').on('click', function () {
|
|
|
510 |
if (activeCallId) { return; }
|
|
|
511 |
startCall();
|
|
|
512 |
});
|
|
|
513 |
|
|
|
514 |
function startCall() {
|
|
|
515 |
$('#lmsCallBtn').prop('disabled', true);
|
|
|
516 |
// Server-side gate first: DND and calling hours. A check that only lives in the browser is
|
|
|
517 |
// not a check, and this also stops us burning an SDK load on a call that cannot be placed.
|
|
|
518 |
jsonFetch('/lms/dialer/precheck?leadId=' + encodeURIComponent(leadId), { method: 'POST' })
|
|
|
519 |
.then(function (pre) {
|
|
|
520 |
if (!pre || !pre.ok) {
|
|
|
521 |
throw new Error((pre && pre.reason) || 'This lead cannot be called right now');
|
|
|
522 |
}
|
|
|
523 |
return jsonFetch('/lms/dialer/token');
|
|
|
524 |
})
|
|
|
525 |
.then(function (auth) {
|
|
|
526 |
if (!auth || !auth.ok) {
|
|
|
527 |
throw new Error((auth && auth.reason) || 'The dialer is unavailable');
|
|
|
528 |
}
|
|
|
529 |
// Two very different providers behind one button. SERVER_PLACED (VBC click2dial)
|
|
|
530 |
// rings the agent's own desk phone or VBC app and never touches browser audio;
|
|
|
531 |
// WEBRTC dials from this page. The mode decides which, so the button behaves
|
|
|
532 |
// correctly whichever platform the account actually has.
|
|
|
533 |
if (auth.mode === 'SERVER_PLACED') { return placeServerCall(); }
|
|
|
534 |
return loadSdk().then(function (sdk) { return connect(sdk, auth); });
|
|
|
535 |
})
|
|
|
536 |
.catch(function (err) {
|
|
|
537 |
$('#lmsCallBtn').prop('disabled', false);
|
|
|
538 |
hideBar();
|
|
|
539 |
// Falling back to the modal keeps the page useful when calling is unavailable.
|
|
|
540 |
alert((err && err.message) || 'Could not start the call');
|
|
|
541 |
openDispositionModal();
|
|
|
542 |
});
|
|
|
543 |
}
|
|
|
544 |
|
|
|
545 |
/* ---- server-placed (VBC click2dial) ---- */
|
|
|
546 |
|
|
|
547 |
function placeServerCall() {
|
|
|
548 |
showBar();
|
|
|
549 |
lastCallServerPlaced = true;
|
|
|
550 |
lastCallAnswered = false;
|
|
|
551 |
setState('Calling your phone…', 'is-ringing');
|
|
|
552 |
$('#cbTimer').text('00:00');
|
|
|
553 |
// Mute and DTMF belong to the handset, not to us — hide rather than offer controls that
|
|
|
554 |
// would silently do nothing.
|
|
|
555 |
$('#cbMute, #cbKeys, #cbKeypad').hide();
|
|
|
556 |
return jsonFetch('/lms/dialer/dial?leadId=' + encodeURIComponent(leadId), { method: 'POST' })
|
|
|
557 |
.then(function (res) {
|
|
|
558 |
if (!res || !res.ok) { throw new Error((res && res.reason) || 'Could not place the call'); }
|
|
|
559 |
leadCallId = res.leadCallId;
|
|
|
560 |
serverCallActive = true;
|
|
|
561 |
setState('Answer your phone (ext ' + (res.extension || '') + ')…', 'is-ringing');
|
|
|
562 |
barError('');
|
|
|
563 |
$('#cbError').hide();
|
|
|
564 |
pollState();
|
|
|
565 |
});
|
|
|
566 |
}
|
|
|
567 |
|
|
|
568 |
function pollState() {
|
|
|
569 |
if (!serverCallActive || !leadCallId) { return; }
|
|
|
570 |
jsonFetch('/lms/dialer/state?leadCallId=' + encodeURIComponent(leadCallId))
|
|
|
571 |
.then(function (st) {
|
|
|
572 |
if (!serverCallActive) { return; }
|
|
|
573 |
if (st.status === 'ANSWERED') {
|
|
|
574 |
lastCallAnswered = true;
|
|
|
575 |
setState('On call', 'is-active');
|
|
|
576 |
if (!timerHandle) { startTimer(); }
|
|
|
577 |
} else if (st.status === 'RINGING') {
|
|
|
578 |
setState('Ringing the retailer…', 'is-ringing');
|
|
|
579 |
}
|
|
|
580 |
if (st.terminal) { serverCallActive = false; endCall('remote'); return; }
|
|
|
581 |
pollHandle = setTimeout(pollState, 3000);
|
|
|
582 |
})
|
|
|
583 |
.catch(function () {
|
|
|
584 |
// A dropped poll should not kill the call; try again on the next tick.
|
|
|
585 |
pollHandle = setTimeout(pollState, 5000);
|
|
|
586 |
});
|
|
|
587 |
}
|
|
|
588 |
|
|
|
589 |
function connect(sdk, auth) {
|
|
|
590 |
showBar();
|
|
|
591 |
lastCallServerPlaced = false;
|
|
|
592 |
setState('Connecting…', null);
|
|
|
593 |
$('#cbTimer').text('00:00');
|
|
|
594 |
|
|
|
595 |
vonageClient = new sdk.VonageClient();
|
|
|
596 |
wireClientEvents();
|
|
|
597 |
return vonageClient.createSession(auth.token)
|
|
|
598 |
.then(function () {
|
|
|
599 |
// leadId travels in the call context; Vonage hands it back to the answer webhook as
|
|
|
600 |
// custom_data, which is where the lead_call row is created. The browser cannot know
|
|
|
601 |
// that row's id up front, so it asks for it once the call ends.
|
|
|
602 |
return vonageClient.serverCall({ leadId: String(leadId) });
|
|
|
603 |
})
|
|
|
604 |
.then(function (callId) {
|
|
|
605 |
activeCallId = callId;
|
|
|
606 |
setState('Ringing…', 'is-ringing');
|
|
|
607 |
})
|
|
|
608 |
.catch(function (err) {
|
|
|
609 |
var msg = (err && err.message) || 'Could not connect';
|
|
|
610 |
// A refused mic is the single most common failure and needs its own wording.
|
|
|
611 |
if (err && (err.name === 'NotAllowedError' || /permission|denied/i.test(msg))) {
|
|
|
612 |
msg = 'Microphone access was blocked — allow it in the browser and try again';
|
|
|
613 |
}
|
|
|
614 |
if (!window.isSecureContext) {
|
|
|
615 |
msg = 'Calling needs a secure (https) connection';
|
|
|
616 |
}
|
|
|
617 |
setState('Failed', 'is-ended');
|
|
|
618 |
barError(msg);
|
|
|
619 |
$('#lmsCallBtn').prop('disabled', false);
|
|
|
620 |
throw err;
|
|
|
621 |
});
|
|
|
622 |
}
|
|
|
623 |
|
|
|
624 |
function wireClientEvents() {
|
|
|
625 |
vonageClient.on('legStatusUpdate', function (callId, legId, status) {
|
|
|
626 |
var s = ('' + status).toLowerCase();
|
|
|
627 |
if (s.indexOf('answer') !== -1 || s === 'active') {
|
|
|
628 |
setState('On call', 'is-active');
|
|
|
629 |
if (!timerHandle) { startTimer(); }
|
|
|
630 |
} else if (s.indexOf('ring') !== -1) {
|
|
|
631 |
setState('Ringing…', 'is-ringing');
|
|
|
632 |
}
|
|
|
633 |
});
|
|
|
634 |
vonageClient.on('callHangup', function (callId, callQuality, reason) {
|
|
|
635 |
endCall(reason);
|
|
|
636 |
});
|
|
|
637 |
vonageClient.on('sessionError', function (err) {
|
|
|
638 |
barError('Call session error: ' + ((err && err.message) || err));
|
|
|
639 |
// Only tear down if a call was actually up; a session error before dialling should not
|
|
|
640 |
// pop the disposition modal for a conversation that never happened.
|
|
|
641 |
if (activeCallId) { endCall('session-error'); }
|
|
|
642 |
else { setState('Failed', 'is-ended'); $('#lmsCallBtn').prop('disabled', false); }
|
|
|
643 |
});
|
|
|
644 |
}
|
|
|
645 |
|
|
|
646 |
function endCall(reason) {
|
|
|
647 |
stopTimer();
|
|
|
648 |
if (pollHandle) { clearTimeout(pollHandle); pollHandle = null; }
|
|
|
649 |
serverCallActive = false;
|
|
|
650 |
activeCallId = null;
|
|
|
651 |
muted = false;
|
|
|
652 |
$('#cbMute').html('<i class="fa fa-microphone"></i> Mute');
|
|
|
653 |
$('#lmsCallBtn').prop('disabled', false);
|
|
|
654 |
|
|
|
655 |
// A server-placed call that was never answered is not a conversation, so do not ask the
|
|
|
656 |
// agent to disposition one. This is the common failure here: VBC accepts the call and
|
|
|
657 |
// returns an id, but never reaches the agent's phone, and the server writes the row off as
|
|
|
658 |
// FAILED after vonage.vbc.initiate.grace.seconds. Dispositioning that would put an outcome
|
|
|
659 |
// like "Not interested" on a retailer nobody ever spoke to.
|
|
|
660 |
if (lastCallServerPlaced && !lastCallAnswered) {
|
|
|
661 |
setState('Could not reach your phone', 'is-ended');
|
|
|
662 |
barError('Your phone never rang, so the retailer was not dialled. '
|
|
|
663 |
+ 'Check you are signed in to the commsSetup app, then try again.');
|
|
|
664 |
return;
|
|
|
665 |
}
|
|
|
666 |
|
|
|
667 |
setState('Call ended', 'is-ended');
|
|
|
668 |
|
|
|
669 |
// The call row is created by the answer webhook, so its id is only knowable after the fact.
|
|
|
670 |
// Fetch it, then open the disposition modal bound to that call.
|
|
|
671 |
jsonFetch('/lms/dialer/call?leadId=' + encodeURIComponent(leadId))
|
|
|
672 |
.then(function (res) {
|
|
|
673 |
if (res && res.found) { leadCallId = res.leadCallId; }
|
|
|
674 |
})
|
|
|
675 |
.catch(function () { /* an unbound disposition still saves; it just has no call link */ })
|
|
|
676 |
.then(function () {
|
|
|
677 |
hideBar();
|
|
|
678 |
openDispositionModal();
|
|
|
679 |
});
|
|
|
680 |
}
|
|
|
681 |
|
|
|
682 |
$('#cbHangup').on('click', function () {
|
|
|
683 |
if (vonageClient && activeCallId) {
|
|
|
684 |
vonageClient.hangup(activeCallId).catch(function () { /* already gone */ });
|
|
|
685 |
} else if (leadCallId) {
|
|
|
686 |
// Server-placed: the call lives at the provider, so the server has to end it.
|
|
|
687 |
jsonFetch('/lms/dialer/hangup?leadCallId=' + encodeURIComponent(leadCallId),
|
|
|
688 |
{ method: 'POST' }).catch(function () { /* already ended */ });
|
|
|
689 |
}
|
|
|
690 |
endCall('local-hangup');
|
|
|
691 |
});
|
|
|
692 |
|
|
|
693 |
$('#cbMute').on('click', function () {
|
|
|
694 |
if (!vonageClient || !activeCallId) { return; }
|
|
|
695 |
var op = muted ? vonageClient.unmute(activeCallId) : vonageClient.mute(activeCallId);
|
|
|
696 |
op.then(function () {
|
|
|
697 |
muted = !muted;
|
|
|
698 |
$('#cbMute').html(muted
|
|
|
699 |
? '<i class="fa fa-microphone-slash"></i> Unmute'
|
|
|
700 |
: '<i class="fa fa-microphone"></i> Mute');
|
|
|
701 |
}).catch(function () { barError('Could not change the microphone'); });
|
|
|
702 |
});
|
|
|
703 |
|
|
|
704 |
$('#cbKeys').on('click', function () {
|
|
|
705 |
var pad = $('#cbKeypad');
|
|
|
706 |
if (!pad.children().length) {
|
|
|
707 |
var keys = ['1','2','3','4','5','6','7','8','9','*','0','#'];
|
|
|
708 |
pad.html(keys.map(function (k) {
|
|
|
709 |
return '<button type="button" data-dtmf="' + k + '">' + k + '</button>';
|
|
|
710 |
}).join(''));
|
|
|
711 |
}
|
|
|
712 |
pad.toggle();
|
|
|
713 |
});
|
|
|
714 |
|
|
|
715 |
$(document).on('click', '#cbKeypad button[data-dtmf]', function () {
|
|
|
716 |
if (!vonageClient || !activeCallId) { return; }
|
|
|
717 |
vonageClient.sendDTMF(activeCallId, $(this).data('dtmf') + '')
|
|
|
718 |
.catch(function () { barError('Could not send the tone'); });
|
|
|
719 |
});
|
|
|
720 |
|
|
|
721 |
// A closed tab must not leave a WebRTC call up on the retailer's end. A server-placed call is
|
|
|
722 |
// on the agent's actual handset and is theirs to end, so closing the tab must NOT drop it.
|
|
|
723 |
$(window).on('beforeunload', function () {
|
|
|
724 |
if (vonageClient && activeCallId) {
|
|
|
725 |
try { vonageClient.hangup(activeCallId); } catch (e) { /* best effort on unload */ }
|
|
|
726 |
}
|
|
|
727 |
});
|
|
|
728 |
|
|
|
729 |
$(document).on('click', '#dispGrid .disp-opt', function () {
|
|
|
730 |
$('#dispGrid .disp-opt').removeClass('sel');
|
|
|
731 |
$(this).addClass('sel');
|
|
|
732 |
selectedDisp = $(this).data('disp');
|
|
|
733 |
renderDispExtra(selectedDisp);
|
|
|
734 |
});
|
|
|
735 |
|
|
|
736 |
$('#dispSaveBtn').on('click', function () {
|
|
|
737 |
if (!selectedDisp) { alert('Pick a disposition'); return; }
|
|
|
738 |
var payload = {
|
|
|
739 |
leadId: leadId,
|
|
|
740 |
disposition: selectedDisp,
|
|
|
741 |
note: $('#dispNote').val(),
|
|
|
742 |
leadCallId: leadCallId,
|
|
|
743 |
recordingUrl: $('#dispRecording').val()
|
|
|
744 |
};
|
|
|
745 |
if (selectedDisp === 'INTERESTED') {
|
|
|
746 |
var v = parseFloat($('#dx_value').val());
|
|
|
747 |
if (!v || v <= 0) { alert('Business value is required'); return; }
|
|
|
748 |
payload.value = v;
|
|
|
749 |
} else if (selectedDisp === 'CALLBACK') {
|
|
|
750 |
if (!$('#dx_callback').val()) { alert('Callback date/time is required'); return; }
|
|
|
751 |
payload.callbackAt = $('#dx_callback').val();
|
|
|
752 |
} else if (selectedDisp === 'NOT_REACHABLE') {
|
|
|
753 |
payload.retrySchedule = $('#dx_retry').val();
|
|
|
754 |
} else if (selectedDisp === 'WRONG_NUMBER') {
|
|
|
755 |
payload.correctedNumber = $('#dx_corrected').val();
|
|
|
756 |
} else if (selectedDisp === 'FOLLOW_UP') {
|
|
|
757 |
if (!$('#dx_followup').val()) { alert('Follow-up date/time is required'); return; }
|
|
|
758 |
payload.followUpType = $('#dx_futype').val();
|
|
|
759 |
payload.followUpAt = $('#dx_followup').val();
|
|
|
760 |
} else if (selectedDisp === 'NOT_INTERESTED') {
|
|
|
761 |
payload.subReason = $('#dx_subreason').val();
|
|
|
762 |
}
|
|
|
763 |
doPostAjaxRequestWithJsonHandler(context + '/lms/disposition', JSON.stringify(payload), function () {
|
|
|
764 |
$('#dispositionModal').modal('hide');
|
|
|
765 |
if (typeof showToast === 'function') { showToast('Disposition saved: ' + selectedDisp); }
|
|
|
766 |
reloadRecord();
|
|
|
767 |
});
|
|
|
768 |
});
|
|
|
769 |
|
|
|
770 |
$('#lmsEditBtn').on('click', function () {
|
|
|
771 |
$('#editLeadModal').modal({ backdrop: false });
|
|
|
772 |
});
|
|
|
773 |
|
|
|
774 |
$('#editSaveBtn').on('click', function () {
|
|
|
775 |
var name = ($('#ed_name').val() || '').trim();
|
|
|
776 |
if (!name) { alert('Retailer name is required'); return; }
|
|
|
777 |
var mobile = ($('#ed_mobile').val() || '').replace(/\D/g, '');
|
|
|
778 |
if (mobile.length < 10) { alert('A valid 10-digit contact number is required'); return; }
|
|
|
779 |
var region = $('#ed_region').val();
|
|
|
780 |
var potential = parseFloat($('#ed_potential').val());
|
|
|
781 |
var payload = {
|
|
|
782 |
leadId: leadId,
|
|
|
783 |
retailerName: name,
|
|
|
784 |
businessName: $('#ed_business').val(),
|
|
|
785 |
mobile: mobile,
|
|
|
786 |
address: $('#ed_address').val(),
|
|
|
787 |
city: $('#ed_city').val(),
|
|
|
788 |
state: $('#ed_state').val(),
|
|
|
789 |
regionId: region ? parseInt(region, 10) : null,
|
|
|
790 |
potential: isNaN(potential) ? null : potential
|
|
|
791 |
};
|
|
|
792 |
doPostAjaxRequestWithJsonHandler(context + '/lms/updateLead', JSON.stringify(payload), function () {
|
|
|
793 |
$('#editLeadModal').modal('hide');
|
|
|
794 |
if (typeof showToast === 'function') { showToast('Record updated'); }
|
|
|
795 |
reloadRecord();
|
|
|
796 |
});
|
|
|
797 |
});
|
|
|
798 |
|
|
|
799 |
$('#advStageBtn').on('click', function () {
|
|
|
800 |
var to = $('#advStageSel').val();
|
|
|
801 |
var comment = $('#advComment').val();
|
|
|
802 |
if (!to) { alert('Choose a stage'); return; }
|
|
|
803 |
if (!comment || !comment.trim()) { alert('A comment is required'); return; }
|
|
|
804 |
doPostAjaxRequestWithParamsHandler(context + '/lms/advanceStage',
|
|
|
805 |
{ leadId: leadId, toStage: to, comment: comment }, function () {
|
|
|
806 |
if (typeof showToast === 'function') { showToast('Stage advanced to ' + to); }
|
|
|
807 |
reloadRecord();
|
|
|
808 |
});
|
|
|
809 |
});
|
|
|
810 |
|
|
|
811 |
function reloadRecord() {
|
|
|
812 |
doGetAjaxRequestHandler(context + '/leadRecord?leadId=' + leadId, function (r) {
|
|
|
813 |
$('#main-content').html(r);
|
|
|
814 |
});
|
|
|
815 |
}
|
|
|
816 |
})();
|
|
|
817 |
</script>
|