Rev 37651 | Blame | Compare with Previous | Last modification | View Log | RSS feed
## LMS Lead Record — full-page record (SOP §6/§8/§12). Loaded into #main-content by the row "Record" link.## Model: lead, effectiveStage, stageIndex, terminal, stages[], dispositions[], trail[], authUserMap, owner, bm, geo, slaState<style>.lms-record { --navy:#1f2a5e; --coral:#f0483e; --amber:#f6a609; --ink:#26324d; --muted:#7683a4; --line:#e5e9f2; }.lms-record { color:var(--ink); }.lms-record .lms-top { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:14px; }.lms-record .lms-code { font-family:monospace; font-size:13px; color:var(--muted); letter-spacing:.5px; }.lms-record h2 { margin:2px 0 0; font-size:22px; color:var(--navy); font-weight:700; }.lms-record .lms-pill { display:inline-block; padding:3px 11px; border-radius:20px; font-size:12px; font-weight:600; }.lms-record .pill-stage { background:var(--navy); color:#fff; }.lms-record .pill-term { background:#8894ad; color:#fff; }.lms-record .sla-MET { background:#e5f6ec; color:#1a8f4c; }.lms-record .sla-BREACHED { background:#fde7e5; color:var(--coral); }.lms-record .sla-AT_RISK { background:#fff3dc; color:#a9720a; }.lms-record .sla-RUNNING { background:#eef1fb; color:var(--navy); }.lms-record .sla-NA { background:#eef0f5; color:var(--muted); }.lms-record .lms-hold { background:#fde7e5; color:var(--coral); }/* stepper */.lms-steps { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0 20px; }.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); }.lms-step.done { background:#eaf7ef; color:#1a8f4c; border-color:#c9ebd6; }.lms-step.cur { background:var(--navy); color:#fff; border-color:var(--navy); font-weight:700; }.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); }.lms-step.done .dot { background:#1a8f4c; color:#fff; border-color:#1a8f4c; }.lms-step.cur .dot { background:#fff; color:var(--navy); }/* cards */.lms-card { border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:16px; background:#fff; }.lms-card h4 { margin:0 0 12px; font-size:14px; color:var(--navy); font-weight:700; text-transform:uppercase; letter-spacing:.4px; }.lms-kv { width:100%; font-size:13px; }.lms-kv td { padding:5px 8px; border-bottom:1px solid var(--line); vertical-align:top; }.lms-kv td.k { color:var(--muted); width:42%; }.lms-note { font-size:11px; color:var(--muted); margin-top:8px; }/* trail */.lms-trail { list-style:none; padding:0; margin:0; }.lms-trail li { position:relative; padding:0 0 14px 20px; border-left:2px solid var(--line); }.lms-trail li:last-child { border-left-color:transparent; }.lms-trail li:before { content:''; position:absolute; left:-6px; top:2px; width:10px; height:10px; border-radius:50%; background:var(--navy); }.lms-trail .t-meta { font-size:11px; color:var(--muted); }.lms-trail .t-body { font-size:13px; }/* disposition modal */.disp-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }.disp-opt { border:1px solid var(--line); border-radius:9px; padding:9px 11px; cursor:pointer; font-size:13px; background:#fff; text-align:left; }.disp-opt.sel { border-color:var(--navy); background:#eef1fb; font-weight:600; }/* in-call bar */.lms-callbar { border:1px solid var(--line); border-radius:10px; padding:12px 14px; margin-top:12px; background:#f8fafd; }.lms-callbar .cb-top { display:flex; align-items:center; justify-content:space-between; gap:10px; }.lms-callbar .cb-state { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600; color:var(--navy); }.lms-callbar .cb-dot { width:9px; height:9px; border-radius:50%; background:#8894ad; flex:none; }.lms-callbar.is-ringing .cb-dot { background:var(--amber); animation:cbPulse 1s infinite; }.lms-callbar.is-active .cb-dot { background:#1a8f4c; animation:cbPulse 2s infinite; }.lms-callbar.is-ended .cb-dot { background:var(--coral); }@keyframes cbPulse { 0%,100%{opacity:1} 50%{opacity:.35} }.lms-callbar .cb-timer { font-family:monospace; font-size:15px; color:var(--ink); }.lms-callbar .cb-actions { display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; }.lms-callbar .cb-keys { display:grid; grid-template-columns:repeat(3,1fr); gap:5px; margin-top:10px; max-width:190px; }.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; }.lms-callbar .cb-err { font-size:12px; color:var(--coral); margin-top:8px; }.lms-record .btn-coral { background:var(--coral); border-color:var(--coral); color:#fff; }.lms-record .btn-navy { background:var(--navy); border-color:var(--navy); color:#fff; }</style><section class="wrapper lms-record"><ol class="breadcrumb"><li><i class="fa fa-home"></i><a href="${rc.contextPath}/dashboard">Home</a></li><li><a href="#" class="lead">Leads</a></li><li>Lead Record</li></ol><div class="lms-top"><div>#if($lead.getLmsCode())<div class="lms-code">$lead.getLmsCode()</div>#else<div class="lms-code">Lead #$lead.getId()</div>#end<h2>$!lead.getFirstName() $!lead.getLastName()#if($lead.getOutLetName()) · <span style="font-weight:500;color:#59668a;">$lead.getOutLetName()</span>#end</h2></div><div style="text-align:right;">#if($terminal)<span class="lms-pill pill-term">$effectiveStage.name().replace("_"," ")</span>#else<span class="lms-pill pill-stage">$effectiveStage.name().replace("_"," ")</span>#end<span class="lms-pill sla-$slaState" title="First-contact SLA (3–5 hrs)">SLA: $slaState.replace("_"," ")</span>#if($lead.getAssignmentStatus() == "HOLD")<span class="lms-pill lms-hold" title="No active BM/RSM mapped for this region">HOLD queue</span>#end</div></div>## ---- Stepper ----<div class="lms-steps">#set($i = 0)#foreach($st in $stages)#if($i < $stageIndex)#set($cls = "done")#elseif($i == $stageIndex)#set($cls = "cur")#else#set($cls = "todo")#end<div class="lms-step $cls"><span class="dot">#if($i < $stageIndex)✓#else#set($n = $i + 1)$n#end</span>$st.name().replace("_"," ")</div>#set($i = $i + 1)#end</div><div class="row">## ================= LEFT: details + trail =================<div class="col-md-7"><div class="lms-card"><h4 style="display:flex;align-items:center;justify-content:space-between;gap:8px;"><span>Record</span><button type="button" class="btn btn-default btn-xs" id="lmsEditBtn"><i class="fa fa-pencil"></i> Edit</button></h4><table class="lms-kv"><tr><td class="k">LMS ID</td><td>#if($lead.getLmsCode())<span style="font-family:monospace">$lead.getLmsCode()</span>#else—#end</td></tr><tr><td class="k">Created</td><td>#if($lead.getCreatedTimestamp())$lead.getCreatedTimestamp().format($dateTimeFormatter)#end · by $!lead.getCreatedBy()</td></tr><tr><td class="k">Creation path</td><td>#if($lead.getCreationPath() == "B")B · Field encounter#elseif($lead.getCreationPath() == "A")A · BGC outbound#else—#end</td></tr><tr><td class="k">Retailer</td><td>$!lead.getFirstName() $!lead.getLastName()</td></tr><tr><td class="k">Business</td><td>#if("$!lead.getOutLetName()" != "")$lead.getOutLetName()#else—#end</td></tr><tr><td class="k">Contact</td><td>$!lead.getLeadMobile()</td></tr>## Empty address/city/state are written as "" (NOT NULL columns), so each part is## tested before it is joined — otherwise an uncaptured location prints as ", ,".#set($loc = "")#if("$!lead.getAddress()" != "")#set($loc = "$!lead.getAddress()")#end#if("$!lead.getCity()" != "")#if($loc != "")#set($loc = "$loc, ")#end#set($loc = "$loc$!lead.getCity()")#end#if("$!lead.getState()" != "")#if($loc != "")#set($loc = "$loc, ")#end#set($loc = "$loc$!lead.getState()")#end<tr><td class="k">Location</td><td>#if($loc != "")$loc#else—#end</td></tr><tr><td class="k">Region</td><td>#if($lead.getRegionCode())$lead.getRegionCode()#else—#end</td></tr>## Formatted by the controller: potential holds lakhs on new rows and rupees on## older ones, and normalising that in Velocity would mean rounding in a template.<tr><td class="k">Business value</td><td>#if($businessValue)₹${businessValue}/mo#else—#end</td></tr><tr><td class="k">Source</td><td>$!lead.getSource()</td></tr><tr><td class="k">Last disposition</td><td>#if($lead.getDisposition())$lead.getDisposition().name().replace("_"," ")#if($lead.getDispositionSubReason()) · $lead.getDispositionSubReason()#end#else—#end</td></tr><tr><td class="k">Owner BM/RSM</td><td>#if($bm)$bm.getFullName() <span class="lms-note" style="display:inline">(auto)</span>#else—#end</td></tr><tr><td class="k">Working owner</td><td>#if($owner)$owner.getFullName()#else—#end</td></tr></table><div class="lms-note">LMS ID, dates & auto-assignment are system-set and immutable.</div></div><div class="lms-card"><h4>Trail</h4><ul class="lms-trail">#if($trail.isEmpty())<li><div class="t-body">No activity yet.</div></li>#else#foreach($act in $trail)<li><div class="t-meta">#if($act.getCreatedTimestamp())$act.getCreatedTimestamp().format($dateTimeFormatter)#end#set($actor = $authUserMap.get($act.getAuthId()))#if($actor) · $actor.getFullName()#end#if($act.getCommunicationType()) · $act.getCommunicationType()#end</div><div class="t-body">$!act.getRemark()#if($act.getSchelduleTimestamp()) <span class="t-meta">(scheduled $act.getSchelduleTimestamp().format($dateTimeFormatter))</span>#end</div>## The recording of the call this disposition came from, so the## agent's account of the conversation and the conversation itself## sit together rather than in two separate lists.#if($act.getLeadCallId())#set($actCall = $callById.get($act.getLeadCallId()))#if($actCall && ($actCall.getRecordingObjectKey() || $actCall.getRecordingUuid()))<div style="margin-top:4px;"><audio controls preload="none" style="height:26px;max-width:220px;vertical-align:middle;"><source src="${rc.contextPath}/lms/recording/$actCall.getId()" type="audio/mpeg"></audio>#if($actCall.getDurationSeconds())<span class="t-meta" style="margin-left:6px;">${actCall.getDurationSeconds()}s</span>#end</div>#end#end</li>#end#end</ul><div class="lms-note">State changes require a comment; calls attach a disposition.</div></div></div>## ================= RIGHT: SLA + call + beat + advance =================<div class="col-md-5"><div class="lms-card"><h4>First-contact SLA</h4><p style="font-size:13px;margin:0 0 6px;"><span class="lms-pill sla-$slaState">$slaState.replace("_"," ")</span></p><table class="lms-kv"><tr><td class="k">Due by</td><td>#if($lead.getFirstContactDue())$lead.getFirstContactDue().format($dateTimeFormatter)#else—#end</td></tr><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><tr><td class="k">Unreachable tries</td><td>#if($lead.getUnreachableCount())$lead.getUnreachableCount()#else 0#end</td></tr></table></div><div class="lms-card"><h4>Click-to-Call</h4>#if($dnd)<p style="font-size:13px;margin:0 0 10px;"><span class="lms-pill lms-hold">Do not call</span></p><p style="font-size:12px;color:#7683a4;margin:0 0 10px;">Blocked#if($dnd.getCreatedTimestamp()) on $dnd.getCreatedTimestamp().format($dateTimeFormatter)#end#if($dnd.getReason()) · $dnd.getReason()#end.Only the audit team can lift a DND.</p><button class="btn btn-default" id="lmsCallBtn" disabledtitle="This retailer is on the do-not-call register"><i class="fa fa-phone-slash"></i> Calling blocked</button><button class="btn btn-default btn-sm" id="lmsLogOutcomeBtn" style="margin-left:6px;"title="Record an outcome without calling"><i class="fa fa-pencil"></i> Log outcome</button>#else<p style="font-size:13px;color:#59668a;margin:0 0 10px;">Bridged call · disposition required on disconnect.</p><button class="btn btn-coral" id="lmsCallBtn" data-mobile="$!lead.getLeadMobile()"><i class="fa fa-phone"></i> Call retailer (C2C)</button>#end#if($lead.getRecordingUrl())<a class="btn btn-default btn-sm" href="$lead.getRecordingUrl()" target="_blank" style="margin-left:6px;"><i class="fa fa-play"></i> Recording</a>#end<div class="lms-callbar" id="lmsCallBar" style="display:none;"><div class="cb-top"><div class="cb-state"><span class="cb-dot"></span><span id="cbState">Starting…</span></div><div class="cb-timer" id="cbTimer">00:00</div></div><div class="cb-actions"><button type="button" class="btn btn-default btn-sm" id="cbMute"><i class="fa fa-microphone"></i> Mute</button><button type="button" class="btn btn-default btn-sm" id="cbKeys"><i class="fa fa-th"></i> Keypad</button><button type="button" class="btn btn-coral btn-sm" id="cbHangup"><i class="fa fa-phone"></i> Hang up</button></div><div class="cb-keys" id="cbKeypad" style="display:none;"></div><div class="cb-err" id="cbError" style="display:none;"></div></div>#if($calls && !$calls.isEmpty())<div class="lms-note" style="margin-top:12px;">$calls.size() call#if($calls.size() != 1)s#end logged</div><table class="lms-kv" style="margin-top:4px;">#foreach($c in $calls)<tr><td class="k" style="width:auto;white-space:nowrap;">#if($c.getStartedAt())$c.getStartedAt().format($dateTimeFormatter)#else—#end</td><td>$c.getStatus()#if($c.getDurationSeconds()) · ${c.getDurationSeconds()}s#end## Playback goes through the gated endpoint, never a raw URL - it## enforces the SOP 15 matrix and logs the open.## Either handle is enough to play: the endpoint serves our archived## copy when there is one and proxies the vendor's otherwise, so an## agent is not made to wait on the archival sweep to hear a call.#if($c.getRecordingObjectKey() || $c.getRecordingUuid())<audio controls preload="none" style="height:28px;vertical-align:middle;margin-left:8px;max-width:190px;"><source src="${rc.contextPath}/lms/recording/$c.getId()" type="audio/mpeg"></audio>#if(!$c.getRecordingObjectKey())<span class="lms-note" style="display:inline;margin-left:6px;">(archiving…)</span>#end#end</td></tr>#end</table>#end</div><div class="lms-card"><h4>Beat plan</h4>#if($geo && $geo.getStatus() && $geo.getStatus().toString() == "APPROVED")<p style="font-size:13px;margin:0 0 8px;"><span class="lms-pill sla-MET">Geo verified</span></p><a href="#" class="lead" style="font-size:12px;">Add to a beat from the Leads screen →</a>#else<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>#end<div class="lms-note">Check-in at the retailer moves the stage to Visited.</div></div>#if(!$terminal)<div class="lms-card"><h4>Advance stage</h4><select id="advStageSel" class="form-control input-sm" style="margin-bottom:8px;"><option value="" disabled selected>Move to…</option>#set($j = 0)#foreach($st in $stages)#if($j > $stageIndex)<option value="$st.name()">$st.name().replace("_"," ")</option>#end#set($j = $j + 1)#end</select><input id="advComment" class="form-control input-sm" placeholder="Comment (required)" style="margin-bottom:8px;"><button class="btn btn-navy btn-sm" id="advStageBtn" data-leadid="$lead.getId()">Advance</button></div>#end</div></div>## ================= Edit record modal =================## Only the fields the SOP lets a user correct. The id, the created stamp, the creation path and## the SLA clock are shown here read-only so it is obvious they are not up for editing (SOP 7.1).<div class="modal" id="editLeadModal" role="dialog"><div class="modal-dialog"><div class="modal-content lms-record"><div class="modal-header"><button type="button" class="close" data-dismiss="modal">×</button><h4 class="modal-title" style="color:#1f2a5e;">Edit record</h4><div class="lms-note">Every change is written to the trail against your name.</div></div><div class="modal-body"><table class="lms-kv" style="margin-bottom:14px;"><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><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><tr><td class="k">Creation path</td><td>#if($lead.getCreationPath() == "B")B · Field encounter#elseif($lead.getCreationPath() == "A")A · BGC outbound#else—#end <span class="lms-note" style="display:inline">locked</span></td></tr></table><div class="row"><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">Retailer name (required)</label><input id="ed_name" class="form-control input-sm" value="$!lead.getFirstName() $!lead.getLastName()"></div><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">Business name</label><input id="ed_business" class="form-control input-sm" value="$!lead.getOutLetName()" placeholder="Shop / trade name"></div><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">Contact number (required)</label><input id="ed_mobile" class="form-control input-sm" value="$!lead.getLeadMobile()" maxlength="10"></div><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">Business value (₹ lakh / month)</label><input id="ed_potential" type="number" min="0" step="0.5" class="form-control input-sm" value="$!businessValueInput"></div><div class="col-sm-12" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">Address</label><input id="ed_address" class="form-control input-sm" value="$!lead.getAddress()" placeholder="Shop address"></div><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">City</label><input id="ed_city" class="form-control input-sm" value="$!lead.getCity()"></div><div class="col-sm-6" style="margin-bottom:10px;"><label style="font-size:12px;color:#7683a4;">State</label><input id="ed_state" class="form-control input-sm" value="$!lead.getState()"></div><div class="col-sm-12"><label style="font-size:12px;color:#7683a4;">Region</label><select id="ed_region" class="form-control input-sm"><option value="">— Not set —</option>#foreach($r in $regions)<option value="$r.getId()"#if($lead.getRegionId() && $lead.getRegionId() == $r.getId()) selected#end>#if($r.getRegionCode())$r.getRegionCode() · #end$r.getName()</option>#end</select><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></div></div></div><div class="modal-footer"><button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button><button type="button" class="btn btn-navy" id="editSaveBtn" data-leadid="$lead.getId()">Save changes</button></div></div></div></div>## ================= Disposition modal =================<div class="modal" id="dispositionModal" role="dialog"><div class="modal-dialog"><div class="modal-content lms-record"><div class="modal-header"><button type="button" class="close" data-dismiss="modal">×</button><h4 class="modal-title" style="color:#1f2a5e;">Call disposition</h4><div class="lms-note">Live call is bridged through C2C; recording auto-links to the trail.</div></div><div class="modal-body"><label style="font-size:12px;color:#7683a4;">Outcome (required)</label><div class="disp-grid" id="dispGrid">#foreach($d in $dispositions)<div class="disp-opt" data-disp="$d.name()">$d.name().replace("_"," ")</div>#end</div><div id="dispExtra" style="margin-top:12px;"></div><div style="margin-top:12px;"><label style="font-size:12px;color:#7683a4;">Note (attaches to trail)</label><textarea id="dispNote" class="form-control" rows="2"></textarea></div><div style="margin-top:10px;"><label style="font-size:12px;color:#7683a4;">Recording URL (optional)</label><input id="dispRecording" class="form-control input-sm" placeholder="https://…"></div></div><div class="modal-footer"><button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button><button type="button" class="btn btn-coral" id="dispSaveBtn" data-leadid="$lead.getId()">Save disposition</button></div></div></div></div></section><script type="text/javascript">(function () {var leadId = $lead.getId();var selectedDisp = null;// Set by the dialer when it places a call; binds this disposition to that call's row so the// trail can show duration and a recording. Null when the agent dialled from their own handset.var leadCallId = null;// Contextual sub-fields per disposition (SOP §12.2), mirroring the mockup.function renderDispExtra(d) {var h = '';if (d === 'INTERESTED') {h = fld('Confirm monthly business value (₹) *', '<input id="dx_value" type="number" class="form-control input-sm" placeholder="e.g. 85000">');} else if (d === 'CALLBACK') {h = fld('Callback date & time *', '<input id="dx_callback" type="datetime-local" class="form-control input-sm">');} else if (d === 'NOT_REACHABLE') {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>')+ '<div class="lms-note">After 3 unreachable attempts the lead auto-flags as Dropped.</div>';} else if (d === 'WRONG_NUMBER') {h = fld('Corrected number (optional)', '<input id="dx_corrected" class="form-control input-sm" placeholder="10-digit; blank closes the lead">');} else if (d === 'FOLLOW_UP') {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>')+ fld('Follow-up date & time *', '<input id="dx_followup" type="datetime-local" class="form-control input-sm">');} else if (d === 'NOT_INTERESTED') {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>');} else if (d === 'DO_NOT_CALL') {h = '<div class="lms-note" style="color:#f0483e;">Retailer will be permanently blocked and added to the DND list.</div>';}$('#dispExtra').html(h);}function fld(label, input) {return '<div style="margin-bottom:8px;"><label style="font-size:12px;color:#7683a4;">' + label + '</label>' + input + '</div>';}function openDispositionModal() {selectedDisp = null;$('#dispGrid .disp-opt').removeClass('sel');$('#dispExtra').html('');$('#dispNote').val('');$('#dispositionModal').modal({ backdrop: false });}// DND leads have no call button, but an outcome can still arrive by email or in person.$('#lmsLogOutcomeBtn').on('click', openDispositionModal);/* ===================== Web dialer =====================The agent's audio runs through the browser over WebRTC; Vonage bridges it to the retailer'sphone using the NCCO our answer webhook returns. This page only drives the call — it neverdecides who gets dialled, which is settled server-side from the lead record.Degrades on purpose: if the dialer is not configured, or the agent is not provisioned, thecall button falls back to opening the disposition modal exactly as it did before, so anunconfigured environment loses calling but not the ability to record an outcome. */var SDK_URL = 'https://cdn.jsdelivr.net/npm/@vonage/client-sdk@2.6.3/dist/vonageClientSDK.min.js';var sdkPromise = null;var vonageClient = null;var activeCallId = null;var muted = false;var timerHandle = null;var callStartedAt = null;var serverCallActive = false;var pollHandle = null;// Whether the LAST call was server-placed, and whether it was ever actually answered. Kept// separately from serverCallActive because endCall() runs after that has been cleared, and it// needs to know what kind of call just ended.var lastCallServerPlaced = false;var lastCallAnswered = false;// 1.36 MB of SDK. Loaded on first dial rather than on every record view, so agents who never// call never pay for it.function loadSdk() {if (sdkPromise) { return sdkPromise; }sdkPromise = new Promise(function (resolve, reject) {if (window.vonageClientSDK) { resolve(window.vonageClientSDK); return; }var el = document.createElement('script');el.src = SDK_URL;el.async = true;el.onload = function () {if (window.vonageClientSDK) { resolve(window.vonageClientSDK); }else { reject(new Error('Dialer library loaded but did not register')); }};el.onerror = function () { reject(new Error('Could not load the dialer library')); };document.head.appendChild(el);});return sdkPromise;}function bar() { return $('#lmsCallBar'); }function setState(text, cls) {$('#cbState').text(text);bar().removeClass('is-ringing is-active is-ended');if (cls) { bar().addClass(cls); }}function showBar() { bar().show(); $('#cbError').hide().text(''); }function hideBar() { bar().hide(); $('#cbKeypad').hide(); }function barError(msg) { $('#cbError').text(msg).show(); }function startTimer() {callStartedAt = Date.now();stopTimer();timerHandle = setInterval(function () {var s = Math.floor((Date.now() - callStartedAt) / 1000);var m = Math.floor(s / 60);$('#cbTimer').text(('0' + m).slice(-2) + ':' + ('0' + (s % 60)).slice(-2));}, 500);}function stopTimer() {if (timerHandle) { clearInterval(timerHandle); timerHandle = null; }}function jsonFetch(url, options) {// Raw fetch rather than the common.js helpers: those attach a global loader modal and an// alert-on-error to every request, which would flash over the page mid-call.return fetch(context + url, $.extend({ credentials: 'same-origin',headers: { 'X-Requested-With': 'XMLHttpRequest' } }, options || {})).then(function (r) { return r.json(); }).then(function (j) { return (j && j.response !== undefined) ? j.response : j; });}$('#lmsCallBtn').on('click', function () {if (activeCallId) { return; }startCall();});function startCall() {$('#lmsCallBtn').prop('disabled', true);// Server-side gate first: DND and calling hours. A check that only lives in the browser is// not a check, and this also stops us burning an SDK load on a call that cannot be placed.jsonFetch('/lms/dialer/precheck?leadId=' + encodeURIComponent(leadId), { method: 'POST' }).then(function (pre) {if (!pre || !pre.ok) {throw new Error((pre && pre.reason) || 'This lead cannot be called right now');}return jsonFetch('/lms/dialer/token');}).then(function (auth) {if (!auth || !auth.ok) {throw new Error((auth && auth.reason) || 'The dialer is unavailable');}// Two very different providers behind one button. SERVER_PLACED (VBC click2dial)// rings the agent's own desk phone or VBC app and never touches browser audio;// WEBRTC dials from this page. The mode decides which, so the button behaves// correctly whichever platform the account actually has.if (auth.mode === 'SERVER_PLACED') { return placeServerCall(); }return loadSdk().then(function (sdk) { return connect(sdk, auth); });}).catch(function (err) {$('#lmsCallBtn').prop('disabled', false);hideBar();// Falling back to the modal keeps the page useful when calling is unavailable.alert((err && err.message) || 'Could not start the call');openDispositionModal();});}/* ---- server-placed (VBC click2dial) ---- */function placeServerCall() {showBar();lastCallServerPlaced = true;lastCallAnswered = false;setState('Calling your phone…', 'is-ringing');$('#cbTimer').text('00:00');// Mute and DTMF belong to the handset, not to us — hide rather than offer controls that// would silently do nothing.$('#cbMute, #cbKeys, #cbKeypad').hide();return jsonFetch('/lms/dialer/dial?leadId=' + encodeURIComponent(leadId), { method: 'POST' }).then(function (res) {if (!res || !res.ok) { throw new Error((res && res.reason) || 'Could not place the call'); }leadCallId = res.leadCallId;serverCallActive = true;setState('Answer your phone (ext ' + (res.extension || '') + ')…', 'is-ringing');barError('');$('#cbError').hide();pollState();});}function pollState() {if (!serverCallActive || !leadCallId) { return; }jsonFetch('/lms/dialer/state?leadCallId=' + encodeURIComponent(leadCallId)).then(function (st) {if (!serverCallActive) { return; }if (st.status === 'ANSWERED') {lastCallAnswered = true;setState('On call', 'is-active');if (!timerHandle) { startTimer(); }} else if (st.status === 'RINGING') {setState('Ringing the retailer…', 'is-ringing');}if (st.terminal) { serverCallActive = false; endCall('remote'); return; }pollHandle = setTimeout(pollState, 3000);}).catch(function () {// A dropped poll should not kill the call; try again on the next tick.pollHandle = setTimeout(pollState, 5000);});}function connect(sdk, auth) {showBar();lastCallServerPlaced = false;setState('Connecting…', null);$('#cbTimer').text('00:00');vonageClient = new sdk.VonageClient();wireClientEvents();return vonageClient.createSession(auth.token).then(function () {// leadId travels in the call context; Vonage hands it back to the answer webhook as// custom_data, which is where the lead_call row is created. The browser cannot know// that row's id up front, so it asks for it once the call ends.return vonageClient.serverCall({ leadId: String(leadId) });}).then(function (callId) {activeCallId = callId;setState('Ringing…', 'is-ringing');}).catch(function (err) {var msg = (err && err.message) || 'Could not connect';// A refused mic is the single most common failure and needs its own wording.if (err && (err.name === 'NotAllowedError' || /permission|denied/i.test(msg))) {msg = 'Microphone access was blocked — allow it in the browser and try again';}if (!window.isSecureContext) {msg = 'Calling needs a secure (https) connection';}setState('Failed', 'is-ended');barError(msg);$('#lmsCallBtn').prop('disabled', false);throw err;});}function wireClientEvents() {vonageClient.on('legStatusUpdate', function (callId, legId, status) {var s = ('' + status).toLowerCase();if (s.indexOf('answer') !== -1 || s === 'active') {setState('On call', 'is-active');if (!timerHandle) { startTimer(); }} else if (s.indexOf('ring') !== -1) {setState('Ringing…', 'is-ringing');}});vonageClient.on('callHangup', function (callId, callQuality, reason) {endCall(reason);});vonageClient.on('sessionError', function (err) {barError('Call session error: ' + ((err && err.message) || err));// Only tear down if a call was actually up; a session error before dialling should not// pop the disposition modal for a conversation that never happened.if (activeCallId) { endCall('session-error'); }else { setState('Failed', 'is-ended'); $('#lmsCallBtn').prop('disabled', false); }});}function endCall(reason) {stopTimer();if (pollHandle) { clearTimeout(pollHandle); pollHandle = null; }serverCallActive = false;activeCallId = null;muted = false;$('#cbMute').html('<i class="fa fa-microphone"></i> Mute');$('#lmsCallBtn').prop('disabled', false);// A server-placed call that was never answered is not a conversation, so do not ask the// agent to disposition one. This is the common failure here: VBC accepts the call and// returns an id, but never reaches the agent's phone, and the server writes the row off as// FAILED after vonage.vbc.initiate.grace.seconds. Dispositioning that would put an outcome// like "Not interested" on a retailer nobody ever spoke to.if (lastCallServerPlaced && !lastCallAnswered) {setState('Could not reach your phone', 'is-ended');barError('Your phone never rang, so the retailer was not dialled. '+ 'Check you are signed in to the commsSetup app, then try again.');return;}setState('Call ended', 'is-ended');// The call row is created by the answer webhook, so its id is only knowable after the fact.// Fetch it, then open the disposition modal bound to that call.jsonFetch('/lms/dialer/call?leadId=' + encodeURIComponent(leadId)).then(function (res) {if (res && res.found) { leadCallId = res.leadCallId; }}).catch(function () { /* an unbound disposition still saves; it just has no call link */ }).then(function () {hideBar();openDispositionModal();});}$('#cbHangup').on('click', function () {if (vonageClient && activeCallId) {vonageClient.hangup(activeCallId).catch(function () { /* already gone */ });} else if (leadCallId) {// Server-placed: the call lives at the provider, so the server has to end it.jsonFetch('/lms/dialer/hangup?leadCallId=' + encodeURIComponent(leadCallId),{ method: 'POST' }).catch(function () { /* already ended */ });}endCall('local-hangup');});$('#cbMute').on('click', function () {if (!vonageClient || !activeCallId) { return; }var op = muted ? vonageClient.unmute(activeCallId) : vonageClient.mute(activeCallId);op.then(function () {muted = !muted;$('#cbMute').html(muted? '<i class="fa fa-microphone-slash"></i> Unmute': '<i class="fa fa-microphone"></i> Mute');}).catch(function () { barError('Could not change the microphone'); });});$('#cbKeys').on('click', function () {var pad = $('#cbKeypad');if (!pad.children().length) {var keys = ['1','2','3','4','5','6','7','8','9','*','0','#'];pad.html(keys.map(function (k) {return '<button type="button" data-dtmf="' + k + '">' + k + '</button>';}).join(''));}pad.toggle();});$(document).on('click', '#cbKeypad button[data-dtmf]', function () {if (!vonageClient || !activeCallId) { return; }vonageClient.sendDTMF(activeCallId, $(this).data('dtmf') + '').catch(function () { barError('Could not send the tone'); });});// A closed tab must not leave a WebRTC call up on the retailer's end. A server-placed call is// on the agent's actual handset and is theirs to end, so closing the tab must NOT drop it.$(window).on('beforeunload', function () {if (vonageClient && activeCallId) {try { vonageClient.hangup(activeCallId); } catch (e) { /* best effort on unload */ }}});$(document).on('click', '#dispGrid .disp-opt', function () {$('#dispGrid .disp-opt').removeClass('sel');$(this).addClass('sel');selectedDisp = $(this).data('disp');renderDispExtra(selectedDisp);});$('#dispSaveBtn').on('click', function () {if (!selectedDisp) { alert('Pick a disposition'); return; }var payload = {leadId: leadId,disposition: selectedDisp,note: $('#dispNote').val(),leadCallId: leadCallId,recordingUrl: $('#dispRecording').val()};if (selectedDisp === 'INTERESTED') {var v = parseFloat($('#dx_value').val());if (!v || v <= 0) { alert('Business value is required'); return; }payload.value = v;} else if (selectedDisp === 'CALLBACK') {if (!$('#dx_callback').val()) { alert('Callback date/time is required'); return; }payload.callbackAt = $('#dx_callback').val();} else if (selectedDisp === 'NOT_REACHABLE') {payload.retrySchedule = $('#dx_retry').val();} else if (selectedDisp === 'WRONG_NUMBER') {payload.correctedNumber = $('#dx_corrected').val();} else if (selectedDisp === 'FOLLOW_UP') {if (!$('#dx_followup').val()) { alert('Follow-up date/time is required'); return; }payload.followUpType = $('#dx_futype').val();payload.followUpAt = $('#dx_followup').val();} else if (selectedDisp === 'NOT_INTERESTED') {payload.subReason = $('#dx_subreason').val();}doPostAjaxRequestWithJsonHandler(context + '/lms/disposition', JSON.stringify(payload), function () {$('#dispositionModal').modal('hide');if (typeof showToast === 'function') { showToast('Disposition saved: ' + selectedDisp); }reloadRecord();});});$('#lmsEditBtn').on('click', function () {$('#editLeadModal').modal({ backdrop: false });});$('#editSaveBtn').on('click', function () {var name = ($('#ed_name').val() || '').trim();if (!name) { alert('Retailer name is required'); return; }var mobile = ($('#ed_mobile').val() || '').replace(/\D/g, '');if (mobile.length < 10) { alert('A valid 10-digit contact number is required'); return; }var region = $('#ed_region').val();var potential = parseFloat($('#ed_potential').val());var payload = {leadId: leadId,retailerName: name,businessName: $('#ed_business').val(),mobile: mobile,address: $('#ed_address').val(),city: $('#ed_city').val(),state: $('#ed_state').val(),regionId: region ? parseInt(region, 10) : null,potential: isNaN(potential) ? null : potential};doPostAjaxRequestWithJsonHandler(context + '/lms/updateLead', JSON.stringify(payload), function () {$('#editLeadModal').modal('hide');if (typeof showToast === 'function') { showToast('Record updated'); }reloadRecord();});});$('#advStageBtn').on('click', function () {var to = $('#advStageSel').val();var comment = $('#advComment').val();if (!to) { alert('Choose a stage'); return; }if (!comment || !comment.trim()) { alert('A comment is required'); return; }doPostAjaxRequestWithParamsHandler(context + '/lms/advanceStage',{ leadId: leadId, toStage: to, comment: comment }, function () {if (typeof showToast === 'function') { showToast('Stage advanced to ' + to); }reloadRecord();});});function reloadRecord() {doGetAjaxRequestHandler(context + '/leadRecord?leadId=' + leadId, function (r) {$('#main-content').html(r);});}})();</script>