Subversion Repositories SmartDukaan

Rev

Rev 37651 | Details | Compare with Previous | 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>
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)&#8377;${businessValue}/mo#else&mdash;#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()) &middot; $lead.getDispositionSubReason()#end#else&mdash;#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&mdash;#end</td></tr>
129
                    <tr><td class="k">Working owner</td><td>#if($owner)$owner.getFullName()#else&mdash;#end</td></tr>
130
                </table>
131
                <div class="lms-note">LMS ID, dates &amp; 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) &middot; $actor.getFullName()#end
146
                                    #if($act.getCommunicationType()) &middot; $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&mdash;#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()) &middot; $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 &middot; 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&mdash;#end
235
                                </td>
236
                                <td>
237
                                    $c.getStatus()#if($c.getDurationSeconds()) &middot; ${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&hellip;)</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 &rarr;</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&hellip;</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">&times;</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 &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>
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 (&#8377; 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="">&mdash; Not set &mdash;</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() &middot; #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">&times;</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://&hellip;">
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>