Subversion Repositories SmartDukaan

Rev

Go to most recent revision | Details | Last modification | View Log | RSS feed

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