| 37125 |
vikas |
1 |
## LMS Dashboard — STANDALONE full page. Faithful copy of sts-mockup.html design (app bar + tabs + hand-drawn
|
|
|
2 |
## funnel/heat-map, NO charts), wired to REAL lead data. Rendered as a complete HTML doc (layout.vm = $screen_content).
|
| 37749 |
vikas |
3 |
## Served at GET /lms/dashboard. Seeds: regions[], summaryJson, stateFunnelJson, slaHeatmapJson, ageingJson, fromDate, toDate,
|
|
|
4 |
## viewerName / viewerRole / viewerInitials (signed-in user for the app bar, pre-escaped).
|
| 37125 |
vikas |
5 |
<!DOCTYPE html>
|
|
|
6 |
<html lang="en">
|
|
|
7 |
<head>
|
|
|
8 |
<meta charset="utf-8">
|
|
|
9 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
10 |
<title>LMS — Lead Dashboard</title>
|
|
|
11 |
<style>
|
|
|
12 |
:root{
|
|
|
13 |
--bg:#eef1f7; --card:#ffffff; --ink:#1d2433; --muted:#69748c; --soft:#97a1b5;
|
|
|
14 |
--line:#e6e9f1; --line2:#eef1f6;
|
|
|
15 |
--navy:#1f2a5e; --navy2:#2f3d78;
|
|
|
16 |
--brand:#3b5bdb; --brand2:#5c7cfa; --teal:#0ca678; --cyan:#1098ad;
|
|
|
17 |
--amber:#f59f00; --orange:#e8590c; --red:#e03131; --coral:#f0483e; --violet:#7048e8; --pink:#d6336c;
|
|
|
18 |
--good:#2f9e44; --warn:#f08c00; --bad:#e03131;
|
|
|
19 |
}
|
|
|
20 |
*{box-sizing:border-box}
|
|
|
21 |
html,body{margin:0;background:var(--bg);color:var(--ink);
|
|
|
22 |
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;font-size:14px}
|
|
|
23 |
.wrap{max-width:1340px;margin:0 auto;padding:0 24px 90px}
|
|
|
24 |
a{color:var(--brand);text-decoration:none}
|
|
|
25 |
code,.mono{font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
|
26 |
|
|
|
27 |
/* ===== app bar ===== */
|
|
|
28 |
.appbar{position:sticky;top:0;z-index:40;background:var(--navy);color:#fff;margin:0;padding:0 24px}
|
|
|
29 |
.appbar .inner{max-width:1340px;margin:0 auto;display:flex;align-items:center;gap:18px;height:58px}
|
|
|
30 |
.logo{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:-.01em;font-size:16px}
|
|
|
31 |
.logo .mk{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--coral),#ff7a5c);display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800}
|
|
|
32 |
.logo small{display:block;font-size:10px;font-weight:600;color:#aab3d6;letter-spacing:.06em}
|
|
|
33 |
.search{flex:1;max-width:560px;position:relative}
|
|
|
34 |
.search input{width:100%;background:#2b3768;border:1px solid #3a488a;color:#fff;border-radius:9px;padding:9px 12px 9px 34px;font-size:13px;font-weight:500}
|
|
|
35 |
.search input::placeholder{color:#9aa4cc}
|
|
|
36 |
.search .si{position:absolute;left:11px;top:9px;color:#9aa4cc;font-size:14px}
|
|
|
37 |
.search .hint{position:absolute;right:10px;top:8px;font-size:10px;color:#9aa4cc;border:1px solid #3a488a;border-radius:5px;padding:2px 6px}
|
|
|
38 |
.abtn{background:#2b3768;border:1px solid #3a488a;color:#fff;border-radius:9px;padding:8px 11px;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px}
|
|
|
39 |
.abtn.coral{background:var(--coral);border-color:var(--coral)}
|
|
|
40 |
.role{display:flex;align-items:center;gap:9px}
|
|
|
41 |
.role .av{width:31px;height:31px;border-radius:50%;background:#3a488a;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px}
|
|
|
42 |
.role .who{font-size:12px;line-height:1.25}
|
|
|
43 |
.role .who b{font-weight:700}
|
|
|
44 |
.role .who span{color:#aab3d6}
|
|
|
45 |
|
|
|
46 |
/* ===== tabs ===== */
|
|
|
47 |
.tabbar{position:sticky;top:58px;z-index:39;background:var(--card);border-bottom:1px solid var(--line);margin:0 0 20px;padding:0 24px;box-shadow:0 1px 2px rgba(20,30,60,.03)}
|
|
|
48 |
.tabbar .inner{max-width:1340px;margin:0 auto;display:flex;gap:2px;overflow-x:auto}
|
|
|
49 |
.tab{padding:13px 4px;margin-right:24px;font-size:13.5px;font-weight:650;color:var(--muted);cursor:pointer;border:0;background:transparent;border-bottom:2.5px solid transparent;margin-bottom:-1px;white-space:nowrap}
|
|
|
50 |
.tab:hover{color:var(--ink)}
|
|
|
51 |
.tab.active{color:var(--navy);border-bottom-color:var(--coral)}
|
|
|
52 |
|
|
|
53 |
/* ===== generic ===== */
|
|
|
54 |
.crumb{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
55 |
h1.page{margin:2px 0 2px;font-size:23px;font-weight:760;letter-spacing:-.01em}
|
| 37784 |
vikas |
56 |
/* Title block and filters share one line: the title block shrinks (its subtitle wraps) rather than
|
|
|
57 |
pushing the filters onto a second row. Only a narrow viewport is allowed to wrap. */
|
|
|
58 |
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:nowrap;margin-bottom:18px}
|
|
|
59 |
.pagehead > div:first-child{min-width:0;flex:1 1 auto}
|
|
|
60 |
.pagehead .hd-right{flex:0 0 auto}
|
|
|
61 |
.pagehead .hd-right select.ctl{max-width:200px;text-overflow:ellipsis}
|
|
|
62 |
@media (max-width:1100px){ .pagehead{flex-wrap:wrap} }
|
| 37125 |
vikas |
63 |
.pagehead .sub{color:var(--muted);font-size:13px;margin-top:2px}
|
| 37784 |
vikas |
64 |
/* When the filter row wraps under the title it must still hug the right edge, not restart at the left. */
|
|
|
65 |
.hd-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto}
|
| 37125 |
vikas |
66 |
.ctl{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;font-size:13px;color:var(--ink);font-weight:600}
|
|
|
67 |
.ctl .lbl{color:var(--soft);font-weight:500;margin-right:5px}
|
|
|
68 |
select.ctl{appearance:none;cursor:pointer;padding-right:26px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 10px center}
|
|
|
69 |
.btn{background:var(--navy);border:0;color:#fff;font-weight:650;padding:9px 15px;border-radius:9px;cursor:pointer;font-size:13px}
|
|
|
70 |
.btn.coral{background:var(--coral)}
|
|
|
71 |
.btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line)}
|
|
|
72 |
.btn.sm{padding:6px 11px;font-size:12px}
|
|
|
73 |
|
|
|
74 |
.sectionlabel{font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);font-weight:700;margin:24px 2px 12px}
|
|
|
75 |
.grid{display:grid;gap:16px}
|
|
|
76 |
.g2{grid-template-columns:1fr 1fr}
|
|
|
77 |
.g3{grid-template-columns:1.6fr 1fr}
|
|
|
78 |
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:17px 19px;margin-bottom:16px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
|
|
|
79 |
.card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}
|
|
|
80 |
.card .desc{color:var(--muted);font-size:12.5px;margin:0 0 14px}
|
|
|
81 |
|
|
|
82 |
/* kpis */
|
|
|
83 |
.kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:8px}
|
|
|
84 |
.kpi{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 15px;box-shadow:0 1px 2px rgba(20,30,60,.04)}
|
|
|
85 |
.kpi .top{display:flex;align-items:center;gap:8px}
|
|
|
86 |
.kpi .ic{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
87 |
.kpi .label{font-size:11.5px;color:var(--muted);font-weight:600;line-height:1.2}
|
|
|
88 |
.kpi .val{font-size:26px;font-weight:760;margin-top:9px;letter-spacing:-.02em;line-height:1}
|
|
|
89 |
.kpi .val small{font-size:13px;color:var(--soft);font-weight:600}
|
|
|
90 |
.kpi .foot{font-size:11.5px;color:var(--soft);margin-top:7px}
|
|
|
91 |
|
|
|
92 |
/* funnel */
|
|
|
93 |
.funnel{display:flex;gap:8px;align-items:stretch;flex-wrap:wrap}
|
|
|
94 |
.fstep{flex:1;min-width:118px;border:1px solid var(--line);border-radius:11px;padding:12px 11px;position:relative;text-align:center;background:#fbfcfe}
|
|
|
95 |
.fstep .fn{font-size:10.5px;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:700}
|
|
|
96 |
.fstep .fv{font-size:24px;font-weight:770;margin-top:6px}
|
|
|
97 |
.fstep .fbar{height:5px;border-radius:3px;margin-top:9px;background:linear-gradient(90deg,var(--navy),var(--brand))}
|
|
|
98 |
.fstep .drop{font-size:10.5px;color:var(--red);margin-top:6px;font-weight:600}
|
|
|
99 |
.fstep .keep{font-size:10.5px;color:var(--good);margin-top:6px;font-weight:600}
|
| 37783 |
vikas |
100 |
.fstep .fcap[data-drop]{cursor:pointer;text-decoration:underline dotted;text-underline-offset:2px}
|
|
|
101 |
.fstep .fcap[data-drop]:hover{text-decoration-style:solid}
|
| 37125 |
vikas |
102 |
.fstep .arrow{position:absolute;right:-11px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:14px}
|
|
|
103 |
|
|
|
104 |
/* table */
|
|
|
105 |
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
|
106 |
th,td{text-align:left;padding:9px 9px;border-bottom:1px solid var(--line2)}
|
|
|
107 |
th{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700;white-space:nowrap}
|
|
|
108 |
th.num,td.num{text-align:right}
|
|
|
109 |
tbody tr:hover{background:#f7f9fd}
|
|
|
110 |
.clickrow{cursor:pointer}
|
|
|
111 |
.rowwarn{background:#fff9f0}
|
|
|
112 |
.rowbad{background:#fff5f5}
|
|
|
113 |
.warnflag{color:var(--orange);font-weight:700}
|
|
|
114 |
.badflag{color:var(--red);font-weight:700}
|
|
|
115 |
|
|
|
116 |
/* pills / stages */
|
|
|
117 |
.idpill{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;font-weight:700;color:var(--navy);background:#eef1fb;border:1px solid #dfe4f7;border-radius:6px;padding:2px 7px;white-space:nowrap}
|
|
|
118 |
.stage{display:inline-flex;align-items:center;gap:6px;font-weight:700;font-size:11px;padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
|
|
|
119 |
.st-new{background:#eef1fb;color:#3b5bdb}
|
|
|
120 |
.st-assigned{background:#e7f5ff;color:#1971c2}
|
|
|
121 |
.st-contacted{background:#e3fafc;color:#0c8599}
|
|
|
122 |
.st-qualified{background:#e6fcf5;color:#0ca678}
|
|
|
123 |
.st-beat{background:#f3f0ff;color:#7048e8}
|
|
|
124 |
.st-visited{background:#fff0f6;color:#c2255c}
|
|
|
125 |
.st-onboarded{background:#ebfbee;color:#2f9e44}
|
|
|
126 |
.st-active{background:#d3f9d8;color:#2b8a3e}
|
|
|
127 |
.st-ni{background:#fff4e6;color:#e8590c}
|
|
|
128 |
.st-dead{background:#f1f3f5;color:#868e96}
|
|
|
129 |
.tag{display:inline-block;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px}
|
|
|
130 |
.tag.a{background:#fff0ef;color:var(--coral)}
|
|
|
131 |
.tag.b{background:#e6fcf5;color:var(--teal)}
|
|
|
132 |
|
|
|
133 |
/* SLA pills */
|
|
|
134 |
.sla{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700}
|
|
|
135 |
.sla .clk{font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
|
136 |
.sla.ok{color:var(--good)} .sla.warn{color:var(--orange)} .sla.bad{color:var(--red)}
|
|
|
137 |
.slabar{display:inline-block;width:52px;height:6px;border-radius:4px;background:#eef1f6;overflow:hidden;vertical-align:middle}
|
|
|
138 |
.slabar>i{display:block;height:100%}
|
|
|
139 |
|
|
|
140 |
/* attention */
|
|
|
141 |
.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}
|
| 37692 |
vikas |
142 |
/* Across the top rather than down a narrow column, so the nine-column table gets the full width. */
|
|
|
143 |
.attstrip{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
|
|
|
144 |
.attstrip .att{margin-bottom:0}
|
|
|
145 |
.attstrip .atitle{font-size:12.5px;line-height:1.25}
|
|
|
146 |
.attstrip .adet{font-size:11.5px;margin-top:1px}
|
|
|
147 |
/* Wide content scrolls inside its own card, never the page body. */
|
|
|
148 |
.pipewrap{overflow-x:auto}
|
|
|
149 |
/* table-layout:fixed + a colgroup so the columns keep sane proportions on a wide screen instead
|
|
|
150 |
of the browser handing City and Created-by a third of the table each. Business/Retailer takes
|
|
|
151 |
the slack because it is the column people actually read. */
|
|
|
152 |
.pipewrap table{min-width:1040px;table-layout:fixed}
|
|
|
153 |
.pipewrap td{vertical-align:middle;overflow:hidden;text-overflow:ellipsis}
|
|
|
154 |
.pipewrap td.wrapok{white-space:normal}
|
|
|
155 |
.pipewrap td.nowrap{white-space:nowrap}
|
| 37125 |
vikas |
156 |
.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
|
|
157 |
.att .ab{flex:1}
|
|
|
158 |
.att .atitle{font-size:13px;font-weight:650}
|
|
|
159 |
.att .adet{font-size:12px;color:var(--muted);margin-top:2px}
|
|
|
160 |
.att .acount{font-size:18px;font-weight:780}
|
|
|
161 |
|
|
|
162 |
/* tiles */
|
|
|
163 |
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}
|
|
|
164 |
.tile{background:#f8f9fc;border:1px solid var(--line2);border-radius:11px;padding:12px}
|
|
|
165 |
.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
|
|
166 |
.tile .v{font-size:21px;font-weight:760;margin-top:5px}
|
|
|
167 |
.tile .v small{font-size:12px;color:var(--soft);font-weight:600}
|
|
|
168 |
|
|
|
169 |
.hide{display:none !important}
|
| 37737 |
vikas |
170 |
/* Liveness reads better as a ticking clock than as a boxed date field: the board is always
|
|
|
171 |
today, and a timestamp that never visibly moves looks stale rather than live. */
|
|
|
172 |
.liveclock{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--soft);font-weight:600}
|
| 37743 |
vikas |
173 |
/* A readout, not a control: the dates are picked on the Command Center filter bar. */
|
|
|
174 |
.periodtag{display:inline-flex;align-items:baseline;gap:7px;font-size:12.5px;color:var(--soft);font-weight:600}
|
|
|
175 |
.periodtag .pl{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:700}
|
|
|
176 |
.periodtag #dashPeriod{color:var(--ink);font-weight:650;white-space:nowrap}
|
| 37737 |
vikas |
177 |
.livedot{width:7px;height:7px;border-radius:50%;background:#2f9e44;flex:none}
|
|
|
178 |
.livedot.beat{animation:livePulse .9s ease-out}
|
|
|
179 |
.livedot.stalled{background:#e03131}
|
|
|
180 |
@keyframes livePulse{0%{box-shadow:0 0 0 0 rgba(47,158,68,.55)}100%{box-shadow:0 0 0 7px rgba(47,158,68,0)}}
|
|
|
181 |
@media (prefers-reduced-motion:reduce){ .livedot.beat{animation:none} }
|
|
|
182 |
.callbar{position:fixed;right:18px;bottom:18px;z-index:88;width:280px;background:#fff;border:1px solid var(--line);
|
|
|
183 |
border-radius:14px;box-shadow:0 18px 44px rgba(10,15,40,.22);padding:14px 16px}
|
|
|
184 |
.callbar .cb-head{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--brand)}
|
|
|
185 |
.callbar .cb-dot{width:9px;height:9px;border-radius:50%;background:#e03131;flex:none;animation:cbPulse 1.4s infinite}
|
|
|
186 |
.callbar .cb-time{margin-left:auto;color:var(--soft);font-weight:600;font-size:12.5px}
|
|
|
187 |
.callbar .cb-state{margin-top:6px;font-size:12.5px;color:var(--soft);text-transform:capitalize}
|
|
|
188 |
.callbar .cb-acts{margin-top:11px;display:flex;gap:7px}
|
|
|
189 |
.callbar .cb-err{margin-top:8px;color:#e03131;font-size:12px}
|
|
|
190 |
@keyframes cbPulse{0%{opacity:1}50%{opacity:.35}100%{opacity:1}}
|
|
|
191 |
@media (prefers-reduced-motion:reduce){ .callbar .cb-dot{animation:none} }
|
|
|
192 |
/* Call button on a pipeline row. */
|
|
|
193 |
.chkq{padding:12px 0;border-bottom:1px solid var(--line2)}
|
|
|
194 |
.chkq:last-child{border-bottom:0}
|
|
|
195 |
.chkq .qp{font-size:13px;font-weight:600;color:var(--ink);margin-bottom:7px}
|
|
|
196 |
.chkq .qp .req{color:#e03131;margin-left:3px}
|
|
|
197 |
.chkq .opts{display:flex;gap:7px;flex-wrap:wrap}
|
|
|
198 |
.chkopt{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 13px;font-size:12.5px;
|
|
|
199 |
cursor:pointer;font-weight:600;color:var(--soft)}
|
|
|
200 |
.chkopt.sel{background:#1f2a5e;border-color:#1f2a5e;color:#fff}
|
|
|
201 |
.rowcall{border:1px solid var(--line);background:#fff;color:#e03131;border-radius:8px;padding:4px 9px;
|
|
|
202 |
cursor:pointer;font-size:13px;line-height:1.1}
|
|
|
203 |
.rowcall:hover{background:#fff0f0;border-color:#ffc9c9}
|
|
|
204 |
.rowcall[disabled]{opacity:.45;cursor:not-allowed}
|
|
|
205 |
.rowedit{border:1px solid var(--line);background:#fff;color:var(--brand2);border-radius:8px;padding:4px 9px;
|
|
|
206 |
cursor:pointer;font-size:13px;line-height:1.1}
|
|
|
207 |
.rowedit:hover{background:#eef1fb;border-color:#c7d2fe}
|
|
|
208 |
.rowassign{border:1px solid var(--line);background:#fff;color:#0ca678;border-radius:8px;padding:4px 9px;
|
|
|
209 |
cursor:pointer;font-size:13px;line-height:1.1}
|
|
|
210 |
.rowassign:hover{background:#e6fcf5;border-color:#96f2d7}
|
|
|
211 |
.tierpill{display:inline-block;min-width:26px;text-align:center;padding:2px 7px;border-radius:6px;
|
|
|
212 |
background:#eef1fb;color:#3b5bdb;font-size:11px;font-weight:700;margin-right:8px}
|
|
|
213 |
.asgrow{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);
|
|
|
214 |
border-radius:10px;margin-bottom:6px;cursor:pointer}
|
|
|
215 |
.asgrow:hover{background:#f8f9fc;border-color:var(--brand2)}
|
|
|
216 |
.asgrow.covers{border-color:#96f2d7;background:#f4fffb}
|
|
|
217 |
.asgrow .nm{font-weight:650;font-size:13px}
|
|
|
218 |
.asgrow .rg{margin-left:auto;font-size:11px;color:var(--soft)}
|
|
|
219 |
.lstat{display:inline-block;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.02em}
|
|
|
220 |
.lstat.oncall{background:#e6fcf5;color:#0ca678}
|
|
|
221 |
.lstat.wrap{background:#eef1f6;color:#69748c}
|
|
|
222 |
.lstat.idle{background:#fff4e6;color:#e8590c}
|
|
|
223 |
.lstat.avail{background:#eef1fb;color:#3b5bdb}
|
|
|
224 |
.lstat.off{background:#f1f3f5;color:#97a1b5}
|
|
|
225 |
.lstale{color:#e03131;font-size:11px;margin-left:6px}
|
| 37692 |
vikas |
226 |
th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
|
|
|
227 |
th.sortable:hover{color:var(--brand2)}
|
|
|
228 |
th.sortable.on{color:var(--brand2)}
|
|
|
229 |
/* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
|
|
|
230 |
th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
|
|
231 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
|
|
232 |
th.sortable .arrow.up{border-bottom:5px solid currentColor}
|
|
|
233 |
th.sortable .arrow.down{border-top:5px solid currentColor}
|
| 37743 |
vikas |
234 |
/* Generic table search + sort, attached to every list table that does not sort server-side.
|
|
|
235 |
Deliberately NOT the .sortable class: renderPipeHead() rewrites every th.sortable in the
|
|
|
236 |
document, so sharing it would have the pipeline stamping its arrows onto these headers. */
|
|
|
237 |
th.tsort{cursor:pointer;user-select:none;white-space:nowrap}
|
|
|
238 |
th.tsort:hover{color:var(--brand2)}
|
|
|
239 |
th.tsort.on{color:var(--brand2)}
|
|
|
240 |
th.tsort .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
|
|
241 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
|
|
242 |
th.tsort .arrow.up{border-bottom:5px solid currentColor}
|
|
|
243 |
th.tsort .arrow.down{border-top:5px solid currentColor}
|
|
|
244 |
.tstools{display:flex;align-items:center;gap:9px;margin:0 0 10px;flex-wrap:wrap}
|
|
|
245 |
.tstools .inp{width:210px;font-size:12.5px;padding:7px 10px}
|
|
|
246 |
.tstools .tscount{font-size:11.5px;color:var(--soft);font-weight:600;font-variant-numeric:tabular-nums}
|
|
|
247 |
.tsempty{font-size:12.5px;color:var(--soft);font-style:italic;padding:14px 0;text-align:center}
|
|
|
248 |
|
| 37692 |
vikas |
249 |
/* Field label outside the filter bar (.ctl .lbl is scoped to it) + the record-action modals. */
|
|
|
250 |
.lbl{display:block;font-size:12px;font-weight:600;color:var(--soft);margin:10px 0 4px}
|
|
|
251 |
.modal{position:fixed;inset:0;z-index:90;background:rgba(14,18,40,.44);display:flex;align-items:flex-start;
|
|
|
252 |
justify-content:center;padding:6vh 16px;overflow:auto}
|
|
|
253 |
.modal .box{background:#fff;border-radius:14px;width:min(640px,100%);box-shadow:0 26px 70px rgba(10,15,40,.3)}
|
|
|
254 |
.modal .mh{padding:18px 22px 0}
|
|
|
255 |
.modal .mh h2{margin:0;font-size:17px;color:var(--brand)}
|
|
|
256 |
.modal .mb{padding:4px 22px 18px}
|
|
|
257 |
.modal .mf{padding:14px 22px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}
|
|
|
258 |
.dispgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:6px}
|
| 37125 |
vikas |
259 |
|
|
|
260 |
/* interactivity */
|
|
|
261 |
.kpi,.fstep,.att,#dash tbody tr,.heat td{cursor:pointer}
|
|
|
262 |
.kpi,.fstep{transition:transform .12s,box-shadow .12s}
|
|
|
263 |
.kpi:hover,.fstep:hover{box-shadow:0 4px 14px rgba(20,30,60,.10);transform:translateY(-1px)}
|
|
|
264 |
.att:hover{border-color:#c7d0e8}
|
|
|
265 |
|
|
|
266 |
.drawer-ov{position:fixed;inset:0;background:rgba(20,26,50,.42);z-index:70;opacity:0;pointer-events:none;transition:opacity .2s}
|
|
|
267 |
.drawer-ov.open{opacity:1;pointer-events:auto}
|
| 37692 |
vikas |
268 |
.drawer{position:fixed;top:0;right:0;height:100%;width:min(680px,96vw);background:#f7f8fc;z-index:71;box-shadow:-14px 0 46px rgba(10,15,40,.22);transform:translateX(100%);transition:transform .26s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column}
|
| 37125 |
vikas |
269 |
.drawer.open{transform:none}
|
|
|
270 |
.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}
|
|
|
271 |
.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}
|
|
|
272 |
.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}
|
|
|
273 |
.drawer .dsub{color:var(--muted);font-size:12.5px}
|
| 37743 |
vikas |
274 |
/* The title block takes the slack, so the tools slot and the close button sit against the right
|
|
|
275 |
edge instead of two competing auto margins splitting the gap between them. */
|
|
|
276 |
.drawer .dhmain{flex:1;min-width:0}
|
|
|
277 |
.drawer .dtools{flex:none;align-self:center}
|
|
|
278 |
.drawer .dtools .tstools{margin:0}
|
|
|
279 |
.drawer .dtools .tstools .inp{width:190px}
|
| 37125 |
vikas |
280 |
.drawer .x{margin-left:auto;cursor:pointer;font-size:22px;line-height:1;color:var(--soft);background:none;border:0;padding:0 2px}
|
|
|
281 |
.drawer .dbody{padding:16px 18px 40px;overflow:auto;flex:1}
|
|
|
282 |
.drawer .card{margin-bottom:12px}
|
|
|
283 |
.dstat{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:12px}
|
|
|
284 |
.dstat .box{background:#fff;border:1px solid var(--line);border-radius:11px;padding:11px 12px}
|
|
|
285 |
.dstat .t{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700}
|
|
|
286 |
.dstat .v{font-size:20px;font-weight:770;margin-top:3px}
|
|
|
287 |
.dnote{font-size:12px;color:var(--muted);line-height:1.55;margin-top:8px}
|
|
|
288 |
|
|
|
289 |
.toastwrap{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:90;display:flex;flex-direction:column;gap:8px;align-items:center}
|
|
|
290 |
.toast{background:#1f2a5e;color:#fff;padding:11px 16px;border-radius:11px;font-size:13px;font-weight:600;box-shadow:0 10px 34px rgba(10,15,40,.32);display:flex;align-items:center;gap:10px;animation:tin .18s ease-out}
|
|
|
291 |
.toast .ok{color:#69db7c;font-weight:800}
|
|
|
292 |
@keyframes tin{from{opacity:0;transform:translateY(10px)}}
|
|
|
293 |
|
|
|
294 |
/* heatmap */
|
|
|
295 |
.heat td{width:34px;height:30px;text-align:center;font-size:11px;font-weight:700;color:#fff;border:2px solid #fff;border-radius:6px}
|
|
|
296 |
|
|
|
297 |
/* callout */
|
|
|
298 |
.callout{background:linear-gradient(180deg,#fbfcff,#f4f7ff);border:1px solid #dfe6fb;border-radius:14px;padding:18px 20px;margin-top:6px}
|
|
|
299 |
.callout h2{margin:0 0 3px;font-size:16px}
|
|
|
300 |
.fix{display:flex;gap:13px;padding:13px 0;border-bottom:1px solid #e6ebf8}
|
|
|
301 |
.fix:last-child{border-bottom:0}
|
|
|
302 |
.fix .sev{width:6px;border-radius:4px;flex:none}
|
|
|
303 |
.fix .fb{flex:1}
|
|
|
304 |
.fix .ft{font-size:14px;font-weight:700}
|
|
|
305 |
.fix .fd{font-size:13px;color:#3a4459;margin-top:4px;line-height:1.5}
|
|
|
306 |
.fix .fd b{color:var(--ink)}
|
|
|
307 |
.fix .act{font-size:12.5px;color:var(--good);margin-top:6px;font-weight:650}
|
|
|
308 |
|
|
|
309 |
.note{font-size:11.5px;color:var(--soft);margin-top:10px;line-height:1.6}
|
|
|
310 |
.ph{font-size:12.5px;color:var(--soft);font-style:italic;padding:16px 0;text-align:center}
|
|
|
311 |
.rbadge{font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:10px;margin-left:6px;vertical-align:middle}
|
|
|
312 |
.rbadge.live{background:#e6f7ee;color:var(--good)}.rbadge.partial{background:#fff3dc;color:var(--warn)}.rbadge.soon{background:#eef0f5;color:var(--soft)}
|
|
|
313 |
|
|
|
314 |
/* record / kv / trail */
|
|
|
315 |
.kv{display:grid;grid-template-columns:130px 1fr;gap:7px 12px;font-size:13px}
|
|
|
316 |
.kv .k{color:var(--soft);font-weight:600}
|
|
|
317 |
.kv .v{font-weight:600}
|
|
|
318 |
.divider{height:1px;background:var(--line);margin:14px 0}
|
|
|
319 |
.recordHost{min-height:120px}
|
|
|
320 |
.lookup{display:flex;gap:8px;align-items:center;margin-bottom:14px}
|
|
|
321 |
.lookup input{border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:13px;width:280px}
|
|
|
322 |
|
|
|
323 |
/* form */
|
|
|
324 |
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px}
|
|
|
325 |
.fld{display:flex;flex-direction:column;gap:5px}
|
|
|
326 |
.fld.full{grid-column:1/3}
|
|
|
327 |
.fld label{font-size:11.5px;font-weight:700;color:var(--muted)}
|
|
|
328 |
.fld label .m{color:var(--red)}
|
|
|
329 |
.fld label .auto{float:right;font-size:10px;font-weight:700;color:var(--brand);background:#eef1fb;border-radius:5px;padding:1px 6px;text-transform:uppercase;letter-spacing:.03em}
|
|
|
330 |
.inp{border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:13px;font-weight:500;background:#fff;color:var(--ink);width:100%}
|
|
|
331 |
.inp:focus{outline:none;border-color:var(--brand2);box-shadow:0 0 0 3px #eaefff}
|
|
|
332 |
.inp[readonly],.inp.ro{background:#f5f6fa;color:var(--muted);font-weight:600}
|
|
|
333 |
select.inp{appearance:none;cursor:pointer;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 11px center}
|
|
|
334 |
.hinttext{font-size:11px;color:var(--soft)}
|
| 37651 |
vikas |
335 |
/* Brand-wise value capture. auto-fill keeps it compact on a laptop and collapses to one column
|
|
|
336 |
on a phone, which is where Path B (field encounter) is actually used. */
|
|
|
337 |
.brandgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(148px,1fr));gap:8px;margin-top:2px}
|
|
|
338 |
.brandcell{display:flex;align-items:center;gap:6px;background:#f7f8fc;border:1px solid var(--line);
|
|
|
339 |
border-radius:8px;padding:4px 8px}
|
|
|
340 |
.brandcell .bname{font-size:11.5px;font-weight:700;color:var(--ink);white-space:nowrap;min-width:58px}
|
|
|
341 |
.brandcell .bval{border:0;background:transparent;padding:3px 0;height:auto;font-size:12.5px;text-align:right}
|
|
|
342 |
.brandcell .bval:focus{outline:0;box-shadow:none}
|
|
|
343 |
.brandcell:focus-within{border-color:var(--brand);background:#fff}
|
| 37125 |
vikas |
344 |
.resolvebox{background:#f5f7ff;border:1px solid #dfe4f7;border-radius:11px;padding:13px 15px;display:flex;align-items:center;gap:13px}
|
|
|
345 |
.resolvebox .ra{width:38px;height:38px;border-radius:10px;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;flex:none}
|
|
|
346 |
.resolvebox .rt{font-size:11px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
|
|
|
347 |
.resolvebox .rn{font-size:15px;font-weight:750;margin-top:1px}
|
|
|
348 |
.resolvebox .rc{font-size:11.5px;color:var(--muted);margin-top:2px}
|
|
|
349 |
.trail{position:relative;padding-left:22px}
|
| 37692 |
vikas |
350 |
/* When the trail scrolls, bleed it out to the card's edge (.card padding is 17px 19px) and put
|
|
|
351 |
that 19px back as padding — the scrollbar then sits flush against the card border instead of
|
|
|
352 |
floating in a gutter, and the entries stay where they were. */
|
|
|
353 |
.trail.scrolls{margin-right:-19px;padding-right:19px}
|
| 37125 |
vikas |
354 |
.trail:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--line)}
|
|
|
355 |
.tevt{position:relative;padding:0 0 14px}
|
|
|
356 |
.tevt:last-child{padding-bottom:0}
|
|
|
357 |
.tevt .tdot{position:absolute;left:-22px;top:2px;width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid var(--brand);display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--brand)}
|
|
|
358 |
.tevt.sys .tdot{border-color:var(--soft);color:var(--soft)}
|
|
|
359 |
.tevt.win .tdot{border-color:var(--good);color:var(--good)}
|
|
|
360 |
.tevt.call .tdot{border-color:var(--coral);color:var(--coral)}
|
|
|
361 |
.tevt .tt{font-size:13px;font-weight:650}
|
|
|
362 |
.tevt .tm{font-size:11.5px;color:var(--soft);margin-top:1px}
|
|
|
363 |
.tevt .td{font-size:12.5px;color:var(--muted);margin-top:4px;background:#f8f9fc;border:1px solid var(--line2);border-radius:8px;padding:8px 10px}
|
| 37692 |
vikas |
364 |
.tevt .trec{display:flex;align-items:center;gap:8px;margin-top:6px}
|
|
|
365 |
.tevt .trec audio{height:30px;max-width:230px}
|
| 37125 |
vikas |
366 |
|
|
|
367 |
/* record stepper */
|
|
|
368 |
.rec-stepper{display:flex;align-items:center;gap:0;margin:2px 0 4px;flex-wrap:wrap}
|
|
|
369 |
.rec-step{display:flex;align-items:center;gap:8px;flex:1;min-width:96px;position:relative}
|
|
|
370 |
.rec-step .dot{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex:none;border:2px solid var(--line);background:#fff;color:var(--soft)}
|
|
|
371 |
.rec-step.done .dot{background:var(--good);border-color:var(--good);color:#fff}
|
|
|
372 |
.rec-step.cur .dot{background:var(--navy);border-color:var(--navy);color:#fff;box-shadow:0 0 0 4px #e7ebfa}
|
|
|
373 |
.rec-step .sl{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.02em;color:var(--muted)}
|
|
|
374 |
.rec-step.cur .sl{color:var(--navy)}
|
|
|
375 |
.rec-step .bar{position:absolute;left:26px;right:0;top:13px;height:2px;background:var(--line);z-index:-1}
|
|
|
376 |
.rec-step.done .bar{background:var(--good)}
|
|
|
377 |
|
|
|
378 |
/* matrix */
|
|
|
379 |
.matrix{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
|
|
|
380 |
.matrix th,.matrix td{padding:9px 8px;border-bottom:1px solid var(--line2);text-align:center}
|
|
|
381 |
.matrix th:first-child,.matrix td:first-child{text-align:left;font-weight:650}
|
|
|
382 |
.matrix thead th{background:#f8f9fc;color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.03em}
|
|
|
383 |
.mk-y{color:var(--good);font-weight:800}
|
|
|
384 |
.mk-n{color:#ccd2e0}
|
|
|
385 |
.mk-s{color:var(--orange);font-weight:800;font-size:11px}
|
|
|
386 |
|
|
|
387 |
@media (max-width:1040px){
|
|
|
388 |
.kpis{grid-template-columns:repeat(3,1fr)}
|
|
|
389 |
.g2,.g3{grid-template-columns:1fr}
|
|
|
390 |
.form-grid{grid-template-columns:1fr}.fld.full{grid-column:1}
|
|
|
391 |
.search{display:none}
|
|
|
392 |
}
|
|
|
393 |
</style>
|
|
|
394 |
</head>
|
|
|
395 |
<body>
|
|
|
396 |
|
|
|
397 |
<!-- ============ APP BAR ============ -->
|
|
|
398 |
<div class="appbar">
|
|
|
399 |
<div class="inner">
|
|
|
400 |
<div class="logo"><span class="mk">SD</span><div>SmartDukaan · LMS<small>LEAD MANAGEMENT SYSTEM</small></div></div>
|
|
|
401 |
<div class="search">
|
|
|
402 |
<span class="si">🔍</span>
|
|
|
403 |
<input id="lmsSearch" placeholder="Search LMS ID, business, phone, retailer… e.g. LMS-UPW-26-000123">
|
|
|
404 |
<span class="hint">/ to focus</span>
|
|
|
405 |
</div>
|
| 37783 |
vikas |
406 |
#if($canCreateLead)
|
| 37125 |
vikas |
407 |
<button class="abtn coral" onclick="window.open(CTX+'/getOpenLead','_blank')">+ New Lead</button>
|
| 37783 |
vikas |
408 |
#end
|
| 37125 |
vikas |
409 |
<div class="role">
|
| 37749 |
vikas |
410 |
<div class="av" title="Signed in as $!viewerName">$!viewerInitials</div>
|
|
|
411 |
<div class="who"><b>$!viewerName</b><br><span>$!viewerRole</span></div>
|
| 37125 |
vikas |
412 |
</div>
|
|
|
413 |
</div>
|
|
|
414 |
</div>
|
|
|
415 |
|
|
|
416 |
<!-- ============ TABS ============ -->
|
|
|
417 |
<div class="tabbar">
|
|
|
418 |
<div class="inner">
|
| 37760 |
vikas |
419 |
<button class="tab active" data-screen="cmd" onclick="go('cmd',this)">Command Center</button>
|
|
|
420 |
<button class="tab" data-screen="record" onclick="go('record',this)">Lead Record</button>
|
|
|
421 |
#if($canCreateLead)
|
|
|
422 |
<button class="tab" data-screen="create" onclick="go('create',this)">Create Lead</button>
|
|
|
423 |
#end
|
|
|
424 |
<button class="tab" data-screen="assign" onclick="go('assign',this)">Auto-Assignment</button>
|
|
|
425 |
<button class="tab" data-screen="live" onclick="go('live',this)">Live Calls</button>
|
|
|
426 |
<button class="tab" data-screen="dash" onclick="go('dash',this)">Dashboards</button>
|
|
|
427 |
<button class="tab" data-screen="chk" onclick="go('chk',this)">Checklist</button>
|
|
|
428 |
<button class="tab" data-screen="audit" onclick="go('audit',this)">Recordings & Audit</button>
|
| 37125 |
vikas |
429 |
</div>
|
|
|
430 |
</div>
|
|
|
431 |
|
|
|
432 |
<div class="wrap">
|
|
|
433 |
|
|
|
434 |
<!-- ======================= COMMAND CENTER ========================= -->
|
|
|
435 |
<div id="cmd">
|
|
|
436 |
<div class="pagehead">
|
|
|
437 |
<div>
|
|
|
438 |
<div class="crumb">Sales · Lead Pipeline</div>
|
|
|
439 |
<h1 class="page">Command Center</h1>
|
|
|
440 |
<div class="sub">Every lead you own or oversee — from creation to onboarding. SLA clock runs on all NEW records.</div>
|
|
|
441 |
</div>
|
|
|
442 |
<div class="hd-right">
|
| 37784 |
vikas |
443 |
<select class="ctl" id="fAgent" title="Call Center user — every number below narrows to leads they created or currently own">
|
|
|
444 |
<option value="">All Call Center users</option>
|
|
|
445 |
</select>
|
| 37125 |
vikas |
446 |
<select class="ctl" id="fRegion">
|
|
|
447 |
<option value="">All regions</option>
|
|
|
448 |
#foreach($r in $regions)
|
|
|
449 |
<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>
|
|
|
450 |
#end
|
|
|
451 |
</select>
|
|
|
452 |
<input type="date" class="ctl" id="fFrom" value="$!fromDate">
|
|
|
453 |
<input type="date" class="ctl" id="fTo" value="$!toDate">
|
|
|
454 |
</div>
|
|
|
455 |
</div>
|
|
|
456 |
|
|
|
457 |
<div class="kpis" id="ccKpis"></div>
|
|
|
458 |
|
| 37749 |
vikas |
459 |
<div class="sectionlabel">Lifecycle Funnel — selected period · beat, visit & onboarding read from source tables</div>
|
| 37125 |
vikas |
460 |
<div class="card"><div class="funnel" id="ccFunnel"></div></div>
|
|
|
461 |
|
| 37692 |
vikas |
462 |
<div class="card">
|
|
|
463 |
<h2>Needs attention now</h2>
|
| 37749 |
vikas |
464 |
<p class="desc">Actionable queues for the selected period & region · click to work them.</p>
|
| 37692 |
vikas |
465 |
<div id="ccAttention" class="attstrip"></div>
|
|
|
466 |
</div>
|
|
|
467 |
|
|
|
468 |
<div class="card">
|
|
|
469 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
|
|
470 |
<span style="display:flex;gap:6px;align-items:center">
|
| 37760 |
vikas |
471 |
<input id="pipeQ" class="inp" style="width:210px;font-size:12.5px;padding:7px 10px"
|
| 37692 |
vikas |
472 |
placeholder="Name, outlet, mobile, city, created by">
|
|
|
473 |
<button class="btn ghost sm" onclick="pipeSearch()">Search</button>
|
|
|
474 |
<button class="btn ghost sm hide" id="pipeClear" onclick="pipeClearSearch()">Clear</button>
|
|
|
475 |
</span>
|
|
|
476 |
</h2>
|
|
|
477 |
<p class="desc" id="pipeDesc">Click any row → full LMS record & trail. Rows shaded by SLA urgency.</p>
|
|
|
478 |
<div class="pipewrap">
|
| 37125 |
vikas |
479 |
<table>
|
| 37692 |
vikas |
480 |
<colgroup>
|
|
|
481 |
<col style="width:132px"><col><col style="width:64px"><col style="width:112px">
|
|
|
482 |
<col style="width:130px"><col style="width:130px"><col style="width:130px">
|
| 37737 |
vikas |
483 |
<col style="width:104px"><col style="width:84px"><col style="width:124px">
|
| 37692 |
vikas |
484 |
</colgroup>
|
|
|
485 |
<thead><tr>
|
|
|
486 |
<th class="sortable" data-sort="created">LMS ID</th>
|
|
|
487 |
<th class="sortable" data-sort="name">Business / Retailer</th>
|
|
|
488 |
<th>Path</th>
|
|
|
489 |
<th class="sortable" data-sort="stage">Stage</th>
|
|
|
490 |
<th>Owner</th>
|
|
|
491 |
<th class="sortable" data-sort="city">City</th>
|
|
|
492 |
<th class="sortable" data-sort="createdBy">Created by</th>
|
|
|
493 |
<th class="sortable" data-sort="sla">SLA</th>
|
|
|
494 |
<th class="sortable num" data-sort="value">Value</th>
|
| 37737 |
vikas |
495 |
<th></th>
|
| 37692 |
vikas |
496 |
</tr></thead>
|
| 37737 |
vikas |
497 |
<tbody id="ccPipeline"><tr><td colspan="10"><div class="ph">Loading…</div></td></tr></tbody>
|
| 37125 |
vikas |
498 |
</table>
|
| 37692 |
vikas |
499 |
</div>
|
|
|
500 |
<div id="pipeFoot" style="display:flex;align-items:center;justify-content:space-between;gap:10px;
|
|
|
501 |
margin-top:10px;flex-wrap:wrap">
|
|
|
502 |
<span class="note" id="pipeCount"></span>
|
|
|
503 |
<span style="display:flex;gap:6px;align-items:center">
|
|
|
504 |
<button class="btn ghost sm" id="pipePrev" onclick="pipePage(-1)">← Prev</button>
|
|
|
505 |
<span class="note" id="pipePageLbl"></span>
|
|
|
506 |
<button class="btn ghost sm" id="pipeNext" onclick="pipePage(1)">Next →</button>
|
|
|
507 |
</span>
|
|
|
508 |
</div>
|
| 37125 |
vikas |
509 |
</div>
|
|
|
510 |
</div>
|
|
|
511 |
|
|
|
512 |
<!-- ======================= LEAD RECORD ========================= -->
|
|
|
513 |
<div id="record" class="hide">
|
|
|
514 |
<div class="pagehead">
|
|
|
515 |
<div>
|
|
|
516 |
<div class="crumb">Command Center · Lead Record</div>
|
|
|
517 |
<h1 class="page">Lead Record</h1>
|
|
|
518 |
<div class="sub">The single source of truth for a lead — stepper, immutable trail, SLA & disposition. Loads the real record.</div>
|
|
|
519 |
</div>
|
|
|
520 |
<div class="hd-right">
|
|
|
521 |
<div class="lookup">
|
|
|
522 |
<input id="recLookup" placeholder="Lead # (e.g. 123)" inputmode="numeric">
|
|
|
523 |
<button class="btn sm" onclick="loadRecord()">Load</button>
|
|
|
524 |
</div>
|
|
|
525 |
</div>
|
|
|
526 |
</div>
|
|
|
527 |
<div class="recordHost" id="recordHost"><div class="card"><div class="ph">Loading the most recent lead…</div></div></div>
|
| 37692 |
vikas |
528 |
<div class="note" style="margin-left:2px">Acts on the real record: edit, call, disposition and stage moves all write here, and every change lands in the trail.</div>
|
| 37125 |
vikas |
529 |
</div>
|
|
|
530 |
|
|
|
531 |
<!-- ======================= CREATE LEAD ========================= -->
|
|
|
532 |
<div id="create" class="hide">
|
|
|
533 |
<div class="pagehead">
|
|
|
534 |
<div>
|
|
|
535 |
<div class="crumb">Create · New Lead</div>
|
|
|
536 |
<h1 class="page">Create Lead</h1>
|
| 37749 |
vikas |
537 |
<div class="sub">What an agent can know on a first call, plus the shop details the Leads screen captures. LMS ID is generated on submit and the lead is auto-assigned from region. Address, store photo and geo-pin come from the capture link, which is created automatically and copied to your clipboard.</div>
|
| 37125 |
vikas |
538 |
</div>
|
|
|
539 |
<div class="hd-right">
|
|
|
540 |
<div class="ctl" style="padding:4px;display:flex;gap:4px">
|
|
|
541 |
<button class="btn coral sm" id="pathA" style="border-radius:7px" onclick="setPath('A',this)">Path A · BGC outbound</button>
|
|
|
542 |
<button class="btn ghost sm" id="pathB" style="border-radius:7px" onclick="setPath('B',this)">Path B · Field encounter</button>
|
|
|
543 |
</div>
|
|
|
544 |
</div>
|
|
|
545 |
</div>
|
|
|
546 |
|
|
|
547 |
<div class="grid g3">
|
|
|
548 |
<div class="card">
|
|
|
549 |
<h2>Lead details</h2>
|
|
|
550 |
<p class="desc" id="createDesc">Path A — captured by BGC agent during an outbound call, after consent.</p>
|
|
|
551 |
<div class="form-grid">
|
|
|
552 |
<div class="fld"><label>Retailer name <span class="m">*</span></label><input class="inp" id="cRetailer" placeholder="Contact person"></div>
|
|
|
553 |
<div class="fld"><label>Business name <span class="m">*</span></label><input class="inp" id="cBusiness" placeholder="Shop / trade name"></div>
|
|
|
554 |
<div class="fld"><label>Contact number <span class="m">*</span></label><input class="inp mono" id="cContact" placeholder="10-digit mobile" inputmode="numeric"></div>
|
| 37651 |
vikas |
555 |
<div class="fld full">
|
|
|
556 |
<label>Monthly business value — brand wise <span class="auto" id="cValueTotal">total ₹0</span></label>
|
|
|
557 |
<div class="brandgrid" id="cBrandGrid">
|
|
|
558 |
#foreach($b in $leadBrands)
|
|
|
559 |
<div class="brandcell">
|
|
|
560 |
<span class="bname">$b</span>
|
|
|
561 |
<input class="inp bval mono" data-brand="$b" placeholder="0" inputmode="numeric"
|
|
|
562 |
oninput="brandTotal()">
|
|
|
563 |
</div>
|
|
|
564 |
#end
|
|
|
565 |
</div>
|
|
|
566 |
<div class="hinttext">Type in rupees per month — 100000 or 1L, 50k both work. Leave a brand blank if it is not stocked.</div>
|
|
|
567 |
</div>
|
| 37125 |
vikas |
568 |
<div class="fld"><label>Interest status</label><select class="inp" id="cInterest"><option>Interested</option><option>Callback</option><option>Not Interested</option></select></div>
|
| 37749 |
vikas |
569 |
<div class="fld"><label>Source</label>
|
|
|
570 |
<select class="inp" id="cSource">
|
|
|
571 |
<option value="LMS Dashboard" selected>LMS Dashboard</option>
|
|
|
572 |
<option value="SELF">SELF</option><option value="NEWSPAPER">NEWSPAPER</option><option value="INTERNET">INTERNET</option>
|
|
|
573 |
<option value="PARTNER">PARTNER</option><option value="WEBINAR">WEBINAR</option><option value="TRIAL">TRIAL</option>
|
|
|
574 |
<option value="WHATSAPP_CAMPAIGN">WHATSAPP CAMPAIGN</option><option value="RETAILER_MEET">RETAILER MEET</option>
|
|
|
575 |
<option value="AI Assistant">AI Assistant</option>
|
|
|
576 |
</select></div>
|
|
|
577 |
<div class="fld full"><label>Address</label><input class="inp" id="cAddress" placeholder="Shop address" maxlength="128"></div>
|
|
|
578 |
<div class="fld"><label>City</label><input class="inp" id="cCity" placeholder="City" maxlength="30"></div>
|
|
|
579 |
<div class="fld"><label>State</label>
|
|
|
580 |
<select class="inp" id="cState">
|
|
|
581 |
<option value="">Select a state…</option>
|
|
|
582 |
#foreach($sn in $stateNames)
|
|
|
583 |
<option value="$sn">$sn</option>
|
|
|
584 |
#end
|
|
|
585 |
</select></div>
|
|
|
586 |
<div class="fld"><label>Conversion probability</label>
|
|
|
587 |
<select class="inp" id="cConversion"><option value="false" selected>Low</option><option value="true">High</option></select></div>
|
| 37760 |
vikas |
588 |
<div class="fld"><label>Counter size <span class="auto" id="cCounterHint">= brand total, editable</span></label>
|
|
|
589 |
<input class="inp mono" id="cCounter" placeholder="₹ per month" inputmode="numeric" oninput="counterEdited()"></div>
|
| 37749 |
vikas |
590 |
<div class="fld full"><label>Remark</label><input class="inp" id="cRemark" placeholder="Anything the BM should know before the first call" maxlength="500"></div>
|
| 37125 |
vikas |
591 |
<div class="fld full">
|
| 37749 |
vikas |
592 |
<label>Store photos <span class="auto">optional — same five shots as the Leads screen</span></label>
|
|
|
593 |
<div class="brandgrid" id="cPhotoGrid">
|
|
|
594 |
<div class="brandcell"><span class="bname">Front</span><input type="file" accept="image/*" class="inp cphoto" data-photo="frontp"></div>
|
|
|
595 |
<div class="brandcell"><span class="bname">Internal with market</span><input type="file" accept="image/*" class="inp cphoto" data-photo="frontWithMarket"></div>
|
|
|
596 |
<div class="brandcell"><span class="bname">Internal long shot</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalLongShot"></div>
|
|
|
597 |
<div class="brandcell"><span class="bname">Internal left wall</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalLeftWall"></div>
|
|
|
598 |
<div class="brandcell"><span class="bname">Internal right wall</span><input type="file" accept="image/*" class="inp cphoto" data-photo="internalRightWall"></div>
|
|
|
599 |
</div>
|
|
|
600 |
<div class="hinttext">Photos upload when you submit. Skip them on a call — the capture link collects them from the retailer.</div>
|
|
|
601 |
</div>
|
|
|
602 |
<div class="fld full">
|
| 37125 |
vikas |
603 |
<label>Region / State <span class="m">*</span> <span class="auto">drives auto-assignment</span></label>
|
|
|
604 |
<select class="inp" id="cRegion" onchange="resolveBM(this.value)">
|
|
|
605 |
<option value="">Select a region…</option>
|
|
|
606 |
#foreach($r in $regions)
|
|
|
607 |
<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>
|
|
|
608 |
#end
|
|
|
609 |
</select>
|
|
|
610 |
</div>
|
|
|
611 |
</div>
|
|
|
612 |
<div style="display:flex;gap:9px;margin-top:16px;justify-content:flex-end">
|
|
|
613 |
<button class="btn coral" onclick="submitLead()">Submit & generate LMS ID</button>
|
|
|
614 |
</div>
|
|
|
615 |
</div>
|
|
|
616 |
|
|
|
617 |
<div>
|
|
|
618 |
<div class="card">
|
|
|
619 |
<h2>Auto-assignment preview</h2>
|
|
|
620 |
<p class="desc">On Region select, the engine resolves the BM/RSM and shows it for confirmation before submit.</p>
|
|
|
621 |
<div class="resolvebox" id="resolveBox">
|
|
|
622 |
<div class="ra" id="raAv">—</div>
|
|
|
623 |
<div>
|
|
|
624 |
<div class="rt">Resolved BM / RSM · <span id="rRegion">—</span></div>
|
|
|
625 |
<div class="rn" id="rName">Pick a region…</div>
|
|
|
626 |
<div class="rc" id="rEsc">The owning BM/RSM is resolved from the region (cs.position).</div>
|
|
|
627 |
</div>
|
|
|
628 |
</div>
|
|
|
629 |
<div class="note">If a region has no active BM/RSM, the LMS enters a <b>HOLD queue</b> and the Sales Head is notified.</div>
|
|
|
630 |
</div>
|
|
|
631 |
<div class="card">
|
|
|
632 |
<h2>What happens on submit</h2>
|
|
|
633 |
<div class="trail" style="margin-top:4px">
|
|
|
634 |
<div class="tevt win"><div class="tdot">1</div><div class="tt">Validate fields & generate LMS ID</div><div class="tm">immutable, searchable</div></div>
|
|
|
635 |
<div class="tevt sys"><div class="tdot">2</div><div class="tt">Set stage = Assigned</div><div class="tm">BM/RSM resolved from region</div></div>
|
|
|
636 |
<div class="tevt sys"><div class="tdot">3</div><div class="tt">Dispatch assignment email</div><div class="tm">BM/RSM + CC Reporting Mgr</div></div>
|
|
|
637 |
<div class="tevt call"><div class="tdot">4</div><div class="tt">Start 5 hr SLA timer</div><div class="tm">first-contact clock begins</div></div>
|
|
|
638 |
</div>
|
|
|
639 |
</div>
|
|
|
640 |
</div>
|
|
|
641 |
</div>
|
|
|
642 |
</div>
|
|
|
643 |
|
|
|
644 |
<!-- ======================= AUTO-ASSIGNMENT ========================= -->
|
|
|
645 |
<div id="assign" class="hide">
|
|
|
646 |
<div class="pagehead">
|
|
|
647 |
<div>
|
|
|
648 |
<div class="crumb">Sales Ops · Routing</div>
|
|
|
649 |
<h1 class="page">Auto-Assignment Engine</h1>
|
|
|
650 |
<div class="sub">Region → BM/RSM mapping resolves ownership at submit — from the live cs.position table. HOLD when a region has no active BM.</div>
|
|
|
651 |
</div>
|
|
|
652 |
</div>
|
|
|
653 |
|
|
|
654 |
<div class="kpis" style="grid-template-columns:repeat(4,1fr)" id="assignKpis"></div>
|
|
|
655 |
|
|
|
656 |
<div class="grid g3">
|
|
|
657 |
<div class="card">
|
|
|
658 |
<h2>Region → BM/RSM mapping</h2>
|
|
|
659 |
<p class="desc">The live routing table — resolved for each region right now.</p>
|
|
|
660 |
<table>
|
| 37760 |
vikas |
661 |
<thead><tr><th>Region / State</th><th>Assigned BM / RSM</th><th>Status</th><th></th></tr></thead>
|
|
|
662 |
<tbody id="assignMap"><tr><td colspan="4"><div class="ph">Resolving…</div></td></tr></tbody>
|
| 37125 |
vikas |
663 |
</table>
|
|
|
664 |
</div>
|
|
|
665 |
<div>
|
|
|
666 |
<div class="card">
|
|
|
667 |
<h2>Engine rules</h2>
|
|
|
668 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#eef1fb;color:#3b5bdb">1</div><div class="ab"><div class="atitle">Resolve at submit</div><div class="adet">Region drives ownership; BM/RSM shown for confirmation.</div></div></div>
|
|
|
669 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff4e6;color:#e8590c">2</div><div class="ab"><div class="atitle">Tier fallback</div><div class="adet">L4 → L5 → L3 within the region (cs.position).</div></div></div>
|
|
|
670 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff0f0;color:#e03131">3</div><div class="ab"><div class="atitle">HOLD queue</div><div class="adet">No active BM/RSM → LMS held; Sales Head notified.</div></div></div>
|
|
|
671 |
<div class="att" style="margin-bottom:0"><div class="ic" style="background:#f3f0ff;color:#7048e8">4</div><div class="ab"><div class="atitle">Manual reassignment</div><div class="adet">BM/RSM/Sales Head can override — always logged.</div></div></div>
|
|
|
672 |
</div>
|
|
|
673 |
<div class="card" style="background:#fff5f5;border-color:#ffd9d5">
|
|
|
674 |
<h2 style="color:#c92a2a">HOLD queue</h2>
|
|
|
675 |
<p class="desc" style="margin-bottom:8px">LMS with no active BM — waiting until one is mapped.</p>
|
|
|
676 |
<div id="holdQueue"><div class="ph">Loading…</div></div>
|
|
|
677 |
</div>
|
|
|
678 |
</div>
|
|
|
679 |
</div>
|
|
|
680 |
</div>
|
|
|
681 |
|
|
|
682 |
<!-- ======================= DASHBOARDS ========================= -->
|
| 37737 |
vikas |
683 |
<!-- ======================= LIVE CALLS ========================= -->
|
|
|
684 |
<div id="live" class="hide">
|
|
|
685 |
<div class="pagehead">
|
|
|
686 |
<div>
|
|
|
687 |
<div class="crumb">Floor · Live</div>
|
|
|
688 |
<h1 class="page">Live Calls</h1>
|
|
|
689 |
<div class="sub">Who is on a call right now, and how the day is going per agent — from the
|
|
|
690 |
call log. Refreshes every 10 seconds.</div>
|
|
|
691 |
</div>
|
|
|
692 |
<div class="hd-right">
|
|
|
693 |
<span class="liveclock"><span class="livedot" id="liveDot"></span><span id="liveAsOf">connecting…</span></span>
|
|
|
694 |
<button class="btn ghost sm" onclick="exportLive()">Export CSV</button>
|
|
|
695 |
</div>
|
|
|
696 |
</div>
|
|
|
697 |
|
|
|
698 |
<div class="kpis" id="liveKpis"></div>
|
|
|
699 |
|
|
|
700 |
<div class="card">
|
|
|
701 |
<h2>Agents</h2>
|
|
|
702 |
<p class="desc">Roster is everyone with a dialer mapping — an agent without one cannot place
|
|
|
703 |
a call. Click a row for their calls today.</p>
|
|
|
704 |
<div class="pipewrap">
|
|
|
705 |
<table>
|
|
|
706 |
<colgroup>
|
|
|
707 |
<col style="width:170px"><col style="width:104px"><col style="width:96px">
|
|
|
708 |
<col style="width:70px"><col style="width:82px"><col style="width:84px">
|
|
|
709 |
<col style="width:88px"><col style="width:90px"><col style="width:86px">
|
|
|
710 |
<col style="width:92px"><col style="width:96px">
|
|
|
711 |
</colgroup>
|
|
|
712 |
<thead><tr>
|
|
|
713 |
<th>Agent</th><th>Status</th><th>In status</th>
|
|
|
714 |
<th class="num">Calls</th><th class="num">Conn.</th><th class="num">Conn%</th>
|
|
|
715 |
<th class="num">Talk</th><th class="num">Longest</th>
|
|
|
716 |
<th class="num">Assigned</th><th class="num">Touched</th><th class="num">SLA breach</th>
|
|
|
717 |
</tr></thead>
|
|
|
718 |
<tbody id="liveBody"><tr><td colspan="11"><div class="ph">Loading…</div></td></tr></tbody>
|
|
|
719 |
</table>
|
|
|
720 |
</div>
|
|
|
721 |
<div class="note">Status is derived from call activity — the Vonage softphone reports no
|
|
|
722 |
presence, so there is deliberately no Login or Break here rather than a guess.</div>
|
|
|
723 |
</div>
|
|
|
724 |
|
|
|
725 |
<div class="card">
|
|
|
726 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Call log
|
|
|
727 |
<span style="display:flex;gap:6px;align-items:center">
|
|
|
728 |
<input type="date" class="ctl" id="clFrom">
|
|
|
729 |
<input type="date" class="ctl" id="clTo">
|
|
|
730 |
<button class="btn ghost sm" onclick="loadCallLog(1)">Apply</button>
|
|
|
731 |
<button class="btn ghost sm" onclick="exportCallLog()">Export CSV</button>
|
|
|
732 |
</span>
|
|
|
733 |
</h2>
|
|
|
734 |
<p class="desc">Every call placed in the window — ring time, talk time, outcome and recording.
|
|
|
735 |
Click a row to open its lead.</p>
|
|
|
736 |
<div class="pipewrap">
|
|
|
737 |
<table>
|
|
|
738 |
<colgroup>
|
|
|
739 |
<col style="width:132px"><col style="width:140px"><col style="width:190px">
|
|
|
740 |
<col style="width:124px"><col style="width:104px"><col style="width:74px">
|
|
|
741 |
<col style="width:74px"><col style="width:120px"><col>
|
|
|
742 |
</colgroup>
|
|
|
743 |
<thead><tr>
|
|
|
744 |
<th>Started</th><th>Agent</th><th>Lead</th><th>Number</th><th>Status</th>
|
|
|
745 |
<th class="num">Ring</th><th class="num">Talk</th><th>Recording</th><th>Disposition</th>
|
|
|
746 |
</tr></thead>
|
|
|
747 |
<tbody id="clBody"><tr><td colspan="9"><div class="ph">Loading…</div></td></tr></tbody>
|
|
|
748 |
</table>
|
|
|
749 |
</div>
|
|
|
750 |
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:10px;flex-wrap:wrap">
|
|
|
751 |
<span class="note" id="clCount"></span>
|
|
|
752 |
<span style="display:flex;gap:6px;align-items:center">
|
|
|
753 |
<button class="btn ghost sm" id="clPrev" onclick="clPage(-1)">← Prev</button>
|
|
|
754 |
<span class="note" id="clPageLbl"></span>
|
|
|
755 |
<button class="btn ghost sm" id="clNext" onclick="clPage(1)">Next →</button>
|
|
|
756 |
</span>
|
|
|
757 |
</div>
|
|
|
758 |
<div class="note">Ring is dial → answer; Talk is answer → hangup. A call that was never
|
|
|
759 |
answered has a ring time but no talk time.</div>
|
|
|
760 |
</div>
|
|
|
761 |
</div>
|
|
|
762 |
|
| 37125 |
vikas |
763 |
<div id="dash" class="hide">
|
|
|
764 |
<div class="pagehead">
|
|
|
765 |
<div>
|
|
|
766 |
<div class="crumb">Management · Cross-region roll-up</div>
|
|
|
767 |
<h1 class="page">Dashboards</h1>
|
|
|
768 |
<div class="sub">Funnel, SLA compliance, ageing & servicing across all regions — derived from the lead record.</div>
|
|
|
769 |
</div>
|
|
|
770 |
<div class="hd-right">
|
| 37743 |
vikas |
771 |
<span class="periodtag"><span class="pl">Period</span><span id="dashPeriod">—</span></span>
|
| 37737 |
vikas |
772 |
<button class="btn ghost sm" onclick="exportPipeline()">Export CSV</button>
|
| 37125 |
vikas |
773 |
</div>
|
|
|
774 |
</div>
|
|
|
775 |
|
|
|
776 |
<div class="kpis" id="dashKpis"></div>
|
|
|
777 |
|
|
|
778 |
<div class="sectionlabel">State funnel — NEW → ONBOARDED by region</div>
|
|
|
779 |
<div class="card">
|
|
|
780 |
<table>
|
| 37749 |
vikas |
781 |
<thead><tr><th>Region</th><th class="num">Leads</th><th class="num">Contacted</th><th class="num">Qualified</th><th class="num">Beat</th><th class="num">Visited</th><th class="num">Onboarded</th><th class="num">Conv%</th><th class="num">SLA%</th></tr></thead>
|
| 37125 |
vikas |
782 |
<tbody id="dashState"></tbody>
|
|
|
783 |
</table>
|
|
|
784 |
</div>
|
|
|
785 |
|
|
|
786 |
<div class="grid g2">
|
|
|
787 |
<div class="card">
|
|
|
788 |
<h2>SLA compliance — owner heat-map</h2>
|
|
|
789 |
<p class="desc">% first contact within SLA, by BM (rows) across recent weeks (cols). Red = chronic breach.</p>
|
|
|
790 |
<div id="dashHeat"></div>
|
|
|
791 |
<div class="note">SLA = first contact within 5 hrs of creation (SOP §11). Reminder T-1h; escalation to BM at T+5h.</div>
|
|
|
792 |
</div>
|
|
|
793 |
<div class="card">
|
|
|
794 |
<h2>Standard dashboards</h2>
|
| 37737 |
vikas |
795 |
<p class="desc">SOP §18.2 report suite — click any row to open it. Live = measured from real data; Pending = its source system does not exist yet.</p>
|
| 37125 |
vikas |
796 |
<table><tbody style="font-size:12.5px">
|
|
|
797 |
<tr class="clickrow" data-rep="State Funnel"><td>📈 <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New → onboarded by region, BM, ASM</td></tr>
|
|
|
798 |
<tr class="clickrow" data-rep="SLA Compliance"><td>⚖ <b>SLA Compliance</b><span class="rbadge live">Live</span></td><td style="color:#69748c">% within SLA, owner heat-map</td></tr>
|
|
|
799 |
<tr class="clickrow" data-rep="Ageing"><td>⌛ <b>Ageing</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Stuck by stage, days-since-action</td></tr>
|
|
|
800 |
<tr class="clickrow" data-rep="Management Roll-up"><td>👑 <b>Management Roll-up</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Cross-region leads, onboardings, value</td></tr>
|
| 37737 |
vikas |
801 |
<tr class="clickrow" data-rep="BGC Daily"><td>📊 <b>BGC Daily</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Calls, connect rate & talk time per agent per day</td></tr>
|
|
|
802 |
<tr class="clickrow" data-rep="Audit Hygiene"><td>🧹 <b>Audit Hygiene</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Dispositions, SLA, HOLD, DND breaches, recording access</td></tr>
|
| 37125 |
vikas |
803 |
<tr class="clickrow" data-rep="RBM Servicing"><td>🔄 <b>RBM Servicing</b><span class="rbadge soon">Pending data</span></td><td style="color:#69748c">Repeat-order / complaint / TAT need order data</td></tr>
|
|
|
804 |
</tbody></table>
|
|
|
805 |
</div>
|
|
|
806 |
</div>
|
|
|
807 |
|
|
|
808 |
<div class="sectionlabel">What to fix</div>
|
|
|
809 |
<div class="callout">
|
|
|
810 |
<h2>Recommended actions — from this period's data</h2>
|
|
|
811 |
<p class="desc">Each links to the section above.</p>
|
|
|
812 |
<div id="dashFix"></div>
|
|
|
813 |
</div>
|
|
|
814 |
</div>
|
|
|
815 |
|
| 37737 |
vikas |
816 |
<!-- ======================= CHECKLIST ADMIN ========================= -->
|
|
|
817 |
<div id="chk" class="hide">
|
|
|
818 |
<div class="pagehead">
|
|
|
819 |
<div>
|
|
|
820 |
<div class="crumb">Setup · In-call script</div>
|
|
|
821 |
<h1 class="page">Checklist questions</h1>
|
|
|
822 |
<div class="sub">What agents are asked while a call is connected. Questions show at the stages
|
|
|
823 |
you pick, and carry points towards a lead's score.</div>
|
|
|
824 |
</div>
|
|
|
825 |
<div class="hd-right">
|
|
|
826 |
<button class="btn coral sm" onclick="editQuestion(null)">+ New question</button>
|
|
|
827 |
</div>
|
|
|
828 |
</div>
|
|
|
829 |
|
|
|
830 |
<div class="card">
|
|
|
831 |
<div class="pipewrap">
|
|
|
832 |
<table>
|
|
|
833 |
<colgroup>
|
|
|
834 |
<col style="width:54px"><col><col style="width:96px"><col style="width:66px">
|
|
|
835 |
<col style="width:70px"><col style="width:220px"><col style="width:74px"><col style="width:132px">
|
|
|
836 |
</colgroup>
|
|
|
837 |
<thead><tr>
|
|
|
838 |
<th class="num">Order</th><th>Question</th><th>Type</th><th class="num">Points</th>
|
|
|
839 |
<th>Required</th><th>Shows at</th><th>Status</th><th></th>
|
|
|
840 |
</tr></thead>
|
|
|
841 |
<tbody id="qBody"><tr><td colspan="8"><div class="ph">Loading…</div></td></tr></tbody>
|
|
|
842 |
</table>
|
|
|
843 |
</div>
|
|
|
844 |
<div class="note">A question that has been answered can be switched off but not deleted —
|
|
|
845 |
the answers reference it.</div>
|
|
|
846 |
</div>
|
|
|
847 |
</div>
|
|
|
848 |
|
| 37125 |
vikas |
849 |
<!-- ======================= RECORDINGS & AUDIT ========================= -->
|
|
|
850 |
<div id="audit" class="hide">
|
|
|
851 |
<div class="pagehead">
|
|
|
852 |
<div>
|
|
|
853 |
<div class="crumb">Audit · Access Control</div>
|
|
|
854 |
<h1 class="page">Recordings & Audit</h1>
|
|
|
855 |
<div class="sub">Recordings are gated by role + reporting hierarchy; every open is logged. Hygiene flags are live; the recording repo is a pending source system.</div>
|
|
|
856 |
</div>
|
|
|
857 |
</div>
|
|
|
858 |
|
|
|
859 |
<div class="kpis" style="grid-template-columns:repeat(5,1fr)" id="auditKpis"></div>
|
|
|
860 |
|
|
|
861 |
<div class="grid g2">
|
|
|
862 |
<div class="card">
|
|
|
863 |
<h2>Access & recording visibility matrix</h2>
|
|
|
864 |
<p class="desc">What each role can open. Recording access is scoped by role + reporting hierarchy (SOP §15).</p>
|
|
|
865 |
<table class="matrix">
|
|
|
866 |
<thead><tr><th>Role</th><th>Own LMS</th><th>Reports'</th><th>State-wide</th><th>All records</th><th>Recordings</th></tr></thead>
|
|
|
867 |
<tbody>
|
|
|
868 |
<tr><td>Individual (BGC/ASM)</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-s">own only</td></tr>
|
|
|
869 |
<tr><td>Reporting Manager</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-s">reports'</td></tr>
|
|
|
870 |
<tr><td>BM / RSM</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-s">state-wide</td></tr>
|
|
|
871 |
<tr><td>Audit Team</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-s">all · read-only</td></tr>
|
|
|
872 |
<tr><td>Upper Management</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-s">via BI drill</td></tr>
|
|
|
873 |
</tbody>
|
|
|
874 |
</table>
|
|
|
875 |
<div class="note">Recording repository (signed URLs, DND enforcement, 24-mo retention) is a Phase-2 source system — not yet wired to real data.</div>
|
|
|
876 |
</div>
|
|
|
877 |
|
|
|
878 |
<div class="card">
|
|
|
879 |
<h2>Weekly hygiene flags <span style="color:#97a1b5;font-weight:500;font-size:12px">· live</span></h2>
|
|
|
880 |
<p class="desc">Derived from the lead record — click to list the leads.</p>
|
|
|
881 |
<div id="auditHygiene"><div class="ph">Loading…</div></div>
|
|
|
882 |
</div>
|
|
|
883 |
</div>
|
|
|
884 |
|
|
|
885 |
<div class="grid g2">
|
|
|
886 |
<div class="card">
|
|
|
887 |
<h2>Audit & compliance program (SOP §17)</h2>
|
|
|
888 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">🎵</div><div class="ab"><div class="atitle">Weekly recording sampling</div><div class="adet">Across BGC, ASM & RBM for quality & process adherence.</div></div></div>
|
|
|
889 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">🧹</div><div class="ab"><div class="atitle">5% deep audit of onboarded</div><div class="adet">Verify data integrity & process compliance.</div></div></div>
|
|
|
890 |
<div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff0f0;color:#e03131">🚫</div><div class="ab"><div class="atitle">DND enforcement</div><div class="adet">Audit maintains the list; repeat calls auto-flag the user.</div></div></div>
|
|
|
891 |
<div class="att" style="margin-bottom:0"><div class="ic" style="background:#eef1f6;color:#69748c">🗃</div><div class="ab"><div class="atitle">Retention policy</div><div class="adet">24 months for recordings · 5 years for LMS records.</div></div></div>
|
|
|
892 |
</div>
|
|
|
893 |
<div class="card">
|
|
|
894 |
<h2>Recording repository <span style="color:#97a1b5;font-weight:500;font-size:12px">· pending source</span></h2>
|
|
|
895 |
<p class="desc">Each recording would be tagged with LMS ID, user, timestamp, duration & disposition.</p>
|
|
|
896 |
<div class="ph">No recording repository connected yet — this arrives with the C2C telephony bridge (Phase 2/3).</div>
|
|
|
897 |
</div>
|
|
|
898 |
</div>
|
|
|
899 |
</div>
|
|
|
900 |
|
|
|
901 |
</div>
|
|
|
902 |
|
|
|
903 |
<!-- drawer + toast -->
|
|
|
904 |
<div class="drawer-ov" id="ov" onclick="closeDrawer()"></div>
|
|
|
905 |
<div class="drawer" id="drawer">
|
| 37743 |
vikas |
906 |
<div class="dh"><div class="dhmain"><div class="crumb2" id="dCrumb"></div><h3 id="dTitle"></h3><div class="dsub" id="dSub"></div></div><div class="dtools" id="dTools"></div><button class="x" onclick="closeDrawer()">×</button></div>
|
| 37125 |
vikas |
907 |
<div class="dbody" id="dBody"></div>
|
|
|
908 |
</div>
|
| 37692 |
vikas |
909 |
<!-- Record actions: edit the record, and log a call outcome. Both write to the same endpoints
|
|
|
910 |
the in-app lead record uses. -->
|
|
|
911 |
<div class="modal hide" id="editModal">
|
|
|
912 |
<div class="box">
|
|
|
913 |
<div class="mh"><h2>Edit record</h2>
|
|
|
914 |
<p class="desc">Every change is written to the trail against your name.</p></div>
|
|
|
915 |
<div class="mb" id="editBody"></div>
|
|
|
916 |
<div class="mf"><button class="btn ghost" onclick="closeEdit()">Cancel</button>
|
|
|
917 |
<button class="btn" style="background:#1f2a5e;color:#fff" onclick="saveEdit()">Save changes</button></div>
|
|
|
918 |
</div>
|
|
|
919 |
</div>
|
|
|
920 |
|
|
|
921 |
<div class="modal hide" id="dispModal">
|
|
|
922 |
<div class="box">
|
|
|
923 |
<div class="mh"><h2>Call disposition</h2>
|
|
|
924 |
<p class="desc">Structured outcome on every interaction (SOP §12.2).</p></div>
|
|
|
925 |
<div class="mb">
|
|
|
926 |
<label class="lbl">Outcome (required)</label>
|
|
|
927 |
<div class="dispgrid" id="dispGrid"></div>
|
|
|
928 |
<div id="dispExtra"></div>
|
|
|
929 |
<label class="lbl">Note (attaches to the trail)</label>
|
|
|
930 |
<textarea id="dispNote" class="inp" rows="2" style="width:100%;resize:vertical"></textarea>
|
|
|
931 |
</div>
|
|
|
932 |
<div class="mf"><button class="btn ghost" onclick="closeDisp()">Cancel</button>
|
|
|
933 |
<button class="btn coral" onclick="saveDisp()">Save disposition</button></div>
|
|
|
934 |
</div>
|
|
|
935 |
</div>
|
|
|
936 |
|
|
|
937 |
<!-- Manual geo verification (SOP §6: geo-pin). Same endpoints the Leads screen uses:
|
|
|
938 |
/lead-geo/geocode-address, /lead-geo/upload-image, /lead-geo/manual-verify. -->
|
|
|
939 |
<div class="modal hide" id="geoModal">
|
|
|
940 |
<div class="box">
|
|
|
941 |
<div class="mh"><h2>Verify location manually</h2>
|
|
|
942 |
<p class="desc">For when the retailer will not tap the capture link — confirm the coordinates
|
|
|
943 |
over the phone, or read them off a Maps link they shared. Saved as approved, against your name.</p></div>
|
|
|
944 |
<div class="mb">
|
|
|
945 |
<label class="lbl">Address</label>
|
|
|
946 |
<div style="display:flex;gap:6px">
|
|
|
947 |
<input id="geoAddress" class="inp" style="flex:1" placeholder="Shop address with city, state, pincode">
|
|
|
948 |
<button class="btn ghost sm" id="geoFindBtn" onclick="geoGeocode()">Find on map</button>
|
|
|
949 |
</div>
|
|
|
950 |
<label class="lbl">Latitude, longitude</label>
|
|
|
951 |
<div style="display:flex;gap:6px">
|
|
|
952 |
<input id="geoLatLng" class="inp" style="flex:1" placeholder="28.6139,77.2090 — or paste a Google Maps link">
|
|
|
953 |
<button class="btn ghost sm" onclick="geoPreview()">Show on map</button>
|
|
|
954 |
</div>
|
|
|
955 |
<div id="geoMapWrap" class="hide" style="margin-top:10px">
|
|
|
956 |
<iframe id="geoMapFrame" style="width:100%;height:220px;border:1px solid var(--line);border-radius:10px" loading="lazy"></iframe>
|
|
|
957 |
<a id="geoOpenMap" class="note" target="_blank" rel="noopener" href="#">Open in Google Maps →</a>
|
|
|
958 |
</div>
|
|
|
959 |
<label class="lbl">Store-board photo (required)</label>
|
|
|
960 |
<input id="geoPhoto" type="file" accept="image/jpeg,image/png" class="inp" onchange="geoPickPhoto()">
|
|
|
961 |
<div class="note">The only evidence that someone actually saw the store-board.</div>
|
|
|
962 |
<img id="geoPhotoImg" class="hide" style="margin-top:8px;max-height:130px;border-radius:8px;border:1px solid var(--line)">
|
|
|
963 |
<label class="lbl">Remark</label>
|
|
|
964 |
<input id="geoRemark" class="inp" placeholder="Optional — how the location was confirmed">
|
|
|
965 |
<div id="geoErr" class="hide" style="margin-top:10px;color:#e03131;font-size:12.5px"></div>
|
|
|
966 |
</div>
|
|
|
967 |
<div class="mf"><button class="btn ghost" onclick="closeGeo()">Cancel</button>
|
|
|
968 |
<button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save & verify</button></div>
|
|
|
969 |
</div>
|
|
|
970 |
</div>
|
|
|
971 |
|
| 37737 |
vikas |
972 |
<!-- One call bar for the whole console. It used to live inside the lead-record card, which meant a
|
|
|
973 |
call started from anywhere else had nowhere to report progress, and switching tabs mid-call
|
|
|
974 |
hid the hang-up button. -->
|
|
|
975 |
<div class="callbar hide" id="gCallBar">
|
|
|
976 |
<div class="cb-head">
|
|
|
977 |
<span class="cb-dot"></span>
|
|
|
978 |
<span id="gCallWho">—</span>
|
|
|
979 |
<span class="cb-time mono" id="gCallTimer">00:00</span>
|
|
|
980 |
</div>
|
|
|
981 |
<div class="cb-state" id="gCallState">Starting…</div>
|
|
|
982 |
<div class="cb-acts">
|
|
|
983 |
<button class="btn ghost sm" onclick="endCall()">Hang up</button>
|
|
|
984 |
<button class="btn coral sm" onclick="openDisp()">Disposition…</button>
|
|
|
985 |
</div>
|
|
|
986 |
<div class="cb-err hide" id="gCallErr"></div>
|
|
|
987 |
</div>
|
|
|
988 |
|
|
|
989 |
<!-- First-call checklist. Opens by itself the moment the first call to a lead is ANSWERED — the
|
|
|
990 |
questions are worth asking while the retailer is on the line, not reconstructed afterwards. -->
|
|
|
991 |
<div class="modal hide" id="chkModal">
|
|
|
992 |
<div class="box">
|
|
|
993 |
<div class="mh"><h2>First call · <span id="chkWho">—</span></h2>
|
|
|
994 |
<p class="desc">Ask these while you have them on the line. Saved against the call and the trail.</p></div>
|
|
|
995 |
<div class="mb" id="chkBody"></div>
|
|
|
996 |
<div class="mf">
|
|
|
997 |
<span class="note" id="chkNote" style="margin-right:auto"></span>
|
|
|
998 |
<button class="btn ghost" onclick="closeChk()">Later</button>
|
|
|
999 |
<button class="btn" style="background:#1f2a5e;color:#fff" id="chkSaveBtn" onclick="saveChk()">Save answers</button>
|
|
|
1000 |
</div>
|
|
|
1001 |
</div>
|
|
|
1002 |
</div>
|
|
|
1003 |
|
|
|
1004 |
<div class="modal hide" id="qModal">
|
|
|
1005 |
<div class="box">
|
|
|
1006 |
<div class="mh"><h2 id="qTitle">New question</h2>
|
|
|
1007 |
<p class="desc">Shows while a call is connected, at the stages you choose.</p></div>
|
|
|
1008 |
<div class="mb">
|
|
|
1009 |
<label class="lbl">Question (required)</label>
|
|
|
1010 |
<input id="qPrompt" class="inp" style="width:100%" placeholder="What should the agent ask?">
|
|
|
1011 |
<div class="grid g2" style="gap:12px">
|
|
|
1012 |
<div>
|
|
|
1013 |
<label class="lbl">Answer type</label>
|
|
|
1014 |
<select id="qType" class="inp" style="width:100%" onchange="qTypeChanged()">
|
|
|
1015 |
<option value="TEXT">Text</option><option value="NUMBER">Number</option>
|
|
|
1016 |
<option value="YESNO">Yes / No</option><option value="CHOICE">Choice</option>
|
|
|
1017 |
</select>
|
|
|
1018 |
</div>
|
|
|
1019 |
<div>
|
|
|
1020 |
<label class="lbl">Points</label>
|
|
|
1021 |
<input id="qPoints" type="number" min="0" step="1" class="inp" style="width:100%" value="0">
|
|
|
1022 |
</div>
|
|
|
1023 |
</div>
|
|
|
1024 |
<div id="qChoicesWrap" class="hide">
|
|
|
1025 |
<label class="lbl">Options — separate with |</label>
|
|
|
1026 |
<input id="qChoices" class="inp" style="width:100%" placeholder="Yes|No|Not sure">
|
|
|
1027 |
</div>
|
|
|
1028 |
<label class="lbl">Show at these stages</label>
|
|
|
1029 |
<div class="opts" id="qStages"></div>
|
|
|
1030 |
<div class="note">None selected means every stage.</div>
|
|
|
1031 |
<div class="grid g2" style="gap:12px">
|
|
|
1032 |
<div>
|
|
|
1033 |
<label class="lbl">Sort order</label>
|
|
|
1034 |
<input id="qOrder" type="number" step="10" class="inp" style="width:100%" value="0">
|
|
|
1035 |
</div>
|
|
|
1036 |
<div>
|
|
|
1037 |
<label class="lbl"> </label>
|
|
|
1038 |
<div style="display:flex;gap:14px;align-items:center;padding-top:8px">
|
|
|
1039 |
<label style="font-size:13px"><input type="checkbox" id="qRequired"> Required</label>
|
|
|
1040 |
<label style="font-size:13px"><input type="checkbox" id="qActive" checked> Active</label>
|
|
|
1041 |
</div>
|
|
|
1042 |
</div>
|
|
|
1043 |
</div>
|
|
|
1044 |
<div id="qErr" class="hide" style="margin-top:10px;color:#e03131;font-size:12.5px"></div>
|
|
|
1045 |
</div>
|
|
|
1046 |
<div class="mf">
|
|
|
1047 |
<button class="btn ghost" id="qDelBtn" onclick="deleteQuestion()" style="margin-right:auto">Delete</button>
|
|
|
1048 |
<button class="btn ghost" onclick="closeQ()">Cancel</button>
|
|
|
1049 |
<button class="btn" style="background:#1f2a5e;color:#fff" id="qSaveBtn" onclick="saveQuestion()">Save</button>
|
|
|
1050 |
</div>
|
|
|
1051 |
</div>
|
|
|
1052 |
</div>
|
|
|
1053 |
|
|
|
1054 |
<div class="modal hide" id="asgModal">
|
|
|
1055 |
<div class="box">
|
|
|
1056 |
<div class="mh"><h2>Assign lead</h2>
|
|
|
1057 |
<p class="desc" id="asgWho">—</p></div>
|
|
|
1058 |
<div class="mb">
|
|
|
1059 |
<input id="asgSearch" class="inp" style="width:100%" placeholder="Filter by name or tier…"
|
|
|
1060 |
oninput="renderAssignList()">
|
|
|
1061 |
<div style="display:flex;gap:6px;margin:10px 0 4px;flex-wrap:wrap" id="asgTiers"></div>
|
|
|
1062 |
<div id="asgList" style="max-height:340px;overflow-y:auto;margin-top:6px"></div>
|
|
|
1063 |
</div>
|
|
|
1064 |
<div class="mf"><button class="btn ghost" onclick="closeAssign()">Cancel</button></div>
|
|
|
1065 |
</div>
|
|
|
1066 |
</div>
|
|
|
1067 |
|
| 37760 |
vikas |
1068 |
<div class="modal hide" id="rgnModal">
|
|
|
1069 |
<div class="box">
|
|
|
1070 |
<div class="mh"><h2>Region owner</h2>
|
|
|
1071 |
<p class="desc" id="rgnWho">—</p></div>
|
|
|
1072 |
<div class="mb">
|
|
|
1073 |
<p class="desc" style="margin-bottom:8px">Pick the BM/RSM who should own this region. This writes the region's Sales L4 position, which the engine reads first; leads already assigned keep their owner.</p>
|
|
|
1074 |
<input id="rgnSearch" class="inp" style="width:100%" placeholder="Filter by name, tier or region…" oninput="renderRegionOwnerList()">
|
|
|
1075 |
<div id="rgnList" style="max-height:340px;overflow-y:auto;margin-top:10px"></div>
|
|
|
1076 |
</div>
|
|
|
1077 |
<div class="mf"><button class="btn ghost" onclick="closeRegionOwner()">Cancel</button></div>
|
|
|
1078 |
</div>
|
|
|
1079 |
</div>
|
|
|
1080 |
|
| 37125 |
vikas |
1081 |
<div class="toastwrap" id="toasts"></div>
|
|
|
1082 |
|
|
|
1083 |
<script>
|
|
|
1084 |
var CTX = "${rc.contextPath}";
|
|
|
1085 |
var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
|
| 37784 |
vikas |
1086 |
var STATE = { regionId:'', agentId:'', from:'$!fromDate', to:'$!toDate' };
|
| 37125 |
vikas |
1087 |
var DATA = { summary:null, state:null, heat:null, ageing:null };
|
|
|
1088 |
|
|
|
1089 |
function byId(x){ return document.getElementById(x); }
|
|
|
1090 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
|
|
1091 |
function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
|
| 37692 |
vikas |
1092 |
/**
|
|
|
1093 |
* Business value in lakhs.
|
|
|
1094 |
*
|
|
|
1095 |
* Two conventions live in user.lead.potential: the form captures LAKHS (rows hold 1–99), but
|
|
|
1096 |
* ~1,355 older rows were entered in RUPEES (1,000 – 2,00,00,000). Nothing between 100 and 999
|
|
|
1097 |
* exists, so the gap is a safe separator: 1000 and above is read as rupees and brought onto the
|
|
|
1098 |
* lakh scale. Normalised on read only — no stored value is rewritten.
|
|
|
1099 |
*/
|
|
|
1100 |
var LAKH_CUTOFF=1000;
|
|
|
1101 |
function toLakh(v){ v=Number(v); return v>=LAKH_CUTOFF ? v/100000 : v; }
|
|
|
1102 |
function inrShort(v){ if(v==null||isNaN(v)||Number(v)<=0) return '₹0';
|
|
|
1103 |
var l=toLakh(v);
|
|
|
1104 |
// Under a lakh needs two decimals: a ₹1,000 lead is 0.01L, and one decimal would print it as
|
|
|
1105 |
// ₹0L — the same information loss the old scaling caused, just at the other end.
|
|
|
1106 |
var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
|
|
|
1107 |
return '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
|
| 37125 |
vikas |
1108 |
|
| 37743 |
vikas |
1109 |
/**
|
|
|
1110 |
* "1 - 21 Sep 2026" / "28 Aug - 21 Sep 2026" / "21 Dec 2025 - 3 Jan 2026".
|
|
|
1111 |
* ISO on both ends made the reader parse two 10-character strings to see it was one month.
|
|
|
1112 |
*/
|
|
|
1113 |
function periodLabel(from,to){
|
|
|
1114 |
if(!from||!to) return (from||to||'\u2014');
|
|
|
1115 |
var a=new Date(from), b=new Date(to);
|
|
|
1116 |
if(isNaN(a)||isNaN(b)) return from+' \u2192 '+to;
|
|
|
1117 |
var M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
1118 |
var sameY=a.getFullYear()===b.getFullYear(), sameM=sameY&&a.getMonth()===b.getMonth();
|
|
|
1119 |
if(sameM) return a.getDate()+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
|
|
1120 |
if(sameY) return a.getDate()+' '+M[a.getMonth()]+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
|
|
1121 |
return a.getDate()+' '+M[a.getMonth()]+' '+a.getFullYear()+' \u2013 '+b.getDate()+' '+M[b.getMonth()]+' '+b.getFullYear();
|
|
|
1122 |
}
|
|
|
1123 |
|
|
|
1124 |
function setDashPeriod(){ byId('dashPeriod').textContent=periodLabel(STATE.from,STATE.to); }
|
|
|
1125 |
|
| 37125 |
vikas |
1126 |
// ---- tabs
|
| 37737 |
vikas |
1127 |
var SCREENS=['cmd','record','create','assign','live','dash','chk','audit'];
|
| 37125 |
vikas |
1128 |
var LOADED={};
|
|
|
1129 |
function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
|
| 37737 |
vikas |
1130 |
// Leaving the live tab must stop its poll; entering it starts a fresh one.
|
|
|
1131 |
if(id==='live'){ startLive(); } else { stopLive(); }
|
|
|
1132 |
if(id==='chk') loadQuestions();
|
| 37125 |
vikas |
1133 |
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');
|
| 37743 |
vikas |
1134 |
if(id==='dash'){ setDashPeriod(); loadBgc(); }
|
| 37125 |
vikas |
1135 |
if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }
|
|
|
1136 |
if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }
|
|
|
1137 |
if(id==='audit'){ buildAudit(); } }
|
|
|
1138 |
|
|
|
1139 |
// ---- toast
|
|
|
1140 |
function toast(msg,ok){ var w=byId('toasts'); var t=document.createElement('div'); t.className='toast';
|
|
|
1141 |
t.innerHTML=(ok?'<span class="ok">✓</span>':'')+esc(msg); w.appendChild(t);
|
|
|
1142 |
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity .3s'; setTimeout(function(){ t.remove(); },300); },2600); }
|
|
|
1143 |
|
|
|
1144 |
// ---- filters + fetch
|
|
|
1145 |
function qs(){ var q=''; if(STATE.regionId)q+='®ionId='+encodeURIComponent(STATE.regionId);
|
| 37784 |
vikas |
1146 |
if(STATE.agentId)q+='&agentId='+encodeURIComponent(STATE.agentId);
|
| 37125 |
vikas |
1147 |
if(STATE.from)q+='&from='+encodeURIComponent(STATE.from); if(STATE.to)q+='&to='+encodeURIComponent(STATE.to); return q; }
|
| 37651 |
vikas |
1148 |
function getJSON(path,cb,onErr){ fetch(CTX+path,{credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'}})
|
|
|
1149 |
.then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
|
|
|
1150 |
.then(function(j){ cb((j&&j.response!==undefined)?j.response:j); })
|
|
|
1151 |
.catch(function(e){ console.error('LMS fetch failed',path,e);
|
|
|
1152 |
if(typeof onErr==='function'){ onErr(e); } else { toast('Could not load data'); } }); }
|
| 37125 |
vikas |
1153 |
|
|
|
1154 |
// ---- stage helpers
|
|
|
1155 |
var STCLASS={NEW:'st-new',ASSIGNED:'st-assigned',CONTACTED:'st-contacted',QUALIFIED:'st-qualified',
|
|
|
1156 |
BEAT_PLANNED:'st-beat',VISITED:'st-visited',ONBOARDED:'st-onboarded',ACTIVE:'st-active',NOT_INTERESTED:'st-ni',DROPPED:'st-dead'};
|
|
|
1157 |
function stagePill(st,lb){ if(!st) return '<span class="stage st-new">—</span>'; return '<span class="stage '+(STCLASS[st]||'st-new')+'">'+esc(lb||st)+'</span>'; }
|
|
|
1158 |
function slaCell(state){ switch(state){
|
|
|
1159 |
case'MET': return '<span class="sla ok"><span class="slabar"><i style="width:100%;background:#2f9e44"></i></span><span class="clk">met</span></span>';
|
|
|
1160 |
case'RUNNING': return '<span class="sla ok"><span class="slabar"><i style="width:50%;background:#1f2a5e"></i></span><span class="clk">running</span></span>';
|
|
|
1161 |
case'AT_RISK': return '<span class="sla warn"><span class="slabar"><i style="width:85%;background:#f08c00"></i></span><span class="clk">< 1 hr</span></span>';
|
|
|
1162 |
case'BREACHED': return '<span class="sla bad"><span class="slabar"><i style="width:100%;background:#e03131"></i></span><span class="clk">breached</span></span>';
|
|
|
1163 |
default: return '<span style="color:#97a1b5;font-size:12px">—</span>'; } }
|
|
|
1164 |
|
|
|
1165 |
// ---- COMMAND CENTER
|
|
|
1166 |
var CC_KPI={ 'new':{i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, awaiting:{i:'⏳',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'},
|
|
|
1167 |
sla:{i:'⚠',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, contacted:{i:'📞',bg:'#e3fafc',fg:'#0c8599'},
|
|
|
1168 |
qualified:{i:'✓',bg:'#e6fcf5',fg:'#0ca678'}, onboarded:{i:'🏆',bg:'#ebfbee',fg:'#2f9e44'} };
|
|
|
1169 |
var ATT_META={ BREACHED_NO_CONTACT:{i:'⚠',bg:'#fff0f0',fg:'#e03131',c:'badflag'}, HOLD:{i:'👤',bg:'#fff0f0',fg:'#e03131',c:'badflag'},
|
|
|
1170 |
SLA_UNDER_1H:{i:'⏳',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'}, MISSING_DISP:{i:'💬',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'},
|
|
|
1171 |
QUALIFIED_NO_BEAT:{i:'🕑',bg:'#eef1f6',fg:'#69748c',c:''}, ONBOARDED_PENDING:{i:'🔄',bg:'#f3f0ff',fg:'#7048e8',c:''} };
|
|
|
1172 |
|
|
|
1173 |
function renderSummary(d){ DATA.summary=d;
|
|
|
1174 |
var kh=''; (d.kpis||[]).forEach(function(k){ var m=CC_KPI[k.key]||{i:'📊',bg:'#eef1f6',fg:'#69748c'};
|
|
|
1175 |
kh+='<div class="kpi" data-kpi="'+esc(k.key)+'"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+
|
|
|
1176 |
'<div class="label">'+esc(k.label)+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+fmtInt(k.value)+'</div>'+
|
|
|
1177 |
'<div class="foot">'+esc(k.foot)+'</div></div>'; });
|
|
|
1178 |
byId('ccKpis').innerHTML=kh;
|
|
|
1179 |
renderFunnel(d.funnel||[]);
|
|
|
1180 |
var ah=''; (d.attention||[]).forEach(function(a){ var m=ATT_META[a.key]||{i:'📊',bg:'#eef1f6',fg:'#69748c',c:''};
|
|
|
1181 |
ah+='<div class="att" data-att="'+esc(a.key)+'" data-attt="'+esc(a.title)+'"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+
|
|
|
1182 |
'<div class="ab"><div class="atitle">'+esc(a.title)+'</div><div class="adet">'+esc(a.detail)+'</div></div>'+
|
|
|
1183 |
'<div class="acount '+m.c+'">'+fmtInt(a.count)+'</div></div>'; });
|
|
|
1184 |
byId('ccAttention').innerHTML=ah;
|
|
|
1185 |
buildDashKpis(); buildFix();
|
|
|
1186 |
}
|
|
|
1187 |
|
|
|
1188 |
function renderFunnel(funnel){
|
|
|
1189 |
var byStage={}; funnel.forEach(function(s){ byStage[s.stage]=s; });
|
| 37749 |
vikas |
1190 |
// First box is every lead created in the period (server key ALL) -- not the NEW stage, which is
|
|
|
1191 |
// the "New" KPI card. BEAT_PLANNED / VISITED / ONBOARDED are evidence-derived on the server.
|
|
|
1192 |
var seq=[['ALL','All leads'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],['BEAT_PLANNED','Beat planned'],['VISITED','Visited'],['ONBOARDED','Onboarded']];
|
| 37783 |
vikas |
1193 |
var h=''; var prev=null, prevKey=null;
|
| 37125 |
vikas |
1194 |
seq.forEach(function(p,idx){ var s=byStage[p[0]]; if(!s) return; var last=(idx===seq.length-1);
|
| 37783 |
vikas |
1195 |
// Leads that fell out between the previous box and this one: at the previous stage exactly,
|
|
|
1196 |
// or -- for the first gap -- anything in scope that never reached ASSIGNED (still NEW, or
|
|
|
1197 |
// closed as NOT_INTERESTED / DROPPED). Same buckets the drill endpoint already understands.
|
|
|
1198 |
var dropKey = prevKey===null ? '' : (prevKey==='ALL' ? 'NEW,NOT_INTERESTED,DROPPED' : prevKey);
|
|
|
1199 |
var dropAttr = dropKey ? ' data-drop="'+esc(dropKey)+'" data-droplabel="'+esc('Dropped before '+p[1])+'" title="Open the leads that did not reach '+esc(p[1])+'"' : '';
|
| 37125 |
vikas |
1200 |
var cap; if(idx===0){ cap='<div class="keep">created</div>'; }
|
| 37783 |
vikas |
1201 |
else if(last){ var dropL=prev?prev.count-s.count:0;
|
|
|
1202 |
cap='<div class="keep"><span class="fcap"'+dropAttr+'>'+s.pct+'% conversion'+(dropL>0?(' · −'+fmtInt(dropL)):'')+'</span></div>'; }
|
|
|
1203 |
else { var drop=prev?prev.count-s.count:0;
|
|
|
1204 |
cap=drop>0?('<div class="drop"><span class="fcap"'+dropAttr+'>−'+fmtInt(drop)+'</span></div>')
|
|
|
1205 |
:('<div class="keep"><span class="fcap"'+dropAttr+'>'+s.pct+'%</span></div>'); }
|
| 37125 |
vikas |
1206 |
h+='<div class="fstep" data-stage="'+p[0]+'" data-label="'+esc(p[1])+'"><div class="fn">'+esc(p[1])+'</div><div class="fv">'+fmtInt(s.count)+'</div>'+
|
|
|
1207 |
'<div class="fbar" style="width:'+Math.max(s.pct,3)+'%"></div>'+cap+(last?'':'<span class="arrow">➤</span>')+'</div>';
|
| 37783 |
vikas |
1208 |
prev=s; prevKey=p[0]; });
|
| 37125 |
vikas |
1209 |
byId('ccFunnel').innerHTML=h||'<div class="ph">No leads in scope.</div>';
|
|
|
1210 |
}
|
|
|
1211 |
|
| 37692 |
vikas |
1212 |
// ---- Lead pipeline: filtered, sorted and paged on the server ------------------------------
|
|
|
1213 |
// PIPE.q is the search term. While it is set the date filter does not apply — a search hunts for
|
|
|
1214 |
// one known retailer, and quietly scoping it to the selected month would report "not found" for a
|
|
|
1215 |
// lead that exists. The caption says which mode is in force so that is never a surprise.
|
| 37784 |
vikas |
1216 |
var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
|
|
|
1217 |
// Call Center users for the page-level agent filter (header). "Their" leads = created by them OR
|
|
|
1218 |
// owned by them; qs() carries it, so cards, funnel, attention queue, pipeline and every drawer
|
|
|
1219 |
// narrow together.
|
| 37760 |
vikas |
1220 |
function loadCallCenterUsers(){
|
|
|
1221 |
getJSON('/lms/dashboard/callcenter-users', function(d){
|
| 37784 |
vikas |
1222 |
var sel=byId('fAgent'); if(!sel) return; var keep=sel.value;
|
| 37760 |
vikas |
1223 |
var users=(d&&d.users)||[];
|
| 37784 |
vikas |
1224 |
var h='<option value="">'+(users.length?'All Call Center users':'No Call Center users found')+'</option>';
|
| 37760 |
vikas |
1225 |
users.forEach(function(u){ h+='<option value="'+u.authId+'">'+esc(u.name)+'</option>'; });
|
|
|
1226 |
sel.innerHTML=h; sel.value=keep||'';
|
| 37784 |
vikas |
1227 |
}, function(){ var sel=byId('fAgent'); if(sel) sel.innerHTML='<option value="">Call Center list unavailable</option>'; });
|
| 37760 |
vikas |
1228 |
}
|
| 37784 |
vikas |
1229 |
// Search mode ignores the date window but keeps the scope filters (region + agent).
|
|
|
1230 |
function scopeQs(){ var q=''; if(STATE.regionId) q+='®ionId='+encodeURIComponent(STATE.regionId);
|
|
|
1231 |
if(STATE.agentId) q+='&agentId='+encodeURIComponent(STATE.agentId); return q; }
|
| 37125 |
vikas |
1232 |
|
| 37692 |
vikas |
1233 |
function loadPipeline(){
|
| 37737 |
vikas |
1234 |
byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">Loading…</div></td></tr>';
|
| 37692 |
vikas |
1235 |
var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
|
| 37784 |
vikas |
1236 |
'&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);
|
|
|
1237 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q)+scopeQs(); }
|
| 37692 |
vikas |
1238 |
else { p+=qs(); }
|
|
|
1239 |
getJSON(p,function(d){
|
|
|
1240 |
d=d||{}; var rows=d.rows||[];
|
|
|
1241 |
PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
|
|
|
1242 |
renderPipeHead();
|
|
|
1243 |
if(!rows.length){
|
| 37737 |
vikas |
1244 |
byId('ccPipeline').innerHTML='<tr><td colspan="10"><div class="ph">'+
|
| 37692 |
vikas |
1245 |
(PIPE.q?('No lead matches “'+esc(PIPE.q)+'”.'):'No leads in scope.')+'</div></td></tr>';
|
|
|
1246 |
} else {
|
|
|
1247 |
var h='';
|
|
|
1248 |
rows.forEach(function(l){
|
|
|
1249 |
var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');
|
|
|
1250 |
h+='<tr class="clickrow '+rc+'" data-leadid="'+l.id+'">'+
|
|
|
1251 |
'<td class="nowrap"><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
|
|
1252 |
'<td class="wrapok"><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
|
|
1253 |
'<td class="nowrap">'+(l.path==='B'?'<span class="tag b">B · Field</span>':'<span class="tag a">A · BGC</span>')+'</td>'+
|
|
|
1254 |
'<td class="nowrap">'+stagePill(l.stage,l.stageLabel)+'</td>'+
|
|
|
1255 |
// title= so a name clipped by the fixed width is still readable on hover.
|
|
|
1256 |
'<td class="nowrap" title="'+esc(l.owner||'')+'">'+esc(l.owner||'—')+'</td>'+
|
|
|
1257 |
'<td class="nowrap" title="'+esc(l.city||'')+'">'+esc(l.city||'—')+'</td>'+
|
|
|
1258 |
'<td class="nowrap" title="'+esc(l.createdBy||'')+'">'+esc(l.createdBy||'—')+'</td>'+
|
|
|
1259 |
'<td class="nowrap">'+slaCell(l.slaState)+'</td>'+
|
| 37737 |
vikas |
1260 |
'<td class="num nowrap">'+inrShort(l.value)+'</td>'+
|
|
|
1261 |
// data-call carries the lead so the handler needs nothing from the row's other cells.
|
|
|
1262 |
'<td class="nowrap" style="display:flex;gap:5px">'+
|
|
|
1263 |
'<button class="rowcall" data-call="'+l.id+'" data-callname="'+esc(l.name||'')+'"'+
|
|
|
1264 |
' title="Call '+esc(l.name||('lead #'+l.id))+'">☎</button>'+
|
|
|
1265 |
'<button class="rowedit" data-editlead="'+l.id+'"'+
|
|
|
1266 |
' title="Edit '+esc(l.name||('lead #'+l.id))+'">✎</button>'+
|
|
|
1267 |
'<button class="rowassign" data-assign="'+l.id+'" data-assignname="'+esc(l.name||'')+'"'+
|
|
|
1268 |
' data-assignowner="'+esc(l.owner||'')+'" data-assignregion="'+esc(l.regionId||'')+'"'+
|
|
|
1269 |
' title="Assign '+esc(l.name||('lead #'+l.id))+'">👤</button>'+
|
|
|
1270 |
'</td></tr>';
|
| 37692 |
vikas |
1271 |
});
|
|
|
1272 |
byId('ccPipeline').innerHTML=h;
|
|
|
1273 |
}
|
|
|
1274 |
// Say how many there actually are, not how many were drawn.
|
|
|
1275 |
var shown=rows.length, first=(PIPE.page-1)*PIPE.pageSize+(shown?1:0), last=(PIPE.page-1)*PIPE.pageSize+shown;
|
|
|
1276 |
byId('pipeCount').textContent=PIPE.total
|
|
|
1277 |
? ('Showing '+first+'–'+last+' of '+fmtInt(PIPE.total)+(PIPE.q?' matching leads (all dates)':' leads in the selected period'))
|
|
|
1278 |
: '';
|
|
|
1279 |
byId('pipePageLbl').textContent='Page '+PIPE.page+' / '+Math.max(PIPE.pages,1);
|
|
|
1280 |
byId('pipePrev').disabled=(PIPE.page<=1);
|
|
|
1281 |
byId('pipeNext').disabled=(PIPE.page>=PIPE.pages);
|
|
|
1282 |
byId('pipeDesc').innerHTML=PIPE.q
|
|
|
1283 |
? 'Searching every lead for “<b>'+esc(PIPE.q)+'</b>” — the date filter is ignored while a search is active.'
|
|
|
1284 |
: 'Click any row → full LMS record & trail. Rows shaded by SLA urgency.';
|
|
|
1285 |
byId('pipeClear').classList.toggle('hide', !PIPE.q);
|
|
|
1286 |
});
|
|
|
1287 |
}
|
|
|
1288 |
|
|
|
1289 |
/**
|
|
|
1290 |
* Mark the active column and its direction.
|
|
|
1291 |
*
|
|
|
1292 |
* The label is captured once into data-label and re-read from there. Deriving it from
|
|
|
1293 |
* textContent instead re-read the arrow this function had just appended, so every sort click
|
|
|
1294 |
* stacked another one onto the header.
|
|
|
1295 |
*/
|
|
|
1296 |
function renderPipeHead(){
|
|
|
1297 |
document.querySelectorAll('th.sortable').forEach(function(th){
|
|
|
1298 |
if(!th.hasAttribute('data-label')) th.setAttribute('data-label', th.textContent.trim());
|
|
|
1299 |
var on=(th.getAttribute('data-sort')===PIPE.sort);
|
|
|
1300 |
th.classList.toggle('on',on);
|
|
|
1301 |
th.innerHTML=esc(th.getAttribute('data-label'))+
|
|
|
1302 |
(on?'<span class="arrow '+(PIPE.dir==='asc'?'up':'down')+'"></span>':'');
|
|
|
1303 |
});
|
|
|
1304 |
}
|
|
|
1305 |
|
|
|
1306 |
function pipeSort(col){
|
|
|
1307 |
// Same column toggles direction; a new column starts descending, which is what people expect
|
|
|
1308 |
// from value and dates and is harmless for the text columns.
|
|
|
1309 |
if(PIPE.sort===col){ PIPE.dir=(PIPE.dir==='asc'?'desc':'asc'); }
|
|
|
1310 |
else { PIPE.sort=col; PIPE.dir='desc'; }
|
|
|
1311 |
PIPE.page=1; loadPipeline();
|
|
|
1312 |
}
|
|
|
1313 |
function pipeSearch(){
|
|
|
1314 |
var v=(byId('pipeQ').value||'').trim();
|
|
|
1315 |
if(v && v.length<2){ toast('Type at least 2 characters'); return; }
|
|
|
1316 |
PIPE.q=v; PIPE.page=1; loadPipeline();
|
|
|
1317 |
}
|
|
|
1318 |
function pipeClearSearch(){ byId('pipeQ').value=''; PIPE.q=''; PIPE.page=1; loadPipeline(); }
|
|
|
1319 |
function pipePage(step){
|
|
|
1320 |
var n=PIPE.page+step;
|
|
|
1321 |
if(n<1||n>PIPE.pages) return;
|
|
|
1322 |
PIPE.page=n; loadPipeline();
|
|
|
1323 |
}
|
|
|
1324 |
|
| 37125 |
vikas |
1325 |
function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];
|
|
|
1326 |
case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];
|
| 37743 |
vikas |
1327 |
case'qualified':return['STAGE','QUALIFIED'];
|
|
|
1328 |
// The Onboarded tile counts ONBOARDED + ACTIVE, so its drill has to as well.
|
|
|
1329 |
case'onboarded':return['STAGE_IN','ONBOARDED,ACTIVE'];default:return['ALL','']; } }
|
| 37125 |
vikas |
1330 |
|
|
|
1331 |
// ---- DASHBOARDS
|
|
|
1332 |
var DASH_KPI=[ {i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'📈',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
|
|
|
1333 |
{i:'⚖',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'📞',bg:'#e3fafc',fg:'#0c8599'},
|
|
|
1334 |
{i:'⌛',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'💰',bg:'#ebfbee',fg:'#2f9e44'} ];
|
|
|
1335 |
function pctColor(p){ return p>=25?'#2f9e44':(p>=18?'#f08c00':(p>=10?'#e8590c':'#e03131')); }
|
|
|
1336 |
function slaColor(p){ return p>=85?'#2f9e44':(p>=70?'#f08c00':'#e03131'); }
|
|
|
1337 |
|
|
|
1338 |
function renderState(d){ DATA.state=d; var rows=(d&&d.rows)?d.rows:[];
|
|
|
1339 |
var h=''; rows.forEach(function(r){ var cls=r.convPct<15?'rowbad':(r.slaPct<60?'rowwarn':'');
|
|
|
1340 |
h+='<tr class="clickrow '+cls+'" data-region="'+esc(r.regionCode)+'" data-rn="'+esc(r.regionName)+'">'+
|
|
|
1341 |
'<td><b>'+esc(r.regionName)+'</b> <span class="idpill">'+esc(r.regionCode)+'</span></td>'+
|
|
|
1342 |
'<td class="num">'+fmtInt(r.newCount)+'</td><td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
| 37749 |
vikas |
1343 |
'<td class="num">'+fmtInt(r.beatPlanned)+'</td><td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
| 37125 |
vikas |
1344 |
'<td class="num"><b style="color:'+pctColor(r.convPct)+'">'+r.convPct+'%</b></td>'+
|
|
|
1345 |
'<td class="num"><b style="color:'+slaColor(r.slaPct)+'">'+r.slaPct+'%</b></td></tr>'; });
|
| 37749 |
vikas |
1346 |
byId('dashState').innerHTML=h||'<tr><td colspan="9"><div class="ph">No regional data.</div></td></tr>';
|
| 37125 |
vikas |
1347 |
buildDashKpis(); buildFix();
|
|
|
1348 |
}
|
|
|
1349 |
|
|
|
1350 |
function renderHeat(d){ DATA.heat=d; var weeks=(d&&d.weeks)?d.weeks:[],bms=(d&&d.bms)?d.bms:[];
|
|
|
1351 |
if(!bms.length){ byId('dashHeat').innerHTML='<div class="ph">No SLA data for the selected period.</div>'; return; }
|
|
|
1352 |
function hc(p){ if(p>=90)return'#40c057';if(p>=80)return'#82c91e';if(p>=70)return'#fab005';if(p>=60)return'#fd7e14';if(p>=50)return'#fa5252';return'#e03131'; }
|
|
|
1353 |
var h='<table class="heat"><thead><tr><td style="background:none;border:0"></td>';
|
|
|
1354 |
weeks.forEach(function(w){ h+='<td style="background:none;border:0;color:#97a1b5;font-size:10px;font-weight:700">'+esc(w)+'</td>'; });
|
|
|
1355 |
h+='</tr></thead><tbody>';
|
|
|
1356 |
bms.forEach(function(bm){ h+='<tr><td style="background:none;color:#69748c;font-size:11px;text-align:left;width:auto;border:0">'+esc(bm.bmName)+'</td>';
|
|
|
1357 |
(bm.cells||[]).forEach(function(c){ if(c.pct==null){ h+='<td style="background:#eef1f6;color:#97a1b5">–</td>'; }
|
|
|
1358 |
else { var col=hc(c.pct),tc=(c.pct>=70&&c.pct<80)?'#333':'#fff'; h+='<td style="background:'+col+';color:'+tc+'">'+c.pct+'</td>'; } }); h+='</tr>'; });
|
|
|
1359 |
h+='</tbody></table>'; byId('dashHeat').innerHTML=h;
|
|
|
1360 |
}
|
|
|
1361 |
|
|
|
1362 |
function renderAgeing(d){ DATA.ageing=d; buildDashKpis(); buildFix(); }
|
|
|
1363 |
|
| 37737 |
vikas |
1364 |
/** Telephony totals for the calls/day tile. Failure leaves the tile honest rather than zeroed. */
|
|
|
1365 |
function loadBgc(){
|
|
|
1366 |
getJSON('/lms/dashboard/bgc-daily'+qsDates(), function(d){ DATA.bgc=d||null; buildDashKpis(); },
|
|
|
1367 |
function(){ DATA.bgc=null; buildDashKpis(); });
|
|
|
1368 |
}
|
|
|
1369 |
|
|
|
1370 |
/** Days in the selected period — the calls/day tile divided by a hardcoded 90 regardless of it. */
|
|
|
1371 |
function periodDays(){
|
|
|
1372 |
if(!STATE.from || !STATE.to) return 1;
|
|
|
1373 |
var a=new Date(STATE.from), b=new Date(STATE.to);
|
|
|
1374 |
if(isNaN(a)||isNaN(b)) return 1;
|
|
|
1375 |
return Math.max(1, Math.round((b-a)/86400000)+1);
|
|
|
1376 |
}
|
|
|
1377 |
|
| 37125 |
vikas |
1378 |
function buildDashKpis(){ var s=DATA.summary; if(!s) return;
|
|
|
1379 |
var tot=0,acc=0; ((DATA.state&&DATA.state.rows)||[]).forEach(function(r){ tot+=r.newCount; acc+=r.slaPct*r.newCount; });
|
|
|
1380 |
var sla=tot>0?Math.round(acc/tot):0;
|
| 37737 |
vikas |
1381 |
|
|
|
1382 |
// Calls/day from the real call log. It used to be CONTACTED ÷ 90 labelled "proxy · no
|
|
|
1383 |
// telephony" — a stage count divided by a constant that stopped matching the period the moment
|
|
|
1384 |
// the default window became the current month. Now: calls placed ÷ days in the selected period,
|
|
|
1385 |
// with the connect rate underneath, or an honest dash when the telephony data has not loaded.
|
|
|
1386 |
var callsTile='—', callsFoot='no calls in period';
|
|
|
1387 |
if(DATA.bgc){
|
|
|
1388 |
var days=periodDays();
|
|
|
1389 |
callsTile=fmtInt(Math.round((DATA.bgc.calls||0)/days));
|
|
|
1390 |
callsFoot=(DATA.bgc.calls||0)+' calls · '+(DATA.bgc.connectPct||0)+'% connected';
|
|
|
1391 |
}
|
|
|
1392 |
|
|
|
1393 |
var ageDays=(DATA.ageing&&DATA.ageing.days)?DATA.ageing.days:7;
|
| 37125 |
vikas |
1394 |
var vals=[ {v:fmtInt(s.total),f:'in period'}, {v:s.conversionPct+'<small>%</small>',f:'new → onboarded'},
|
| 37737 |
vikas |
1395 |
{v:sla+'<small>%</small>',f:'first contact ≤ 5h'}, {v:callsTile,f:callsFoot},
|
|
|
1396 |
{v:DATA.ageing?fmtInt(DATA.ageing.total):'—',f:'stuck by stage'},
|
|
|
1397 |
// inrShort, not fmtInr: business value is stored in lakhs and every other figure on the page
|
|
|
1398 |
// renders it that way. fmtInr printed a bare ₹139 for what is ₹139 lakh.
|
|
|
1399 |
{v:inrShort(s.onboardedValue),f:'est. potential'} ];
|
|
|
1400 |
var labels=['LMS created','Conversion','SLA compliance','Calls / day (BGC)',
|
|
|
1401 |
'Ageing > '+ageDays+' days','Onboarded value'];
|
| 37125 |
vikas |
1402 |
var h=''; vals.forEach(function(x,i){ var m=DASH_KPI[i];
|
|
|
1403 |
h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+
|
|
|
1404 |
'<div class="label">'+labels[i]+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+x.v+'</div><div class="foot">'+x.f+'</div></div>'; });
|
|
|
1405 |
byId('dashKpis').innerHTML=h;
|
|
|
1406 |
}
|
|
|
1407 |
|
|
|
1408 |
function buildFix(){ var rows=((DATA.state&&DATA.state.rows)||[]).slice();
|
|
|
1409 |
var out='';
|
|
|
1410 |
if(rows.length){ var worst=rows.slice().sort(function(a,b){return a.convPct-b.convPct;})[0];
|
|
|
1411 |
out+='<div class="fix"><div class="sev" style="background:#e03131"></div><div class="fb"><div class="ft">'+esc(worst.regionName)+' is leaking at the top</div>'+
|
|
|
1412 |
'<div class="fd"><b>'+fmtInt(worst.newCount)+' created, only '+worst.convPct+'% onboarded and '+worst.slaPct+'% SLA</b> — the weakest region this period.</div>'+
|
|
|
1413 |
'<div class="act">→ Review its stuck leads; check BM mapping / HOLD queue.</div></div></div>';
|
|
|
1414 |
var lowSla=rows.slice().sort(function(a,b){return a.slaPct-b.slaPct;})[0];
|
|
|
1415 |
if(lowSla && lowSla.regionCode!==worst.regionCode){ out+='<div class="fix"><div class="sev" style="background:#f59f00"></div><div class="fb"><div class="ft">'+esc(lowSla.regionName)+' SLA slipping</div>'+
|
|
|
1416 |
'<div class="fd"><b>'+lowSla.slaPct+'% on-time first contact</b> — first-contact discipline is breaking down.</div>'+
|
|
|
1417 |
'<div class="act">→ Enforce ASM mapping (≤2h) + coaching.</div></div></div>'; } }
|
|
|
1418 |
var age=DATA.ageing?DATA.ageing.total:0;
|
|
|
1419 |
out+='<div class="fix"><div class="sev" style="background:#f59f00"></div><div class="fb"><div class="ft">'+fmtInt(age)+' leads ageing > 7 days</div>'+
|
|
|
1420 |
'<div class="fd"><b>Stuck open leads by stage</b> — qualified leads not being added to beats lose revival odds.</div>'+
|
|
|
1421 |
'<div class="act">→ Nudge ASMs to beat-plan qualified LMS within 48h.</div></div></div>';
|
|
|
1422 |
byId('dashFix').innerHTML=out;
|
|
|
1423 |
}
|
|
|
1424 |
|
|
|
1425 |
// ---- drill drawer
|
|
|
1426 |
function openDrawer(crumb,title,sub,html){ byId('dCrumb').textContent=crumb||''; byId('dTitle').textContent=title||'';
|
| 37743 |
vikas |
1427 |
byId('dSub').textContent=sub||'';
|
|
|
1428 |
// The filter for a drawer table lives in the drawer header, so it has to go when the body it
|
|
|
1429 |
// belongs to is replaced — otherwise the next drawer opens wearing the last one's filter.
|
|
|
1430 |
byId('dTools').innerHTML='';
|
|
|
1431 |
byId('dBody').innerHTML=html||''; byId('ov').classList.add('open'); byId('drawer').classList.add('open'); }
|
| 37125 |
vikas |
1432 |
function closeDrawer(){ byId('ov').classList.remove('open'); byId('drawer').classList.remove('open'); }
|
|
|
1433 |
document.addEventListener('keydown',function(e){ if(e.key==='Escape')closeDrawer();
|
|
|
1434 |
if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){ e.preventDefault(); byId('lmsSearch').focus(); } });
|
|
|
1435 |
|
| 37743 |
vikas |
1436 |
// No SLA column: the drawer lists leads by stage, region or search term, and the first-contact
|
|
|
1437 |
// clock only ever ran on leads created through the LMS -- so the column was a wall of em-dashes
|
|
|
1438 |
// with the occasional breach, carrying no information the drawer was opened to find. SLA lives
|
|
|
1439 |
// where it is the subject: the pipeline, the attention queue, and the SLA Compliance report.
|
| 37125 |
vikas |
1440 |
function drawerLeadTable(leads){ if(!leads||!leads.length) return '<div class="ph">No leads in this bucket.</div>';
|
| 37743 |
vikas |
1441 |
var h='<div class="card"><table><thead><tr><th>LMS ID</th><th>Business</th><th>Stage</th><th>Owner</th><th class="num">Value</th></tr></thead><tbody>';
|
| 37125 |
vikas |
1442 |
leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
|
|
1443 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
| 37743 |
vikas |
1444 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td>'+
|
| 37125 |
vikas |
1445 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
|
|
1446 |
return h+'</tbody></table></div>'; }
|
| 37749 |
vikas |
1447 |
// The drill drawer pages server-side (100 per page), so a bar reading 501 can be walked in full
|
|
|
1448 |
// instead of stopping at the first 100. The header filter box only filters the page on screen.
|
|
|
1449 |
var DRILL={crumb:'',title:'',sub:'',bt:'',bk:'',page:1,pageSize:100,pages:1,total:0};
|
|
|
1450 |
function drill(crumb,title,sub,bt,bk){ DRILL={crumb:crumb,title:title,sub:sub,bt:bt,bk:bk,page:1,pageSize:100,pages:1,total:0}; loadDrill(); }
|
|
|
1451 |
function drillPage(delta){ var n=DRILL.page+delta; if(n<1||n>DRILL.pages) return; DRILL.page=n; loadDrill(); }
|
|
|
1452 |
function loadDrill(){ var D=DRILL; openDrawer(D.crumb,D.title,'Loading…','<div class="ph">Loading…</div>');
|
|
|
1453 |
var q='?bucketType='+encodeURIComponent(D.bt)+(D.bk?'&bucketKey='+encodeURIComponent(D.bk):'')+
|
|
|
1454 |
'&page='+D.page+'&pageSize='+D.pageSize+qs();
|
| 37125 |
vikas |
1455 |
getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];
|
| 37749 |
vikas |
1456 |
D.total=(d&&d.total!=null)?d.total:leads.length; D.pages=Math.max((d&&d.pages)||1,1); D.page=(d&&d.page)||D.page;
|
|
|
1457 |
var count=fmtInt(D.total)+' lead(s)';
|
|
|
1458 |
if(D.pages>1){ var first=(D.page-1)*D.pageSize+(leads.length?1:0), last=(D.page-1)*D.pageSize+leads.length;
|
|
|
1459 |
count+=' · showing '+first+'–'+last; }
|
|
|
1460 |
openDrawer(D.crumb,D.title,(D.sub||'')+' · '+count,drawerLeadTable(leads)+drillPager());
|
|
|
1461 |
byId('dBody').scrollTop=0; }); }
|
|
|
1462 |
function drillPager(){ if(DRILL.pages<=1) return '';
|
|
|
1463 |
return '<div style="display:flex;justify-content:flex-end;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">'+
|
|
|
1464 |
'<button class="btn ghost sm" onclick="drillPage(-1)"'+(DRILL.page<=1?' disabled':'')+'>← Prev</button>'+
|
|
|
1465 |
'<span class="note">Page '+DRILL.page+' / '+DRILL.pages+'</span>'+
|
|
|
1466 |
'<button class="btn ghost sm" onclick="drillPage(1)"'+(DRILL.page>=DRILL.pages?' disabled':'')+'>Next →</button></div>'; }
|
| 37125 |
vikas |
1467 |
|
| 37651 |
vikas |
1468 |
// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawer
|
|
|
1469 |
var searchTimer=null, searchSeq=0;
|
|
|
1470 |
function runSearch(){ var q=(byId('lmsSearch').value||'').trim();
|
|
|
1471 |
if(q.length<2){ toast('Type at least 2 characters'); return; }
|
|
|
1472 |
var seq=++searchSeq;
|
|
|
1473 |
openDrawer('Search','\u201c'+q+'\u201d','Searching…','<div class="ph">Searching…</div>');
|
|
|
1474 |
getJSON('/lms/dashboard/search?q='+encodeURIComponent(q),function(d){
|
|
|
1475 |
if(seq!==searchSeq) return; // a newer search already won
|
|
|
1476 |
var leads=(d&&d.leads)?d.leads:[];
|
|
|
1477 |
openDrawer('Search','\u201c'+q+'\u201d',leads.length+' match(es)',drawerLeadTable(leads)); },
|
|
|
1478 |
function(){ if(seq!==searchSeq) return;
|
|
|
1479 |
openDrawer('Search','\u201c'+q+'\u201d','Search failed','<div class="ph">Could not run the search.</div>'); }); }
|
| 37692 |
vikas |
1480 |
byId('pipeQ').addEventListener('keydown',function(e){
|
|
|
1481 |
if(e.key==='Enter'){ e.preventDefault(); pipeSearch(); }
|
|
|
1482 |
else if(e.key==='Escape'){ pipeClearSearch(); } });
|
|
|
1483 |
|
| 37651 |
vikas |
1484 |
byId('lmsSearch').addEventListener('keydown',function(e){
|
|
|
1485 |
if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); runSearch(); }
|
|
|
1486 |
else if(e.key==='Escape'){ this.blur(); closeDrawer(); } });
|
|
|
1487 |
byId('lmsSearch').addEventListener('input',function(){ clearTimeout(searchTimer);
|
|
|
1488 |
var v=(this.value||'').trim(); if(v.length<2) return;
|
|
|
1489 |
searchTimer=setTimeout(runSearch,400); });
|
|
|
1490 |
|
| 37125 |
vikas |
1491 |
var REPORT_NOTE={ 'State Funnel':'Live from the lead record — grouped by region, BM and ASM.',
|
|
|
1492 |
'SLA Compliance':'Live — first_contacted_at vs first_contact_due, owner heat-map.',
|
|
|
1493 |
'Ageing':'Live — open leads untouched > 7 days, by stage.',
|
|
|
1494 |
'Management Roll-up':'Live — cross-region leads, onboardings and estimated value.',
|
|
|
1495 |
'BGC Daily':'Partial — created + dispositions are real; call volume is a PROXY (contacted ÷ days). No telephony bridge yet.',
|
|
|
1496 |
'Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.',
|
|
|
1497 |
'RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };
|
|
|
1498 |
|
|
|
1499 |
// ---- delegated clicks
|
|
|
1500 |
document.addEventListener('click',function(e){
|
| 37737 |
vikas |
1501 |
var eq=e.target.closest('[data-editq]');
|
|
|
1502 |
if(eq){ editQuestion(parseInt(eq.getAttribute('data-editq'),10)); return; }
|
|
|
1503 |
// Stage chips are multi-select; the in-call answer chips are single-select.
|
|
|
1504 |
var qs=e.target.closest('.qstage');
|
|
|
1505 |
if(qs){ qs.classList.toggle('sel'); return; }
|
| 37743 |
vikas |
1506 |
var tf=e.target.closest('.trailf');
|
|
|
1507 |
if(tf){ setTrailFilter(tf.getAttribute('data-trailtype')); return; }
|
|
|
1508 |
var tp=e.target.closest('#asgTiers .chkopt');
|
|
|
1509 |
if(tp){ byId('asgTiers').querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
|
|
1510 |
tp.classList.add('sel'); ASG.tier=tp.getAttribute('data-tier'); renderAssignList(); return; }
|
|
|
1511 |
// The assign-modal tier pills share the .chkopt class for styling, so they are handled above
|
|
|
1512 |
// and excluded here: this generic branch only picks an answer chip, and letting it swallow a
|
|
|
1513 |
// tier click selected the pill visually while never applying the filter.
|
| 37737 |
vikas |
1514 |
var co=e.target.closest('.chkopt');
|
| 37743 |
vikas |
1515 |
if(co && !co.closest('#asgTiers') && !co.classList.contains('trailf')){ var wrap=co.parentNode;
|
| 37737 |
vikas |
1516 |
wrap.querySelectorAll('.chkopt').forEach(function(x){ x.classList.remove('sel'); });
|
|
|
1517 |
co.classList.add('sel'); return; }
|
|
|
1518 |
var asg=e.target.closest('[data-assign]');
|
|
|
1519 |
if(asg){ e.preventDefault(); e.stopPropagation();
|
|
|
1520 |
openAssign(parseInt(asg.getAttribute('data-assign'),10), asg.getAttribute('data-assignname'),
|
|
|
1521 |
asg.getAttribute('data-assignowner'), asg.getAttribute('data-assignregion')); return; }
|
|
|
1522 |
var pick=e.target.closest('[data-assignto]');
|
|
|
1523 |
if(pick){ assignTo(parseInt(pick.getAttribute('data-assignto'),10)); return; }
|
| 37760 |
vikas |
1524 |
var rb=e.target.closest('[data-rgn]');
|
|
|
1525 |
if(rb){ openRegionOwner(parseInt(rb.getAttribute('data-rgn'),10), rb.getAttribute('data-rgnlabel'), rb.getAttribute('data-rgnowner')); return; }
|
|
|
1526 |
var rp=e.target.closest('[data-rgnpick]');
|
|
|
1527 |
if(rp){ setRegionOwner(parseInt(rp.getAttribute('data-rgnpick'),10), rp.getAttribute('data-rgnname')); return; }
|
| 37737 |
vikas |
1528 |
var ed=e.target.closest('[data-editlead]');
|
|
|
1529 |
if(ed){ e.preventDefault(); e.stopPropagation();
|
|
|
1530 |
editLeadById(parseInt(ed.getAttribute('data-editlead'),10)); return; }
|
|
|
1531 |
var cb=e.target.closest('[data-call]');
|
|
|
1532 |
if(cb){ e.preventDefault(); e.stopPropagation();
|
|
|
1533 |
dialLead(parseInt(cb.getAttribute('data-call'),10), cb.getAttribute('data-callname'), cb); return; }
|
|
|
1534 |
var la=e.target.closest('tr[data-liveagent]');
|
|
|
1535 |
if(la){ openAgentCalls(parseInt(la.getAttribute('data-liveagent'),10)); return; }
|
| 37692 |
vikas |
1536 |
var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
|
| 37125 |
vikas |
1537 |
var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
|
|
|
1538 |
drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
|
| 37783 |
vikas |
1539 |
var fc=e.target.closest('.fcap[data-drop]'); if(fc){ var dk=fc.getAttribute('data-drop');
|
|
|
1540 |
drill('Lifecycle funnel',fc.getAttribute('data-droplabel'),'At the previous step, never reached this one',
|
|
|
1541 |
dk.indexOf(',')>=0?'STAGE_IN':'STAGE',dk); return; }
|
| 37125 |
vikas |
1542 |
var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
|
| 37743 |
vikas |
1543 |
drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE_REACHED',st==='ALL'?'':st); return; }
|
| 37125 |
vikas |
1544 |
var att=e.target.closest('.att[data-att]'); if(att){ drill('Needs attention',att.getAttribute('data-attt'),'Attention queue','ATTENTION',att.getAttribute('data-att')); return; }
|
|
|
1545 |
var reg=e.target.closest('tr[data-region]'); if(reg){ drill('State funnel',reg.getAttribute('data-rn'),'Region '+reg.getAttribute('data-region'),'REGION',reg.getAttribute('data-region')); return; }
|
| 37737 |
vikas |
1546 |
var rep=e.target.closest('tr[data-rep]'); if(rep){ openReport(rep.getAttribute('data-rep')); return; }
|
| 37125 |
vikas |
1547 |
var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');
|
|
|
1548 |
closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);
|
| 37760 |
vikas |
1549 |
go('record',document.querySelector('.tab[data-screen="record"]')); return; }
|
| 37125 |
vikas |
1550 |
});
|
|
|
1551 |
|
|
|
1552 |
// ---- filters
|
| 37784 |
vikas |
1553 |
function refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.agentId=byId('fAgent').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';
|
| 37125 |
vikas |
1554 |
getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();
|
|
|
1555 |
getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);
|
|
|
1556 |
getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);
|
|
|
1557 |
getJSON('/lms/dashboard/ageing',renderAgeing);
|
| 37743 |
vikas |
1558 |
setDashPeriod(); }
|
| 37784 |
vikas |
1559 |
['fAgent','fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });
|
| 37125 |
vikas |
1560 |
|
|
|
1561 |
// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================
|
|
|
1562 |
var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],
|
|
|
1563 |
['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];
|
|
|
1564 |
function fmtDT(s){ if(!s) return '—'; var d=new Date(s); if(isNaN(d)) return esc(s);
|
|
|
1565 |
var mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
1566 |
function p(n){ return (n<10?'0':'')+n; }
|
|
|
1567 |
return p(d.getDate())+' '+mo[d.getMonth()]+' '+d.getFullYear()+' · '+p(d.getHours())+':'+p(d.getMinutes()); }
|
|
|
1568 |
function kvRow(k,v){ return '<div class="k">'+k+'</div><div class="v">'+(v==null||v===''?'—':v)+'</div>'; }
|
|
|
1569 |
function trailType(t){ if(t==='TELEPHONIC'||t==='CALL') return 'call'; if(!t) return 'sys'; return ''; }
|
|
|
1570 |
|
|
|
1571 |
function injectRecord(id){ byId('recordHost').innerHTML='<div class="ph">Loading lead #'+esc(id)+'…</div>';
|
|
|
1572 |
getJSON('/lms/dashboard/record?leadId='+encodeURIComponent(id),function(d){ if(!d||!d.id){ byId('recordHost').innerHTML='<div class="ph">Lead #'+esc(id)+' not found.</div>'; return; } renderRecord(d); }); }
|
|
|
1573 |
function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }
|
|
|
1574 |
function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
|
|
1575 |
if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }
|
|
|
1576 |
else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }
|
|
|
1577 |
|
| 37743 |
vikas |
1578 |
/** Trail type filter. '' is everything; the rest match LeadActivity.communicationType. */
|
|
|
1579 |
var TRAIL_TYPES=[['','All'],['TELEPHONIC','Telephonic'],['VISIT','Visit'],['UPDATE','Update']];
|
|
|
1580 |
var TRAIL={ filter:'' };
|
|
|
1581 |
|
|
|
1582 |
/** An entry with no communication type is a stage move or a note — shown as Update. */
|
|
|
1583 |
function trailTypeKey(t){ return t && t.type ? t.type : 'UPDATE'; }
|
|
|
1584 |
|
|
|
1585 |
function trailCounts(list){
|
|
|
1586 |
var c={};
|
|
|
1587 |
list.forEach(function(t){ var k=trailTypeKey(t); c[k]=(c[k]||0)+1; });
|
|
|
1588 |
return c;
|
|
|
1589 |
}
|
|
|
1590 |
|
|
|
1591 |
function setTrailFilter(type){
|
|
|
1592 |
TRAIL.filter=type||'';
|
|
|
1593 |
document.querySelectorAll('#trailFilter .trailf').forEach(function(b){
|
|
|
1594 |
b.classList.toggle('sel', b.getAttribute('data-trailtype')===TRAIL.filter); });
|
|
|
1595 |
renderTrailList();
|
|
|
1596 |
}
|
|
|
1597 |
|
|
|
1598 |
function renderTrailList(){
|
|
|
1599 |
var host=byId('trailHost'); if(!host || !REC) return;
|
|
|
1600 |
var all=REC.trail||[];
|
|
|
1601 |
var list=TRAIL.filter?all.filter(function(t){ return trailTypeKey(t)===TRAIL.filter; }):all;
|
|
|
1602 |
if(!list.length){
|
|
|
1603 |
host.innerHTML='<div class="ph">'+(all.length?'No entries of this type.':'No activity yet.')+'</div>';
|
|
|
1604 |
return;
|
|
|
1605 |
}
|
|
|
1606 |
var h='<div class="trail" id="trailScroll">';
|
|
|
1607 |
list.forEach(function(t){
|
|
|
1608 |
h+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+
|
|
|
1609 |
'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+
|
|
|
1610 |
'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+
|
|
|
1611 |
(t.body?('<div class="td">'+esc(t.body)+'</div>'):'');
|
|
|
1612 |
// The recording of the call this entry came from, so the agent's account of the conversation
|
|
|
1613 |
// and the conversation itself sit together instead of in two separate lists.
|
|
|
1614 |
if(t.callId){
|
|
|
1615 |
h+='<div class="trec"><audio controls preload="none" src="'+CTX+'/lms/recording/'+t.callId+'"></audio>'+
|
|
|
1616 |
(t.durationSeconds?('<span class="note">'+t.durationSeconds+'s</span>'):'')+
|
|
|
1617 |
(t.archived?'':'<span class="note">archiving…</span>')+'</div>';
|
|
|
1618 |
}
|
|
|
1619 |
h+='</div>';
|
|
|
1620 |
});
|
|
|
1621 |
host.innerHTML=h+'</div>';
|
|
|
1622 |
capTrail();
|
|
|
1623 |
}
|
|
|
1624 |
|
| 37692 |
vikas |
1625 |
/** Trail entries shown before the list starts scrolling. */
|
|
|
1626 |
var TRAIL_VISIBLE=5;
|
|
|
1627 |
|
|
|
1628 |
/**
|
|
|
1629 |
* Cap the trail at TRAIL_VISIBLE entries and scroll the rest.
|
|
|
1630 |
*
|
|
|
1631 |
* Measured from the 6th entry's own offset rather than assumed from a row height — entries vary a
|
|
|
1632 |
* lot (a one-line note, a wrapped remark, one carrying an audio player), so any fixed px guess
|
|
|
1633 |
* would cut an entry in half on most leads.
|
|
|
1634 |
*/
|
|
|
1635 |
function capTrail(){
|
|
|
1636 |
var box=byId('trailScroll'); if(!box) return;
|
|
|
1637 |
var items=box.children;
|
|
|
1638 |
if(items.length<=TRAIL_VISIBLE){ box.style.maxHeight=''; box.style.overflowY=''; box.classList.remove('scrolls'); return; }
|
|
|
1639 |
var cut=items[TRAIL_VISIBLE];
|
|
|
1640 |
box.style.maxHeight=Math.round(cut.offsetTop-box.firstElementChild.offsetTop)+'px';
|
|
|
1641 |
box.style.overflowY='auto';
|
|
|
1642 |
box.classList.add('scrolls');
|
|
|
1643 |
}
|
|
|
1644 |
|
| 37125 |
vikas |
1645 |
function renderRecord(d){
|
| 37743 |
vikas |
1646 |
REC=d; CALL.id=null; TRAIL.filter='';
|
| 37125 |
vikas |
1647 |
// header
|
|
|
1648 |
var head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+
|
|
|
1649 |
'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+
|
|
|
1650 |
'<h1 class="page" style="margin-top:4px">'+esc(d.name||'—')+(d.outlet?' <span style="font-weight:500;color:#59668a;font-size:18px">· '+esc(d.outlet)+'</span>':'')+'</h1></div>'+
|
|
|
1651 |
'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+
|
|
|
1652 |
' <span class="stage" style="background:#eef1f6;color:#69748c">SLA: '+esc(d.slaState||'NA')+'</span>'+
|
|
|
1653 |
(d.assignmentStatus==='HOLD'?' <span class="stage st-ni">HOLD</span>':'')+'</div></div>';
|
|
|
1654 |
// stepper
|
|
|
1655 |
var idx=(d.stageIndex==null?-1:d.stageIndex);
|
| 37692 |
vikas |
1656 |
// A terminal lead has no stepper to draw, and a whole card repeating what the header pill and
|
|
|
1657 |
// the Record card's Current stage already say is just a band of empty space. Omit it.
|
|
|
1658 |
var step='';
|
|
|
1659 |
if(!d.terminal){
|
|
|
1660 |
step='<div class="card" style="padding:16px 22px"><div class="rec-stepper">';
|
|
|
1661 |
STEPS.forEach(function(s,i){ var cls=i<idx?'done':(i===idx?'cur':''); var last=(i===STEPS.length-1);
|
|
|
1662 |
step+='<div class="rec-step '+cls+'"><div class="dot">'+(i<idx?'✓':(i+1))+'</div><div class="sl">'+esc(s[1])+'</div>'+(last?'':'<div class="bar"></div>')+'</div>'; });
|
|
|
1663 |
step+='</div></div>';
|
|
|
1664 |
}
|
| 37125 |
vikas |
1665 |
// details
|
| 37692 |
vikas |
1666 |
var details='<div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px">Record'+
|
|
|
1667 |
'<button class="btn ghost sm" onclick="openEdit()">✎ Edit</button></h2>'+
|
|
|
1668 |
'<p class="desc">ID, dates & auto-assignment are system-set and immutable. Everything else is correctable.</p>'+
|
| 37125 |
vikas |
1669 |
'<div class="grid g2" style="gap:20px"><div class="kv">'+
|
|
|
1670 |
kvRow('LMS ID', d.lmsCode?('<span class="mono">'+esc(d.lmsCode)+'</span>'):'—')+
|
|
|
1671 |
kvRow('Created', fmtDT(d.created)+(d.createdBy?(' · '+esc(d.createdBy)):''))+
|
|
|
1672 |
kvRow('Creation path', d.path?('<span class="tag '+(d.path==='B'?'b':'a')+'">'+(d.path==='B'?'B · Field':'A · BGC')+'</span>'):'—')+
|
|
|
1673 |
kvRow('Retailer', esc(d.name))+
|
|
|
1674 |
kvRow('Business', esc(d.outlet))+
|
|
|
1675 |
kvRow('Contact', d.contact?('<span class="mono">'+esc(d.contact)+'</span>'):'—')+
|
|
|
1676 |
'</div><div class="kv">'+
|
|
|
1677 |
kvRow('Location', esc(d.location))+
|
|
|
1678 |
kvRow('Region', esc(d.regionCode))+
|
|
|
1679 |
kvRow('Business value', inrShort(d.value))+
|
|
|
1680 |
kvRow('Source', esc(d.source))+
|
|
|
1681 |
kvRow('Last disposition', d.disposition?esc(d.disposition):'—')+
|
|
|
1682 |
kvRow('Owner BM/RSM', esc(d.ownerBm))+
|
|
|
1683 |
'</div></div><div class="divider"></div>'+
|
|
|
1684 |
'<div class="kv" style="grid-template-columns:130px 1fr 130px 1fr">'+
|
|
|
1685 |
kvRow('Working owner', esc(d.owner))+kvRow('Current stage', stagePill(d.stage,d.stageLabel))+
|
|
|
1686 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+
|
|
|
1687 |
kvRow('Unreachable tries', d.unreachableCount==null?'0':d.unreachableCount)+
|
|
|
1688 |
'</div></div>';
|
|
|
1689 |
// SLA card
|
|
|
1690 |
var slaClr=d.slaState==='MET'?'#2f9e44':(d.slaState==='BREACHED'?'#e03131':(d.slaState==='AT_RISK'?'#f08c00':'#69748c'));
|
|
|
1691 |
var sla='<div class="card"><h2>First-contact SLA</h2><p class="desc">5-hr commitment from creation (SOP §11).</p>'+
|
|
|
1692 |
'<div style="font-size:26px;font-weight:800;color:'+slaClr+'">'+esc(d.slaState||'NA')+'</div>'+
|
|
|
1693 |
'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+
|
|
|
1694 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';
|
|
|
1695 |
// trail
|
| 37743 |
vikas |
1696 |
// Trail card. The list itself is rendered by renderTrailList() so the type filter can redraw it
|
|
|
1697 |
// without refetching the record — the whole trail is already in hand.
|
|
|
1698 |
var counts=trailCounts(d.trail||[]);
|
|
|
1699 |
var trail='<div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">'+
|
|
|
1700 |
'<span>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></span>'+
|
|
|
1701 |
(d.trail&&d.trail.length?('<span class="opts" id="trailFilter">'+
|
|
|
1702 |
TRAIL_TYPES.map(function(t){
|
|
|
1703 |
var n=counts[t[0]]||0;
|
|
|
1704 |
// Offer only the types this lead actually has — a filter that returns nothing is noise.
|
|
|
1705 |
if(t[0] && !n) return '';
|
|
|
1706 |
return '<button type="button" class="chkopt trailf'+(TRAIL.filter===t[0]?' sel':'')+'"'+
|
|
|
1707 |
' data-trailtype="'+t[0]+'">'+esc(t[1])+' <span class="note" style="display:inline">'+
|
|
|
1708 |
(t[0]?n:(d.trail.length))+'</span></button>';
|
|
|
1709 |
}).join('')+'</span>'):'')+
|
|
|
1710 |
'</h2>'+
|
|
|
1711 |
'<div id="trailHost"></div>';
|
| 37125 |
vikas |
1712 |
trail+='</div>';
|
|
|
1713 |
|
| 37692 |
vikas |
1714 |
// ---- Click-to-call (SOP §12). VBC places the call server-side: our own device rings first,
|
|
|
1715 |
// then the retailer. A blocked number never gets a button at all — the refusal belongs in
|
|
|
1716 |
// front of the agent, not in the response to a dial they already started.
|
|
|
1717 |
var call='<div class="card"><h2>Click-to-call</h2>';
|
|
|
1718 |
if(d.dnd){ call+='<p class="desc" style="color:#e03131"><b>Do-not-call.</b> '+esc(d.dndReason||'This retailer asked not to be contacted.')+'</p>'; }
|
|
|
1719 |
else if(!d.contact){ call+='<p class="desc">No contact number on this lead.</p>'; }
|
|
|
1720 |
else { call+='<p class="desc">Bridged call · disposition required on disconnect.</p>'+
|
| 37737 |
vikas |
1721 |
'<button class="btn coral" id="callBtn" onclick="startCall()">☎ Call retailer (C2C)</button>'; }
|
| 37692 |
vikas |
1722 |
call+='<div class="note" style="margin-top:10px">A call is only worth what its disposition says — log one on every disconnect.</div></div>';
|
|
|
1723 |
|
|
|
1724 |
// ---- Beat plan. Read-only on purpose: adding a stop belongs to the beat planner, and the
|
|
|
1725 |
// gate that matters (an approved geo-pin) is what this card exists to report.
|
|
|
1726 |
// The card used to state the geo gate and leave the agent to go and find the Leads screen.
|
|
|
1727 |
// The action that clears the gate now lives on it.
|
|
|
1728 |
var mapLink=(d.geoLat!=null&&d.geoLng!=null)
|
|
|
1729 |
? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.google.com/maps?q='+d.geoLat+','+d.geoLng+'">View on map</a>' : '';
|
|
|
1730 |
var photoLink=(d.geoPhotoId>0)
|
|
|
1731 |
? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="'+CTX+'/open-attachment?documentId='+d.geoPhotoId+'">Store photo</a>' : '';
|
|
|
1732 |
var beat='<div class="card"><h2>Beat plan</h2>'+
|
|
|
1733 |
(d.geoApproved
|
|
|
1734 |
? '<p class="desc"><span class="stage st-visited">Geo verified</span> — this lead can be added to a beat.</p>'
|
|
|
1735 |
: '<p class="desc" style="color:#a9720a">Geo-pin not yet approved'+
|
|
|
1736 |
(d.geoStatus?(' ('+esc(d.geoStatus.toLowerCase())+')'):'')+
|
|
|
1737 |
'. Verify the location before adding this lead to a beat.</p>')+
|
|
|
1738 |
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">'+
|
|
|
1739 |
'<button class="btn ghost sm" onclick="openGeo()">'+(d.geoApproved?'Re-verify location':'Verify manually')+'</button>'+
|
|
|
1740 |
mapLink+photoLink+'</div>'+
|
|
|
1741 |
'<div class="note">Check-in at the retailer moves the stage to Visited.</div></div>';
|
|
|
1742 |
|
|
|
1743 |
// ---- Advance stage. Forward-only and comment-mandatory, same rules the endpoint enforces.
|
|
|
1744 |
var adv='';
|
|
|
1745 |
if(!d.terminal){
|
|
|
1746 |
var opts='<option value="" selected disabled>Move to…</option>';
|
|
|
1747 |
STEPS.forEach(function(st,i){ if(i>idx) opts+='<option value="'+st[0]+'">'+esc(st[1])+'</option>'; });
|
|
|
1748 |
['NOT_INTERESTED','DROPPED'].forEach(function(t){ opts+='<option value="'+t+'">'+t.replace('_',' ')+'</option>'; });
|
|
|
1749 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">Forward-only. The comment becomes the trail entry.</p>'+
|
|
|
1750 |
'<select id="advStage" class="inp" style="width:100%;margin-bottom:8px">'+opts+'</select>'+
|
|
|
1751 |
'<input id="advComment" class="inp" style="width:100%;margin-bottom:10px" placeholder="Comment (required)">'+
|
|
|
1752 |
'<button class="btn" style="background:#1f2a5e;color:#fff" onclick="advance()">Advance</button></div>';
|
|
|
1753 |
} else {
|
|
|
1754 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">'+esc(d.stageLabel)+' is terminal — this lead cannot be moved on.</p></div>';
|
|
|
1755 |
}
|
|
|
1756 |
|
|
|
1757 |
byId('recordHost').innerHTML=head+step+
|
|
|
1758 |
'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
|
| 37743 |
vikas |
1759 |
renderTrailList();
|
| 37125 |
vikas |
1760 |
}
|
|
|
1761 |
|
| 37737 |
vikas |
1762 |
// ====================== LIVE CALL BOARD ===================================================
|
|
|
1763 |
// Polls /lms/dashboard/live-calls. First paint builds the table; subsequent ticks PATCH the
|
|
|
1764 |
// volatile cells rather than re-rendering — re-drawing the whole table every 10s fights anyone
|
|
|
1765 |
// mid-read and throws away their scroll position. Same approach the RBM board takes.
|
|
|
1766 |
|
|
|
1767 |
var LIVE = { timer:null, built:false, rows:[], failures:0, lastAt:null };
|
|
|
1768 |
var LIVE_MS = 10000;
|
|
|
1769 |
|
|
|
1770 |
var LSTAT={'On Call':'oncall','Wrap Up':'wrap','Idle':'idle','Available':'avail','—':'off'};
|
|
|
1771 |
function lstat(s){ return '<span class="lstat '+(LSTAT[s]||'off')+'">'+esc(s)+'</span>'; }
|
|
|
1772 |
/** hh:mm:ss, like the RBM board's time-in-status. */
|
|
|
1773 |
function hms(sec){
|
|
|
1774 |
if(sec==null) return '—';
|
|
|
1775 |
sec=Math.max(0,Math.floor(sec));
|
|
|
1776 |
var h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s2=sec%60;
|
|
|
1777 |
return (h?('0'+h).slice(-2)+':':'')+('0'+m).slice(-2)+':'+('0'+s2).slice(-2);
|
|
|
1778 |
}
|
|
|
1779 |
|
|
|
1780 |
/** Successful refresh: stamp the time, pulse the dot. Time only — the board is always today. */
|
|
|
1781 |
function liveBeat(asOf){
|
|
|
1782 |
LIVE.failures=0;
|
|
|
1783 |
LIVE.lastAt=asOf?new Date(asOf):new Date();
|
|
|
1784 |
var dot=byId('liveDot'); if(dot){ dot.classList.remove('stalled','beat');
|
|
|
1785 |
void dot.offsetWidth; // restart the animation on a repeat refresh
|
|
|
1786 |
dot.classList.add('beat'); }
|
|
|
1787 |
byId('liveAsOf').textContent='updated '+clockOf(LIVE.lastAt);
|
|
|
1788 |
}
|
|
|
1789 |
function liveStalled(){
|
|
|
1790 |
var dot=byId('liveDot'); if(dot){ dot.classList.remove('beat'); dot.classList.add('stalled'); }
|
|
|
1791 |
byId('liveAsOf').textContent=LIVE.lastAt
|
|
|
1792 |
? 'not updating · last '+clockOf(LIVE.lastAt) : 'not updating';
|
|
|
1793 |
}
|
|
|
1794 |
function clockOf(d){
|
|
|
1795 |
if(!d || isNaN(d)) return '—';
|
|
|
1796 |
return ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2);
|
|
|
1797 |
}
|
|
|
1798 |
|
|
|
1799 |
function startLive(){
|
|
|
1800 |
LIVE.failures=0;
|
|
|
1801 |
// Default the log window to today; the board above is always "now" so this matches it.
|
|
|
1802 |
if(!byId('clFrom').value){
|
|
|
1803 |
var t=new Date().toISOString().slice(0,10);
|
|
|
1804 |
byId('clFrom').value=t; byId('clTo').value=t;
|
|
|
1805 |
}
|
|
|
1806 |
loadCallLog(1);
|
|
|
1807 |
loadLive();
|
|
|
1808 |
stopLive();
|
|
|
1809 |
LIVE.timer=setInterval(loadLive, LIVE_MS);
|
|
|
1810 |
}
|
|
|
1811 |
// Must be cleared on tab change, or every visit leaves another interval polling forever.
|
|
|
1812 |
function stopLive(){ if(LIVE.timer){ clearInterval(LIVE.timer); LIVE.timer=null; } }
|
|
|
1813 |
|
|
|
1814 |
function loadLive(){
|
|
|
1815 |
getJSON('/lms/dashboard/live-calls', function(d){
|
|
|
1816 |
d=d||{}; LIVE.rows=d.rows||[];
|
|
|
1817 |
liveBeat(d.asOf);
|
|
|
1818 |
byId('liveKpis').innerHTML=
|
|
|
1819 |
kpiTile('On call now', fmtInt(d.onCallNow))+
|
|
|
1820 |
kpiTile('Calls today', fmtInt(d.calls))+
|
|
|
1821 |
kpiTile('Connect rate', (d.connectPct||0)+'%')+
|
|
|
1822 |
kpiTile('Talk time', (d.talkMinutes||0)+' min')+
|
|
|
1823 |
kpiTile('Agents', fmtInt(d.agents));
|
|
|
1824 |
if(!LIVE.built){ buildLiveTable(); LIVE.built=true; } else { patchLiveTable(); }
|
|
|
1825 |
}, function(){
|
|
|
1826 |
// A dropped poll keeps the last good picture, but must not keep claiming to be live.
|
|
|
1827 |
LIVE.failures++;
|
|
|
1828 |
if(LIVE.failures>=2) liveStalled();
|
|
|
1829 |
});
|
|
|
1830 |
}
|
|
|
1831 |
|
|
|
1832 |
function buildLiveTable(){
|
|
|
1833 |
if(!LIVE.rows.length){
|
|
|
1834 |
byId('liveBody').innerHTML='<tr><td colspan="11"><div class="ph">No agents mapped to the dialer yet.</div></td></tr>';
|
|
|
1835 |
return;
|
|
|
1836 |
}
|
|
|
1837 |
byId('liveBody').innerHTML=LIVE.rows.map(function(r){
|
|
|
1838 |
return '<tr class="clickrow" data-liveagent="'+r.authId+'">'+
|
|
|
1839 |
'<td class="nowrap"><b>'+esc(r.agent)+'</b>'+(r.extension?'<br><span style="color:#97a1b5;font-size:11px">ext '+esc(r.extension)+'</span>':'')+'</td>'+
|
|
|
1840 |
'<td class="nowrap" data-live="status" data-authid="'+r.authId+'">'+liveStatusCell(r)+'</td>'+
|
|
|
1841 |
'<td class="nowrap mono" data-live="since" data-authid="'+r.authId+'">'+hms(r.sinceSeconds)+'</td>'+
|
|
|
1842 |
'<td class="num" data-live="calls" data-authid="'+r.authId+'">'+fmtInt(r.calls)+'</td>'+
|
|
|
1843 |
'<td class="num" data-live="connected" data-authid="'+r.authId+'">'+fmtInt(r.connected)+'</td>'+
|
|
|
1844 |
'<td class="num" data-live="connectPct" data-authid="'+r.authId+'">'+pctCell(r.connectPct)+'</td>'+
|
|
|
1845 |
'<td class="num" data-live="talk" data-authid="'+r.authId+'">'+secs(r.talkSeconds)+'</td>'+
|
|
|
1846 |
'<td class="num" data-live="longest" data-authid="'+r.authId+'">'+secs(r.longestSeconds)+'</td>'+
|
|
|
1847 |
'<td class="num">'+fmtInt(r.assigned)+'</td>'+
|
|
|
1848 |
'<td class="num" data-live="touched" data-authid="'+r.authId+'">'+fmtInt(r.touched)+'</td>'+
|
|
|
1849 |
'<td class="num" data-live="sla" data-authid="'+r.authId+'">'+slaBreachCell(r.slaBreached)+'</td></tr>';
|
|
|
1850 |
}).join('');
|
|
|
1851 |
}
|
|
|
1852 |
|
|
|
1853 |
function liveStatusCell(r){
|
|
|
1854 |
// A stale row means the agent closed the tab mid-call; say so rather than silently showing Idle.
|
|
|
1855 |
return lstat(r.status)+(r.staleCall?'<span class="lstale" title="A call was never closed — the agent likely closed the tab mid-call">unclosed call</span>':'');
|
|
|
1856 |
}
|
|
|
1857 |
function slaBreachCell(n){
|
|
|
1858 |
n=Number(n)||0;
|
|
|
1859 |
return n>0?'<span style="font-weight:700;color:#e03131">'+fmtInt(n)+'</span>':'0';
|
|
|
1860 |
}
|
|
|
1861 |
|
|
|
1862 |
function patchLiveTable(){
|
|
|
1863 |
// A new agent provisioned mid-session changes the row set — rebuild rather than patch.
|
|
|
1864 |
if(LIVE.rows.length!==document.querySelectorAll('#liveBody tr[data-liveagent]').length){
|
|
|
1865 |
buildLiveTable(); return;
|
|
|
1866 |
}
|
|
|
1867 |
LIVE.rows.forEach(function(r){
|
|
|
1868 |
set(r.authId,'status',liveStatusCell(r));
|
|
|
1869 |
set(r.authId,'since',hms(r.sinceSeconds));
|
|
|
1870 |
set(r.authId,'calls',fmtInt(r.calls));
|
|
|
1871 |
set(r.authId,'connected',fmtInt(r.connected));
|
|
|
1872 |
set(r.authId,'connectPct',pctCell(r.connectPct));
|
|
|
1873 |
set(r.authId,'talk',secs(r.talkSeconds));
|
|
|
1874 |
set(r.authId,'longest',secs(r.longestSeconds));
|
|
|
1875 |
set(r.authId,'touched',fmtInt(r.touched));
|
|
|
1876 |
set(r.authId,'sla',slaBreachCell(r.slaBreached));
|
|
|
1877 |
});
|
|
|
1878 |
function set(authId,key,html){
|
|
|
1879 |
var el=document.querySelector('#liveBody td[data-live="'+key+'"][data-authid="'+authId+'"]');
|
|
|
1880 |
if(el && el.innerHTML!==html) el.innerHTML=html;
|
|
|
1881 |
}
|
|
|
1882 |
}
|
|
|
1883 |
|
|
|
1884 |
function exportLive(){
|
|
|
1885 |
if(!LIVE.rows.length){ toast('Nothing to export'); return; }
|
|
|
1886 |
var cols=[['agent','Agent'],['extension','Extension'],['status','Status'],['sinceSeconds','Seconds in status'],
|
|
|
1887 |
['calls','Calls'],['connected','Connected'],['connectPct','Connect %'],['talkSeconds','Talk seconds'],
|
|
|
1888 |
['longestSeconds','Longest call seconds'],['assigned','Leads assigned'],['touched','Touched today'],
|
|
|
1889 |
['dispositioned','Dispositioned today'],['slaBreached','SLA breached']];
|
|
|
1890 |
var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
|
|
|
1891 |
LIVE.rows.forEach(function(r){
|
|
|
1892 |
csv.push(cols.map(function(c){ var v=r[c[0]]; if(v==null) v='';
|
|
|
1893 |
return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
|
|
|
1894 |
});
|
|
|
1895 |
var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
|
|
|
1896 |
var a=document.createElement('a');
|
|
|
1897 |
a.href=URL.createObjectURL(blob);
|
|
|
1898 |
a.download='lms-live-calls-'+new Date().toISOString().slice(0,10)+'.csv';
|
|
|
1899 |
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
|
1900 |
URL.revokeObjectURL(a.href);
|
|
|
1901 |
toast('Exported '+LIVE.rows.length+' agent(s)',1);
|
|
|
1902 |
}
|
|
|
1903 |
|
|
|
1904 |
// ---- Call log -----------------------------------------------------------------------------
|
|
|
1905 |
var CL = { page:1, pageSize:50, pages:1, total:0, rows:[] };
|
|
|
1906 |
|
|
|
1907 |
var CLSTAT={COMPLETED:'oncall',ANSWERED:'oncall',RINGING:'avail',INITIATED:'avail',
|
|
|
1908 |
UNANSWERED:'idle',BUSY:'idle',REJECTED:'off',FAILED:'off',CANCELLED:'off'};
|
|
|
1909 |
function clStatus(s){ return '<span class="lstat '+(CLSTAT[s]||'off')+'">'+esc((s||'').replace(/_/g,' '))+'</span>'; }
|
|
|
1910 |
|
|
|
1911 |
function clDates(){
|
|
|
1912 |
var f=byId('clFrom').value, t=byId('clTo').value, q='?page='+CL.page+'&pageSize='+CL.pageSize;
|
|
|
1913 |
if(f) q+='&from='+encodeURIComponent(f);
|
|
|
1914 |
if(t) q+='&to='+encodeURIComponent(t);
|
|
|
1915 |
return q;
|
|
|
1916 |
}
|
|
|
1917 |
|
|
|
1918 |
function loadCallLog(page){
|
|
|
1919 |
if(page) CL.page=page;
|
|
|
1920 |
byId('clBody').innerHTML='<tr><td colspan="9"><div class="ph">Loading…</div></td></tr>';
|
|
|
1921 |
getJSON('/lms/dashboard/call-log'+clDates(), function(d){
|
|
|
1922 |
d=d||{}; CL.rows=d.rows||[]; CL.total=d.total||0; CL.pages=d.pages||1; CL.page=d.page||1;
|
|
|
1923 |
if(!CL.rows.length){
|
|
|
1924 |
byId('clBody').innerHTML='<tr><td colspan="9"><div class="ph">No calls in this window.</div></td></tr>';
|
|
|
1925 |
} else {
|
|
|
1926 |
byId('clBody').innerHTML=CL.rows.map(function(r){
|
|
|
1927 |
return '<tr class="clickrow"'+(r.leadId?' data-leadid="'+r.leadId+'"':'')+'>'+
|
|
|
1928 |
'<td class="nowrap">'+fmtDT(r.startedAt)+'</td>'+
|
|
|
1929 |
'<td class="nowrap" title="'+esc(r.agent||'')+'">'+esc(r.agent||'—')+
|
|
|
1930 |
(r.extension?'<br><span style="color:#97a1b5;font-size:11px">ext '+esc(r.extension)+'</span>':'')+'</td>'+
|
|
|
1931 |
'<td><b>'+esc(r.retailer||('#'+(r.leadId||'')))+'</b>'+
|
|
|
1932 |
(r.lmsCode?'<br><span class="mono" style="color:#97a1b5;font-size:11px">'+esc(r.lmsCode)+'</span>':'')+'</td>'+
|
|
|
1933 |
'<td class="nowrap mono">'+esc(r.toNumber||'—')+'</td>'+
|
|
|
1934 |
'<td class="nowrap">'+clStatus(r.status)+'</td>'+
|
|
|
1935 |
// A dash, not 0s: never answered is not the same as answered instantly.
|
|
|
1936 |
'<td class="num nowrap">'+(r.ringSeconds==null?'—':secs(r.ringSeconds))+'</td>'+
|
|
|
1937 |
'<td class="num nowrap">'+(r.talkSeconds?secs(r.talkSeconds):'—')+'</td>'+
|
|
|
1938 |
'<td class="nowrap">'+clRec(r)+'</td>'+
|
|
|
1939 |
'<td>'+esc(r.disposition||'—')+'</td></tr>';
|
|
|
1940 |
}).join('');
|
|
|
1941 |
}
|
|
|
1942 |
var first=(CL.page-1)*CL.pageSize+(CL.rows.length?1:0), last=(CL.page-1)*CL.pageSize+CL.rows.length;
|
|
|
1943 |
byId('clCount').textContent=CL.total?('Showing '+first+'–'+last+' of '+fmtInt(CL.total)+' calls'):'';
|
|
|
1944 |
byId('clPageLbl').textContent='Page '+CL.page+' / '+Math.max(CL.pages,1);
|
|
|
1945 |
byId('clPrev').disabled=(CL.page<=1);
|
|
|
1946 |
byId('clNext').disabled=(CL.page>=CL.pages);
|
|
|
1947 |
});
|
|
|
1948 |
}
|
|
|
1949 |
|
|
|
1950 |
function clRec(r){
|
|
|
1951 |
if(r.recording==='none') return '<span style="color:#97a1b5">—</span>';
|
|
|
1952 |
// Playback goes through the gated endpoint, never a vendor URL: it enforces the access matrix
|
|
|
1953 |
// and logs the open (SOP §15).
|
|
|
1954 |
return '<audio controls preload="none" style="height:28px;max-width:150px" src="'+CTX+'/lms/recording/'+r.callId+'"></audio>'+
|
|
|
1955 |
(r.recording==='vendor'?'<span class="note" style="display:inline;margin-left:4px">archiving…</span>':'');
|
|
|
1956 |
}
|
|
|
1957 |
|
|
|
1958 |
function clPage(step){
|
|
|
1959 |
var n=CL.page+step;
|
|
|
1960 |
if(n<1||n>CL.pages) return;
|
|
|
1961 |
loadCallLog(n);
|
|
|
1962 |
}
|
|
|
1963 |
|
|
|
1964 |
function exportCallLog(){
|
|
|
1965 |
if(!CL.rows.length){ toast('Nothing to export'); return; }
|
|
|
1966 |
var cols=[['startedAt','Started'],['answeredAt','Answered'],['endedAt','Ended'],['agent','Agent'],
|
|
|
1967 |
['extension','Extension'],['lmsCode','LMS ID'],['retailer','Retailer'],['outlet','Business'],
|
|
|
1968 |
['toNumber','Number'],['direction','Direction'],['status','Status'],
|
|
|
1969 |
['ringSeconds','Ring seconds'],['talkSeconds','Talk seconds'],['recording','Recording'],
|
|
|
1970 |
['disposition','Disposition']];
|
|
|
1971 |
var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
|
|
|
1972 |
CL.rows.forEach(function(r){
|
|
|
1973 |
csv.push(cols.map(function(c){ var v=r[c[0]]; if(v==null) v='';
|
|
|
1974 |
return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
|
|
|
1975 |
});
|
|
|
1976 |
var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
|
|
|
1977 |
var a=document.createElement('a');
|
|
|
1978 |
a.href=URL.createObjectURL(blob);
|
|
|
1979 |
a.download='lms-call-log-'+(byId('clFrom').value||'all')+'.csv';
|
|
|
1980 |
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
|
1981 |
URL.revokeObjectURL(a.href);
|
|
|
1982 |
toast('Exported '+CL.rows.length+' call(s)',1);
|
|
|
1983 |
}
|
|
|
1984 |
|
|
|
1985 |
// ====================== STANDARD REPORT SUITE (SOP §18.2) =================================
|
|
|
1986 |
// Each catalogue row opens the real report in the drawer rather than a sentence describing it.
|
|
|
1987 |
// The four that were already "Live" reuse data this page has loaded; BGC Daily and Audit Hygiene
|
|
|
1988 |
// call their own endpoints. RBM Servicing stays honest about needing order data it cannot reach.
|
|
|
1989 |
|
|
|
1990 |
function secs(n){ n=Number(n)||0; var m=Math.floor(n/60); return m? (m+'m '+(n%60)+'s') : (n+'s'); }
|
|
|
1991 |
function pctCell(v){ var c=v>=80?'#2f9e44':(v>=50?'#f08c00':'#e03131');
|
|
|
1992 |
return '<span style="font-weight:700;color:'+c+'">'+v+'%</span>'; }
|
|
|
1993 |
function tbl(head, rows){
|
|
|
1994 |
if(!rows.length) return '<div class="ph">Nothing in the selected period.</div>';
|
|
|
1995 |
return '<table><thead><tr>'+head.map(function(h){
|
|
|
1996 |
return '<th'+(h.n?' class="num"':'')+'>'+esc(h.t)+'</th>'; }).join('')+'</tr></thead><tbody>'+
|
|
|
1997 |
rows.join('')+'</tbody></table>';
|
|
|
1998 |
}
|
|
|
1999 |
|
|
|
2000 |
function openReport(name){
|
|
|
2001 |
openDrawer('Report suite', name, 'SOP §18.2', '<div class="ph">Loading…</div>');
|
|
|
2002 |
if(name==='BGC Daily') return reportBgcDaily();
|
|
|
2003 |
if(name==='Audit Hygiene') return reportAuditHygiene();
|
|
|
2004 |
if(name==='State Funnel') return reportStateFunnel();
|
|
|
2005 |
if(name==='SLA Compliance') return reportSla();
|
|
|
2006 |
if(name==='Ageing') return reportAgeing();
|
|
|
2007 |
if(name==='Management Roll-up') return reportRollup();
|
|
|
2008 |
// RBM Servicing: repeat-order rate, complaint rate and resolution TAT all live in order and
|
|
|
2009 |
// ticket data the lead record does not reach. Say so rather than showing an empty table.
|
|
|
2010 |
openDrawer('Report suite', name, 'SOP §18.2',
|
|
|
2011 |
'<div class="card"><div class="dnote">Repeat-order rate, complaint rate and resolution TAT need order '+
|
|
|
2012 |
'and ticket data, which the LMS does not hold. Post-sales (SOP §14) is not built yet — the lead '+
|
|
|
2013 |
'record stops at ONBOARDED.</div></div>');
|
|
|
2014 |
}
|
|
|
2015 |
|
|
|
2016 |
function reportBgcDaily(){
|
|
|
2017 |
getJSON('/lms/dashboard/bgc-daily'+qsDates(),function(d){
|
|
|
2018 |
d=d||{};
|
|
|
2019 |
var head=[{t:'Day'},{t:'Agent'},{t:'Calls',n:1},{t:'Connected',n:1},{t:'Connect%',n:1},{t:'Avg talk',n:1},{t:'Longest',n:1}];
|
|
|
2020 |
var rows=(d.rows||[]).map(function(r){
|
|
|
2021 |
return '<tr><td class="nowrap">'+esc(r.day||'—')+'</td><td>'+esc(r.agent)+'</td>'+
|
|
|
2022 |
'<td class="num">'+fmtInt(r.calls)+'</td><td class="num">'+fmtInt(r.connected)+'</td>'+
|
|
|
2023 |
'<td class="num">'+pctCell(r.connectPct)+'</td><td class="num">'+secs(r.avgTalkSeconds)+'</td>'+
|
|
|
2024 |
'<td class="num">'+secs(r.longestSeconds)+'</td></tr>'; });
|
|
|
2025 |
var kpi='<div class="kpis" style="margin-bottom:12px">'+
|
|
|
2026 |
kpiTile('Calls placed', fmtInt(d.calls))+kpiTile('Connected', fmtInt(d.connected))+
|
|
|
2027 |
kpiTile('Connect rate', (d.connectPct||0)+'%')+kpiTile('Talk time', (d.talkMinutes||0)+' min')+'</div>';
|
|
|
2028 |
openDrawer('Report suite','BGC Daily','Real telephony from the call log',
|
|
|
2029 |
kpi+'<div class="card">'+tbl(head,rows)+
|
|
|
2030 |
'<div class="note">Connect rate = answered ÷ placed. Average talk time counts connected calls only — '+
|
|
|
2031 |
'averaging over every dial would make a day of unanswered ringing look like short conversations.</div></div>');
|
|
|
2032 |
});
|
|
|
2033 |
}
|
|
|
2034 |
|
|
|
2035 |
function reportAuditHygiene(){
|
|
|
2036 |
getJSON('/lms/dashboard/audit-hygiene'+qsDates(),function(d){
|
|
|
2037 |
d=d||{};
|
|
|
2038 |
var kpi='<div class="kpis" style="margin-bottom:12px">'+
|
|
|
2039 |
kpiTile('Missing dispositions', fmtInt(d.missingDisposition))+
|
|
|
2040 |
kpiTile('SLA breached', fmtInt(d.slaBreached))+
|
|
|
2041 |
kpiTile('HOLD (no owner)', fmtInt(d.hold))+
|
|
|
2042 |
kpiTile('DND register', fmtInt(d.dndRegister))+'</div>';
|
|
|
2043 |
var head=[{t:'When'},{t:'Lead'},{t:'Number'},{t:'Call'}];
|
|
|
2044 |
var rows=(d.dndBreaches||[]).map(function(b){
|
|
|
2045 |
return '<tr class="clickrow" data-leadid="'+b.leadId+'"><td class="nowrap">'+fmtDT(b.when)+'</td>'+
|
|
|
2046 |
'<td>#'+b.leadId+'</td><td class="mono">'+esc(b.mobile||'—')+'</td><td>#'+b.callId+'</td></tr>'; });
|
|
|
2047 |
openDrawer('Report suite','Audit Hygiene','SOP §17',
|
|
|
2048 |
kpi+
|
|
|
2049 |
'<div class="card"><h2>DND breaches</h2><p class="desc">Calls placed to a number already on the '+
|
|
|
2050 |
'do-not-call register. The pre-check blocks these, so a row here is a bypass worth asking about.</p>'+
|
|
|
2051 |
tbl(head,rows)+'</div>'+
|
|
|
2052 |
'<div class="card"><h2>Recording access</h2><div class="kv">'+
|
|
|
2053 |
kvRow('Opens granted', fmtInt(d.recordingOpens))+
|
|
|
2054 |
kvRow('Opens denied', fmtInt(d.recordingDenied))+'</div>'+
|
|
|
2055 |
'<div class="note">Every open and every refusal is logged against the viewer (SOP §15). '+
|
|
|
2056 |
'Repeated denials are what a scope probe looks like.</div></div>');
|
|
|
2057 |
});
|
|
|
2058 |
}
|
|
|
2059 |
|
|
|
2060 |
function reportStateFunnel(){
|
|
|
2061 |
var d=DATA.state||SEED.state||{}; var rows=(d.rows||[]).map(function(r){
|
|
|
2062 |
return '<tr><td><b>'+esc(r.regionName||r.regionCode||'—')+'</b></td><td class="num">'+fmtInt(r.newCount)+'</td>'+
|
|
|
2063 |
'<td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+
|
| 37749 |
vikas |
2064 |
'<td class="num">'+fmtInt(r.beatPlanned)+'</td><td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+
|
| 37737 |
vikas |
2065 |
'<td class="num">'+pctCell(r.convPct||0)+'</td><td class="num">'+pctCell(r.slaPct||0)+'</td></tr>'; });
|
|
|
2066 |
openDrawer('Report suite','State Funnel','NEW → ONBOARDED by region','<div class="card">'+
|
| 37749 |
vikas |
2067 |
tbl([{t:'Region'},{t:'Leads',n:1},{t:'Contacted',n:1},{t:'Qualified',n:1},{t:'Beat',n:1},{t:'Visited',n:1},{t:'Onboarded',n:1},{t:'Conv%',n:1},{t:'SLA%',n:1}],rows)+'</div>');
|
| 37737 |
vikas |
2068 |
}
|
|
|
2069 |
|
|
|
2070 |
function reportSla(){
|
|
|
2071 |
var d=DATA.heat||SEED.heat||{}; var rows=(d.bms||[]).map(function(r){
|
|
|
2072 |
var cells=(r.cells||[]).map(function(c){ return '<td class="num">'+(c.pct==null?'—':pctCell(c.pct))+'</td>'; }).join('');
|
|
|
2073 |
return '<tr><td><b>'+esc(r.bmName||'—')+'</b></td>'+cells+'</tr>'; });
|
|
|
2074 |
var head=[{t:'Owner'}].concat((d.weeks||[]).map(function(c){ return {t:c,n:1}; }));
|
|
|
2075 |
openDrawer('Report suite','SLA Compliance','% first contact within 5 hrs','<div class="card">'+
|
|
|
2076 |
tbl(head,rows)+'<div class="note">Blank cells mean no leads in that week — not 0% compliance.</div></div>');
|
|
|
2077 |
}
|
|
|
2078 |
|
|
|
2079 |
function reportAgeing(){
|
|
|
2080 |
var d=DATA.ageing||SEED.ageing||{}; var rows=(d.rows||[]).map(function(r){
|
|
|
2081 |
return '<tr><td><b>'+esc(r.label||r.stage||'—')+'</b></td>'+
|
|
|
2082 |
'<td class="num">'+fmtInt(r.count)+'</td><td class="num">'+fmtInt(r.days)+'</td></tr>'; });
|
|
|
2083 |
openDrawer('Report suite','Ageing','Open leads with no action for '+(d.days||7)+'+ days','<div class="card">'+
|
|
|
2084 |
tbl([{t:'Stage'},{t:'Leads',n:1},{t:'Days idle',n:1}],rows)+
|
|
|
2085 |
'<div class="note">Total stuck: '+fmtInt(d.total||0)+'</div></div>');
|
|
|
2086 |
}
|
|
|
2087 |
|
|
|
2088 |
function reportRollup(){
|
|
|
2089 |
var s=DATA.summary||SEED.summary||{};
|
|
|
2090 |
var kpi='<div class="kpis" style="margin-bottom:12px">'+
|
|
|
2091 |
kpiTile('Leads in period', fmtInt(s.total))+
|
|
|
2092 |
kpiTile('Onboarded', fmtInt(onboardedCount()))+
|
|
|
2093 |
kpiTile('Conversion', (s.conversionPct||0)+'%')+
|
|
|
2094 |
kpiTile('Onboarded value', inrShort(s.onboardedValue))+'</div>';
|
|
|
2095 |
var rows=(s.funnel||[]).map(function(f){
|
|
|
2096 |
return '<tr><td><b>'+esc(f.label||f.stage)+'</b></td><td class="num">'+fmtInt(f.count)+'</td>'+
|
|
|
2097 |
'<td class="num">'+(f.pct==null?'—':pctCell(f.pct))+'</td></tr>'; });
|
|
|
2098 |
openDrawer('Report suite','Management Roll-up','Cross-region',
|
|
|
2099 |
kpi+'<div class="card">'+tbl([{t:'Stage'},{t:'Leads',n:1},{t:'% of top',n:1}],rows)+'</div>');
|
|
|
2100 |
}
|
|
|
2101 |
|
|
|
2102 |
function openAgentCalls(authId){
|
|
|
2103 |
var r=null; LIVE.rows.forEach(function(x){ if(x.authId===authId) r=x; });
|
|
|
2104 |
if(!r) return;
|
|
|
2105 |
openDrawer('Live floor', r.agent, r.status+(r.extension?(' · ext '+r.extension):''),
|
|
|
2106 |
'<div class="card"><div class="kv">'+
|
|
|
2107 |
kvRow('Status', lstat(r.status))+
|
|
|
2108 |
kvRow('In status', hms(r.sinceSeconds))+
|
|
|
2109 |
kvRow('Calls today', fmtInt(r.calls)+' · '+fmtInt(r.connected)+' connected ('+(r.connectPct||0)+'%)')+
|
|
|
2110 |
kvRow('Talk time', secs(r.talkSeconds)+' · longest '+secs(r.longestSeconds))+
|
|
|
2111 |
kvRow('Last call', r.lastCallAt?fmtDT(r.lastCallAt):'—')+
|
|
|
2112 |
kvRow('Leads assigned', fmtInt(r.assigned))+
|
|
|
2113 |
kvRow('Touched today', fmtInt(r.touched)+' · '+fmtInt(r.dispositioned)+' dispositioned')+
|
|
|
2114 |
kvRow('SLA breached', slaBreachCell(r.slaBreached))+
|
|
|
2115 |
'</div>'+
|
|
|
2116 |
(r.liveLeadId?('<div class="note">On a call now — <a href="#" data-leadid="'+r.liveLeadId+'">open lead #'+r.liveLeadId+'</a></div>'):'')+
|
|
|
2117 |
(r.staleCall?'<div class="note" style="color:#e03131">An earlier call was never closed. Only the browser poll ends a VBC call, so a tab closed mid-call leaves the row open.</div>':'')+
|
|
|
2118 |
'</div>');
|
|
|
2119 |
}
|
|
|
2120 |
|
|
|
2121 |
function kpiTile(label,val){
|
|
|
2122 |
return '<div class="kpi"><div class="top"><div class="label">'+esc(label)+'</div></div>'+
|
|
|
2123 |
'<div class="val">'+val+'</div></div>';
|
|
|
2124 |
}
|
|
|
2125 |
function qsDates(){ var q='?'; if(STATE.from)q+='from='+encodeURIComponent(STATE.from)+'&';
|
|
|
2126 |
if(STATE.to)q+='to='+encodeURIComponent(STATE.to); return q; }
|
|
|
2127 |
|
|
|
2128 |
// ---- Export (SOP §18.1) -------------------------------------------------------------------
|
|
|
2129 |
// Exports what is on screen — the current filters, search and sort — rather than everything,
|
|
|
2130 |
// because an export that ignores the filters is a different report than the one being looked at.
|
|
|
2131 |
function exportPipeline(){
|
|
|
2132 |
var p='/lms/dashboard/pipeline?page=1&pageSize=500&sort='+encodeURIComponent(PIPE.sort)+
|
| 37784 |
vikas |
2133 |
'&dir='+encodeURIComponent(PIPE.dir);
|
|
|
2134 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q)+scopeQs(); }
|
| 37737 |
vikas |
2135 |
else { p+=qs(); }
|
|
|
2136 |
toast('Preparing export…');
|
|
|
2137 |
getJSON(p,function(d){
|
|
|
2138 |
var rows=(d&&d.rows)?d.rows:[];
|
|
|
2139 |
if(!rows.length){ toast('Nothing to export'); return; }
|
|
|
2140 |
var cols=[['lmsCode','LMS ID'],['name','Retailer'],['outlet','Business'],['path','Path'],
|
|
|
2141 |
['stageLabel','Stage'],['owner','Owner'],['city','City'],['createdBy','Created by'],
|
|
|
2142 |
['slaState','SLA'],['value','Business value (lakh)']];
|
|
|
2143 |
var csv=[cols.map(function(c){ return '"'+c[1]+'"'; }).join(',')];
|
|
|
2144 |
rows.forEach(function(r){
|
|
|
2145 |
csv.push(cols.map(function(c){
|
|
|
2146 |
var v=r[c[0]]; if(v==null) v='';
|
|
|
2147 |
// Quote everything and double embedded quotes: names here contain commas routinely.
|
|
|
2148 |
return '"'+String(v).replace(/"/g,'""')+'"'; }).join(','));
|
|
|
2149 |
});
|
|
|
2150 |
var blob=new Blob([""+csv.join('\r\n')],{type:'text/csv;charset=utf-8;'});
|
|
|
2151 |
var a=document.createElement('a');
|
|
|
2152 |
a.href=URL.createObjectURL(blob);
|
|
|
2153 |
a.download='lms-pipeline-'+(STATE.from||'all')+'-to-'+(STATE.to||'now')+'.csv';
|
|
|
2154 |
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
|
2155 |
URL.revokeObjectURL(a.href);
|
|
|
2156 |
toast('Exported '+rows.length+' lead(s)',1);
|
|
|
2157 |
});
|
|
|
2158 |
}
|
|
|
2159 |
|
| 37692 |
vikas |
2160 |
// ====================== RECORD ACTIONS (edit · call · disposition · stage) =================
|
|
|
2161 |
// Every control here drives the SAME endpoints the in-app record uses, so the two screens cannot
|
|
|
2162 |
// drift into disagreeing about what a disposition or a stage move means.
|
|
|
2163 |
|
|
|
2164 |
var REC = null; // the record currently rendered, for the action panels
|
| 37737 |
vikas |
2165 |
var CALL = { id:null, leadId:null, btn:null, poll:null, tick:null, startedAt:0, checklistShown:false };
|
| 37692 |
vikas |
2166 |
|
|
|
2167 |
/** POST form-encoded (advanceStage / dialer take @RequestParam, not a JSON body). */
|
|
|
2168 |
function postForm(path, params, cb, onErr){
|
|
|
2169 |
var body = Object.keys(params).map(function(k){
|
|
|
2170 |
return encodeURIComponent(k)+'='+encodeURIComponent(params[k]==null?'':params[k]); }).join('&');
|
|
|
2171 |
fetch(CTX+path,{method:'POST',credentials:'same-origin',
|
|
|
2172 |
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:body})
|
|
|
2173 |
.then(function(r){ return r.text(); })
|
|
|
2174 |
.then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}
|
|
|
2175 |
var res=(d&&d.response!==undefined)?d.response:d; cb(res); })
|
|
|
2176 |
.catch(function(e){ console.error('LMS post failed',path,e);
|
|
|
2177 |
if(typeof onErr==='function') onErr(e); else toast('Request failed'); });
|
|
|
2178 |
}
|
|
|
2179 |
|
|
|
2180 |
/** POST JSON (disposition / updateLead take @RequestBody). */
|
|
|
2181 |
function postJSON(path, payload, cb, onErr){
|
|
|
2182 |
fetch(CTX+path,{method:'POST',credentials:'same-origin',
|
|
|
2183 |
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify(payload)})
|
|
|
2184 |
.then(function(r){ return r.text(); })
|
|
|
2185 |
.then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}
|
|
|
2186 |
var res=(d&&d.response!==undefined)?d.response:d; cb(res); })
|
|
|
2187 |
.catch(function(e){ console.error('LMS post failed',path,e);
|
|
|
2188 |
if(typeof onErr==='function') onErr(e); else toast('Request failed'); });
|
|
|
2189 |
}
|
|
|
2190 |
|
|
|
2191 |
/** A refusal comes back as a bare string under `response`; success is an object. */
|
|
|
2192 |
function refusal(res){ return (typeof res==='string' && res) ? res : null; }
|
|
|
2193 |
|
|
|
2194 |
function reloadRecord(){ if(REC) injectRecord(REC.id); }
|
|
|
2195 |
|
|
|
2196 |
// ---- stage --------------------------------------------------------------------------------
|
|
|
2197 |
function advance(){
|
|
|
2198 |
var to=byId('advStage').value, c=(byId('advComment').value||'').trim();
|
|
|
2199 |
if(!to){ toast('Choose a stage'); return; }
|
|
|
2200 |
if(!c){ toast('A comment is required'); return; }
|
|
|
2201 |
postForm('/lms/advanceStage',{leadId:REC.id,toStage:to,comment:c},function(res){
|
|
|
2202 |
var why=refusal(res); if(why){ toast(why); return; }
|
|
|
2203 |
toast('Stage advanced to '+to.replace('_',' '),1); reloadRecord(); refresh();
|
|
|
2204 |
});
|
|
|
2205 |
}
|
|
|
2206 |
|
|
|
2207 |
// ---- edit ---------------------------------------------------------------------------------
|
| 37737 |
vikas |
2208 |
// EDIT.lead is the record being edited. It is NOT always REC: the pipeline can edit any row while
|
|
|
2209 |
// a different lead (or none) is open on the record tab, and binding the save to REC would have
|
|
|
2210 |
// written one lead's form over another lead's row.
|
|
|
2211 |
var EDIT = { lead:null };
|
|
|
2212 |
|
|
|
2213 |
/** Edit straight from a list row — fetch that lead, then open the same form. */
|
|
|
2214 |
function editLeadById(leadId){
|
|
|
2215 |
if(!leadId) return;
|
|
|
2216 |
getJSON('/lms/dashboard/record?leadId='+leadId, function(d){
|
|
|
2217 |
if(!d || !d.id){ toast('Could not load lead #'+leadId); return; }
|
|
|
2218 |
openEditFor(d);
|
|
|
2219 |
}, function(){ toast('Could not load lead #'+leadId); });
|
|
|
2220 |
}
|
|
|
2221 |
|
|
|
2222 |
function openEdit(){ if(REC) openEditFor(REC); }
|
|
|
2223 |
|
|
|
2224 |
function openEditFor(rec){
|
|
|
2225 |
if(!rec) return;
|
|
|
2226 |
EDIT.lead=rec;
|
|
|
2227 |
var REC=rec; // shadow, so the markup below reads the target record
|
| 37692 |
vikas |
2228 |
var regionOpts='<option value="">— Not set —</option>';
|
|
|
2229 |
// Same region list the Create tab renders, so the two forms cannot offer different regions.
|
|
|
2230 |
byId('cRegion').querySelectorAll('option').forEach(function(o){
|
|
|
2231 |
if(!o.value) return;
|
|
|
2232 |
regionOpts+='<option value="'+esc(o.value)+'"'+(String(REC.regionId)===String(o.value)?' selected':'')+'>'+esc(o.textContent)+'</option>';
|
|
|
2233 |
});
|
|
|
2234 |
byId('editBody').innerHTML=
|
|
|
2235 |
'<div class="kv" style="margin-bottom:14px">'+
|
|
|
2236 |
kvRow('LMS ID','<span class="mono">'+esc(REC.lmsCode||('#'+REC.id))+'</span> <span class="note" style="display:inline">locked</span>')+
|
|
|
2237 |
kvRow('Created', fmtDT(REC.created)+' <span class="note" style="display:inline">locked</span>')+
|
|
|
2238 |
'</div>'+
|
|
|
2239 |
'<div class="grid g2" style="gap:12px">'+
|
|
|
2240 |
fld('eName','Retailer name (required)',REC.name)+
|
|
|
2241 |
fld('eBusiness','Business name',REC.outlet)+
|
|
|
2242 |
fld('eContact','Contact number (required)',REC.contact)+
|
|
|
2243 |
fld('eValue','Business value (₹ lakh / month)',(REC.value>0?Math.round(toLakh(REC.value)*10)/10:''))+
|
|
|
2244 |
'</div>'+
|
|
|
2245 |
fld('eAddress','Address',REC.address)+
|
|
|
2246 |
'<div class="grid g2" style="gap:12px">'+fld('eCity','City',REC.city)+fld('eState','State',REC.state)+'</div>'+
|
|
|
2247 |
'<label class="lbl">Region</label><select id="eRegion" class="inp" style="width:100%">'+regionOpts+'</select>'+
|
|
|
2248 |
'<div class="note">Changing the region re-runs auto-assignment. A lead already mapped to a named owner keeps that owner.</div>';
|
|
|
2249 |
byId('editModal').classList.remove('hide');
|
|
|
2250 |
}
|
|
|
2251 |
function fld(id,label,val){
|
|
|
2252 |
return '<div><label class="lbl">'+label+'</label><input id="'+id+'" class="inp" style="width:100%" value="'+esc(val==null?'':val)+'"></div>';
|
|
|
2253 |
}
|
|
|
2254 |
function closeEdit(){ byId('editModal').classList.add('hide'); }
|
|
|
2255 |
function saveEdit(){
|
| 37737 |
vikas |
2256 |
var target=EDIT.lead;
|
|
|
2257 |
if(!target){ toast('Nothing to edit'); return; }
|
| 37692 |
vikas |
2258 |
var name=(byId('eName').value||'').trim();
|
|
|
2259 |
if(!name){ toast('Retailer name is required'); return; }
|
|
|
2260 |
var mob=(byId('eContact').value||'').replace(/\D/g,'');
|
|
|
2261 |
if(mob.length<10){ toast('A valid 10-digit contact number is required'); return; }
|
|
|
2262 |
var v=parseFloat(byId('eValue').value), r=byId('eRegion').value;
|
| 37737 |
vikas |
2263 |
postJSON('/lms/updateLead',{leadId:target.id,retailerName:name,businessName:byId('eBusiness').value,
|
| 37692 |
vikas |
2264 |
mobile:mob,address:byId('eAddress').value,city:byId('eCity').value,state:byId('eState').value,
|
|
|
2265 |
regionId:r?parseInt(r,10):null,potential:isNaN(v)?null:v},function(res){
|
|
|
2266 |
var why=refusal(res); if(why){ toast(why); return; }
|
| 37737 |
vikas |
2267 |
closeEdit();
|
|
|
2268 |
toast('Record updated · '+(res&&res.changes||0)+' change(s) logged',1);
|
|
|
2269 |
// Refresh whichever views are showing this lead.
|
|
|
2270 |
if(REC && REC.id===target.id) reloadRecord();
|
|
|
2271 |
if(!byId('cmd').classList.contains('hide')) loadPipeline();
|
| 37692 |
vikas |
2272 |
});
|
|
|
2273 |
}
|
|
|
2274 |
|
|
|
2275 |
// ---- call ---------------------------------------------------------------------------------
|
| 37737 |
vikas |
2276 |
// One flow for every entry point: the lead record's Call button and the phone icon on a pipeline
|
|
|
2277 |
// row both come through dialLead(). The bar is global so a call started from the pipeline still
|
|
|
2278 |
// has somewhere to report progress, and switching tabs mid-call does not hide the hang-up button.
|
|
|
2279 |
function callErr(msg){ var e=byId('gCallErr'); if(!e) return; e.textContent=msg; e.classList.remove('hide'); }
|
|
|
2280 |
function callState(t){ var e=byId('gCallState'); if(e) e.textContent=t; }
|
|
|
2281 |
function showBar(who){
|
|
|
2282 |
byId('gCallWho').textContent=who||'Calling…';
|
|
|
2283 |
byId('gCallErr').classList.add('hide');
|
|
|
2284 |
byId('gCallTimer').textContent='00:00';
|
|
|
2285 |
byId('gCallBar').classList.remove('hide');
|
|
|
2286 |
}
|
|
|
2287 |
function hideBar(){ byId('gCallBar').classList.add('hide'); }
|
|
|
2288 |
|
|
|
2289 |
function startCall(){ if(REC) dialLead(REC.id, REC.name, byId('callBtn')); }
|
|
|
2290 |
|
|
|
2291 |
function dialLead(leadId, label, btn){
|
|
|
2292 |
if(!leadId) return;
|
|
|
2293 |
// One call at a time: the dialer rings the agent's own device, so a second dial would collide.
|
|
|
2294 |
if(CALL.id){ toast('Already on a call — hang up first'); return; }
|
|
|
2295 |
if(btn) btn.disabled=true;
|
| 37692 |
vikas |
2296 |
// Pre-check server-side: DND and the 09:00-21:00 calling window. A check that only lives in
|
|
|
2297 |
// the browser is not a check, which is why this endpoint exists at all.
|
| 37737 |
vikas |
2298 |
postForm('/lms/dialer/precheck',{leadId:leadId},function(res){
|
| 37692 |
vikas |
2299 |
var why=refusal(res);
|
| 37737 |
vikas |
2300 |
if(why || !res || res.ok!==true){ if(btn) btn.disabled=false; toast(why||(res&&res.reason)||'Cannot place the call'); return; }
|
|
|
2301 |
showBar(label||('Lead #'+leadId)); callState('Ringing your device…');
|
|
|
2302 |
postForm('/lms/dialer/dial',{leadId:leadId},function(r2){
|
| 37692 |
vikas |
2303 |
var w2=refusal(r2);
|
| 37737 |
vikas |
2304 |
if(w2 || !r2 || r2.ok!==true){ if(btn) btn.disabled=false; hideBar();
|
| 37692 |
vikas |
2305 |
toast(w2||'Could not place the call'); return; }
|
| 37737 |
vikas |
2306 |
CALL.id=r2.leadCallId;
|
|
|
2307 |
CALL.checklistShown=false;
|
|
|
2308 |
// The disposition belongs to the lead that was DIALLED, which is not necessarily the record
|
|
|
2309 |
// currently open — the pipeline can start a call while another lead is on screen.
|
|
|
2310 |
CALL.leadId=leadId;
|
|
|
2311 |
CALL.btn=btn||null;
|
|
|
2312 |
CALL.startedAt=Date.now();
|
| 37692 |
vikas |
2313 |
CALL.tick=setInterval(function(){ var s=Math.floor((Date.now()-CALL.startedAt)/1000);
|
| 37737 |
vikas |
2314 |
byId('gCallTimer').textContent=('0'+Math.floor(s/60)).slice(-2)+':'+('0'+(s%60)).slice(-2); },1000);
|
| 37692 |
vikas |
2315 |
// click2dial has no event webhook, so the browser has to ask.
|
|
|
2316 |
CALL.poll=setInterval(pollCall,3000);
|
|
|
2317 |
});
|
|
|
2318 |
});
|
|
|
2319 |
}
|
|
|
2320 |
function pollCall(){
|
|
|
2321 |
if(!CALL.id) return;
|
|
|
2322 |
getJSON('/lms/dialer/state?leadCallId='+CALL.id,function(st){
|
|
|
2323 |
if(!st) return;
|
|
|
2324 |
if(st.status) callState(String(st.status).replace(/_/g,' ').toLowerCase());
|
| 37737 |
vikas |
2325 |
// Connected, not merely placed. The poll repeats every 3s while answered, so the latch stops
|
|
|
2326 |
// the checklist reopening over the agent every tick.
|
|
|
2327 |
if(st.answered && !CALL.checklistShown){
|
|
|
2328 |
CALL.checklistShown=true;
|
|
|
2329 |
maybeOpenChecklist(CALL.leadId, CALL.id);
|
|
|
2330 |
}
|
| 37692 |
vikas |
2331 |
if(st.terminal){ stopCall(); callState('Call ended'); openDisp(); }
|
|
|
2332 |
},function(){ /* a dropped poll is not a dropped call — keep waiting */ });
|
|
|
2333 |
}
|
|
|
2334 |
function stopCall(){ if(CALL.poll) clearInterval(CALL.poll); if(CALL.tick) clearInterval(CALL.tick);
|
| 37737 |
vikas |
2335 |
CALL.poll=CALL.tick=null;
|
|
|
2336 |
if(CALL.btn) CALL.btn.disabled=false;
|
|
|
2337 |
var b=byId('callBtn'); if(b) b.disabled=false; }
|
| 37692 |
vikas |
2338 |
function endCall(){
|
|
|
2339 |
if(!CALL.id){ stopCall(); return; }
|
|
|
2340 |
postForm('/lms/dialer/hangup',{leadCallId:CALL.id},function(){ stopCall(); callState('Call ended'); openDisp(); });
|
|
|
2341 |
}
|
|
|
2342 |
|
| 37737 |
vikas |
2343 |
// ---- assign a lead to a sales person --------------------------------------------------------
|
|
|
2344 |
var ASG = { leadId:null, people:[], tiers:[], tier:'', regionId:null };
|
|
|
2345 |
|
|
|
2346 |
function openAssign(leadId, name, currentOwner, regionId){
|
|
|
2347 |
ASG.leadId=leadId; ASG.tier=''; ASG.regionId=regionId||null;
|
|
|
2348 |
byId('asgWho').innerHTML='<b>'+esc(name||('Lead #'+leadId))+'</b>'+
|
|
|
2349 |
(currentOwner?(' · currently '+esc(currentOwner)):' · unassigned');
|
|
|
2350 |
byId('asgSearch').value='';
|
|
|
2351 |
byId('asgList').innerHTML='<div class="ph">Loading…</div>';
|
|
|
2352 |
byId('asgModal').classList.remove('hide');
|
|
|
2353 |
getJSON('/lms/salespeople'+(regionId?('?regionId='+encodeURIComponent(regionId)):''), function(d){
|
|
|
2354 |
d=d||{}; ASG.people=d.people||[]; ASG.tiers=d.tiers||[];
|
|
|
2355 |
byId('asgTiers').innerHTML=['<button type="button" class="chkopt sel" data-tier="">All</button>']
|
|
|
2356 |
.concat(ASG.tiers.map(function(t){
|
|
|
2357 |
return '<button type="button" class="chkopt" data-tier="'+t+'">'+t+'</button>'; })).join('');
|
|
|
2358 |
renderAssignList();
|
|
|
2359 |
}, function(){ byId('asgList').innerHTML='<div class="ph">Could not load the sales list.</div>'; });
|
|
|
2360 |
}
|
|
|
2361 |
function closeAssign(){ byId('asgModal').classList.add('hide'); }
|
|
|
2362 |
|
|
|
2363 |
function renderAssignList(){
|
|
|
2364 |
var q=(byId('asgSearch').value||'').trim().toLowerCase();
|
|
|
2365 |
var list=ASG.people.filter(function(p){
|
|
|
2366 |
if(ASG.tier && p.tier!==ASG.tier) return false;
|
|
|
2367 |
if(!q) return true;
|
|
|
2368 |
return (p.name||'').toLowerCase().indexOf(q)>=0 || (p.tier||'').toLowerCase().indexOf(q)>=0
|
|
|
2369 |
|| (p.regions||[]).join(' ').toLowerCase().indexOf(q)>=0;
|
|
|
2370 |
});
|
|
|
2371 |
// Whoever covers this lead's region first — the likely right answer without forcing it.
|
|
|
2372 |
list.sort(function(a,b){
|
|
|
2373 |
if(a.coversRegion!==b.coversRegion) return a.coversRegion?-1:1;
|
|
|
2374 |
if(a.tier!==b.tier) return a.tier<b.tier?-1:1;
|
|
|
2375 |
return (a.name||'').localeCompare(b.name||'');
|
|
|
2376 |
});
|
|
|
2377 |
if(!list.length){ byId('asgList').innerHTML='<div class="ph">Nobody matches.</div>'; return; }
|
|
|
2378 |
byId('asgList').innerHTML=list.map(function(p){
|
|
|
2379 |
return '<div class="asgrow'+(p.coversRegion?' covers':'')+'" data-assignto="'+p.authId+'">'+
|
|
|
2380 |
'<span class="tierpill">'+esc(p.tier)+'</span>'+
|
|
|
2381 |
'<span class="nm">'+esc(p.name)+'</span>'+
|
|
|
2382 |
'<span class="rg">'+(p.coversRegion?'covers this region · ':'')+esc((p.regions||[]).join(', ')||'no region')+'</span>'+
|
|
|
2383 |
'</div>'; }).join('');
|
|
|
2384 |
}
|
|
|
2385 |
|
|
|
2386 |
function assignTo(authId){
|
|
|
2387 |
if(!ASG.leadId) return;
|
|
|
2388 |
postForm('/lms/assignLead',{leadId:ASG.leadId, authId:authId}, function(res){
|
|
|
2389 |
var why=refusal(res); if(why){ toast(why); return; }
|
|
|
2390 |
closeAssign();
|
|
|
2391 |
toast('Assigned to '+(res&&res.owner||'owner')+' ('+(res&&res.tier||'')+')',1);
|
|
|
2392 |
if(!byId('cmd').classList.contains('hide')) loadPipeline();
|
|
|
2393 |
if(REC && REC.id===ASG.leadId) reloadRecord();
|
|
|
2394 |
refresh();
|
|
|
2395 |
});
|
|
|
2396 |
}
|
|
|
2397 |
|
| 37760 |
vikas |
2398 |
// ---- region owner (Auto-Assignment tab): add or change the BM/RSM behind a region -----------
|
|
|
2399 |
var RGN={ regionId:null, label:'', current:'', people:[] };
|
|
|
2400 |
function openRegionOwner(regionId,label,current){
|
|
|
2401 |
RGN.regionId=regionId; RGN.label=label; RGN.current=current||'';
|
|
|
2402 |
byId('rgnWho').innerHTML='<b>'+esc(label)+'</b> · '+(current?('currently '+esc(current)):'vacant (HOLD)');
|
|
|
2403 |
byId('rgnSearch').value=''; byId('rgnList').innerHTML='<div class="ph">Loading…</div>';
|
|
|
2404 |
byId('rgnModal').classList.remove('hide');
|
|
|
2405 |
getJSON('/lms/salespeople?regionId='+encodeURIComponent(regionId), function(d){
|
|
|
2406 |
RGN.people=(d&&d.people)||[]; renderRegionOwnerList();
|
|
|
2407 |
}, function(){ byId('rgnList').innerHTML='<div class="ph">Could not load the sales list.</div>'; });
|
|
|
2408 |
}
|
|
|
2409 |
function closeRegionOwner(){ byId('rgnModal').classList.add('hide'); }
|
|
|
2410 |
function renderRegionOwnerList(){
|
|
|
2411 |
var q=(byId('rgnSearch').value||'').trim().toLowerCase();
|
|
|
2412 |
// One row per person: the tier they hold elsewhere is context, not a choice — the region
|
|
|
2413 |
// always gets an L4 row so the engine's first lookup finds them.
|
|
|
2414 |
var seen={}, list=[];
|
|
|
2415 |
RGN.people.forEach(function(p){ if(seen[p.authId]) { if(p.coversRegion) seen[p.authId].coversRegion=true; return; }
|
|
|
2416 |
var c={authId:p.authId,name:p.name,tier:p.tier,regions:(p.regions||[]).slice(),coversRegion:!!p.coversRegion}; seen[p.authId]=c; list.push(c); });
|
|
|
2417 |
list=list.filter(function(p){ if(!q) return true;
|
|
|
2418 |
return (p.name||'').toLowerCase().indexOf(q)>=0 || (p.tier||'').toLowerCase().indexOf(q)>=0 || (p.regions||[]).join(' ').toLowerCase().indexOf(q)>=0; });
|
|
|
2419 |
list.sort(function(a,b){ if(a.coversRegion!==b.coversRegion) return a.coversRegion?-1:1; return (a.name||'').localeCompare(b.name||''); });
|
|
|
2420 |
if(!list.length){ byId('rgnList').innerHTML='<div class="ph">Nobody matches. A person must already hold a sales position (L1–L5) to be offered here.</div>'; return; }
|
|
|
2421 |
byId('rgnList').innerHTML=list.map(function(p){
|
|
|
2422 |
return '<div class="asgrow'+(p.coversRegion?' covers':'')+'" data-rgnpick="'+p.authId+'" data-rgnname="'+esc(p.name)+'">'+
|
|
|
2423 |
'<span class="tierpill">'+esc(p.tier)+'</span><span class="nm">'+esc(p.name)+'</span>'+
|
|
|
2424 |
'<span class="rg">'+(p.coversRegion?'already covers this region · ':'')+esc((p.regions||[]).join(', ')||'no region')+'</span></div>'; }).join('');
|
|
|
2425 |
}
|
|
|
2426 |
function setRegionOwner(authId,name){
|
|
|
2427 |
if(!RGN.regionId) return;
|
|
|
2428 |
if(!confirm((RGN.current?('Change '+RGN.label+' from '+RGN.current+' to '+name+'?'):('Assign '+RGN.label+' to '+name+'?'))+'\n\nNew leads in this region will be routed to them.')) return;
|
|
|
2429 |
postForm('/lms/region-owner',{regionId:RGN.regionId, authId:authId}, function(res){
|
|
|
2430 |
var why=refusal(res); if(why){ toast(why); return; }
|
|
|
2431 |
closeRegionOwner();
|
|
|
2432 |
toast((res&&res.bmName?res.bmName:name)+' now owns '+RGN.label+(res&&res.previous?(' (was '+res.previous+')'):''),1);
|
|
|
2433 |
buildAssign();
|
|
|
2434 |
});
|
|
|
2435 |
}
|
|
|
2436 |
|
| 37737 |
vikas |
2437 |
// ---- checklist question management (CRUD) --------------------------------------------------
|
|
|
2438 |
var QADM = { questions:[], stages:[], editing:null };
|
|
|
2439 |
|
|
|
2440 |
function loadQuestions(){
|
|
|
2441 |
byId('qBody').innerHTML='<tr><td colspan="8"><div class="ph">Loading…</div></td></tr>';
|
|
|
2442 |
getJSON('/lms/checklist/questions', function(d){
|
|
|
2443 |
d=d||{}; QADM.questions=d.questions||[]; QADM.stages=d.stages||[];
|
|
|
2444 |
if(!QADM.questions.length){
|
|
|
2445 |
byId('qBody').innerHTML='<tr><td colspan="8"><div class="ph">No questions yet — add the first one.</div></td></tr>';
|
|
|
2446 |
return;
|
|
|
2447 |
}
|
|
|
2448 |
byId('qBody').innerHTML=QADM.questions.map(function(q){
|
|
|
2449 |
return '<tr'+(q.active?'':' style="opacity:.55"')+'>'+
|
|
|
2450 |
'<td class="num mono">'+q.sortOrder+'</td>'+
|
|
|
2451 |
'<td><b>'+esc(q.prompt)+'</b>'+
|
|
|
2452 |
(q.choices?'<br><span style="color:#97a1b5;font-size:11px">'+esc(q.choices.replace(/\|/g,' · '))+'</span>':'')+'</td>'+
|
|
|
2453 |
'<td class="nowrap">'+esc(q.answerType)+'</td>'+
|
|
|
2454 |
'<td class="num">'+(q.points||0)+'</td>'+
|
|
|
2455 |
'<td>'+(q.required?'<span class="lstat idle">Required</span>':'<span style="color:#97a1b5">—</span>')+'</td>'+
|
|
|
2456 |
'<td>'+stageChips(q.stages)+'</td>'+
|
|
|
2457 |
'<td>'+(q.active?'<span class="lstat oncall">Active</span>':'<span class="lstat off">Off</span>')+'</td>'+
|
|
|
2458 |
'<td class="nowrap"><button class="btn ghost sm" data-editq="'+q.id+'">Edit</button>'+
|
|
|
2459 |
(q.answerCount>0?'<span class="note" style="display:block;margin-top:3px">'+q.answerCount+' answered</span>':'')+
|
|
|
2460 |
'</td></tr>';
|
|
|
2461 |
}).join('');
|
|
|
2462 |
});
|
|
|
2463 |
}
|
|
|
2464 |
|
|
|
2465 |
function stageChips(stages){
|
|
|
2466 |
if(!stages || !stages.trim()) return '<span style="color:#97a1b5">every stage</span>';
|
|
|
2467 |
return stages.split('|').map(function(x){
|
|
|
2468 |
return '<span class="lstat avail" style="margin:1px 2px">'+esc(prettyStage(x))+'</span>'; }).join('');
|
|
|
2469 |
}
|
|
|
2470 |
function prettyStage(x){ return (x||'').replace(/_/g,' ').toLowerCase().replace(/\b\w/g,function(c){return c.toUpperCase();}); }
|
|
|
2471 |
|
|
|
2472 |
function editQuestion(id){
|
|
|
2473 |
var q=null;
|
|
|
2474 |
QADM.questions.forEach(function(x){ if(x.id===id) q=x; });
|
|
|
2475 |
QADM.editing=q;
|
|
|
2476 |
byId('qTitle').textContent=q?'Edit question':'New question';
|
|
|
2477 |
byId('qPrompt').value=q?q.prompt:'';
|
|
|
2478 |
byId('qType').value=q?q.answerType:'TEXT';
|
|
|
2479 |
byId('qPoints').value=q?(q.points||0):0;
|
|
|
2480 |
byId('qChoices').value=q&&q.choices?q.choices:'';
|
|
|
2481 |
byId('qOrder').value=q?q.sortOrder:nextOrder();
|
|
|
2482 |
byId('qRequired').checked=q?!!q.required:false;
|
|
|
2483 |
byId('qActive').checked=q?!!q.active:true;
|
|
|
2484 |
byId('qErr').classList.add('hide');
|
|
|
2485 |
// Delete is only offered when it is actually allowed; the server refuses it otherwise anyway.
|
|
|
2486 |
byId('qDelBtn').style.display=(q && !q.answerCount)?'':'none';
|
|
|
2487 |
var sel=(q&&q.stages)?q.stages.split('|'):[];
|
|
|
2488 |
byId('qStages').innerHTML=QADM.stages.map(function(st){
|
|
|
2489 |
return '<button type="button" class="chkopt qstage'+(sel.indexOf(st)>=0?' sel':'')+'" data-stage="'+st+'">'+
|
|
|
2490 |
esc(prettyStage(st))+'</button>'; }).join('');
|
|
|
2491 |
qTypeChanged();
|
|
|
2492 |
byId('qModal').classList.remove('hide');
|
|
|
2493 |
}
|
|
|
2494 |
function nextOrder(){
|
|
|
2495 |
var mx=0; QADM.questions.forEach(function(q){ if(q.sortOrder>mx) mx=q.sortOrder; });
|
|
|
2496 |
return mx+10;
|
|
|
2497 |
}
|
|
|
2498 |
function closeQ(){ byId('qModal').classList.add('hide'); }
|
|
|
2499 |
function qTypeChanged(){
|
|
|
2500 |
byId('qChoicesWrap').classList.toggle('hide', byId('qType').value!=='CHOICE');
|
|
|
2501 |
}
|
|
|
2502 |
function qErr(m){ var e=byId('qErr'); e.textContent=m; e.classList.remove('hide'); }
|
|
|
2503 |
|
|
|
2504 |
function saveQuestion(){
|
|
|
2505 |
var prompt=(byId('qPrompt').value||'').trim();
|
|
|
2506 |
if(!prompt){ qErr('The question text is required'); return; }
|
|
|
2507 |
var type=byId('qType').value;
|
|
|
2508 |
var choices=(byId('qChoices').value||'').trim();
|
|
|
2509 |
if(type==='CHOICE' && !choices){ qErr('A choice question needs at least one option'); return; }
|
|
|
2510 |
var stages=[];
|
|
|
2511 |
document.querySelectorAll('#qStages .qstage.sel').forEach(function(b){ stages.push(b.getAttribute('data-stage')); });
|
|
|
2512 |
var body={ id:QADM.editing?QADM.editing.id:0, prompt:prompt, answerType:type,
|
|
|
2513 |
choices:choices, required:byId('qRequired').checked, active:byId('qActive').checked,
|
|
|
2514 |
points:parseInt(byId('qPoints').value,10)||0, stages:stages.join('|'),
|
|
|
2515 |
sortOrder:parseInt(byId('qOrder').value,10)||0 };
|
|
|
2516 |
var b=byId('qSaveBtn'); b.disabled=true; b.textContent='Saving…';
|
|
|
2517 |
postJSON('/lms/checklist/questions/save', body, function(res){
|
|
|
2518 |
b.disabled=false; b.textContent='Save';
|
|
|
2519 |
var why=refusal(res); if(why){ qErr(why); return; }
|
|
|
2520 |
closeQ(); toast(res&&res.created?'Question added':'Question updated',1); loadQuestions();
|
|
|
2521 |
}, function(){ b.disabled=false; b.textContent='Save'; qErr('Could not save the question'); });
|
|
|
2522 |
}
|
|
|
2523 |
|
|
|
2524 |
function deleteQuestion(){
|
|
|
2525 |
if(!QADM.editing) return;
|
|
|
2526 |
if(!confirm('Delete this question? Switching it off keeps it out of calls without losing anything.')) return;
|
|
|
2527 |
postForm('/lms/checklist/questions/delete', {id:QADM.editing.id}, function(res){
|
|
|
2528 |
var why=refusal(res); if(why){ qErr(why); return; }
|
|
|
2529 |
closeQ(); toast('Question deleted',1); loadQuestions();
|
|
|
2530 |
});
|
|
|
2531 |
}
|
|
|
2532 |
|
|
|
2533 |
// ---- first-call checklist -----------------------------------------------------------------
|
|
|
2534 |
// Opened automatically on the first ANSWERED call to a lead. Deliberately not on dial: a question
|
|
|
2535 |
// set that appears while the phone is still ringing is a question set that gets dismissed.
|
|
|
2536 |
var CHK = { leadId:null, callId:null, questions:[] };
|
|
|
2537 |
|
|
|
2538 |
function maybeOpenChecklist(leadId, callId){
|
|
|
2539 |
if(!leadId) return;
|
|
|
2540 |
getJSON('/lms/checklist?leadId='+leadId+(callId?('&leadCallId='+callId):''), function(d){
|
|
|
2541 |
if(!d || !d.firstCall) return; // not their first connected call — stay out of the way
|
|
|
2542 |
if(!d.questions || !d.questions.length) return;
|
|
|
2543 |
openChk(d, leadId, callId);
|
|
|
2544 |
}, function(){ /* the checklist must never take the call down with it */ });
|
|
|
2545 |
}
|
|
|
2546 |
|
|
|
2547 |
function openChk(d, leadId, callId){
|
|
|
2548 |
CHK.leadId=leadId; CHK.callId=callId; CHK.questions=d.questions;
|
|
|
2549 |
byId('chkWho').textContent=d.retailer||('Lead #'+leadId);
|
|
|
2550 |
byId('chkNote').textContent=d.answered?'Already answered once — saving will update.':'';
|
|
|
2551 |
byId('chkBody').innerHTML=d.questions.map(function(q,i){
|
|
|
2552 |
var body;
|
|
|
2553 |
if(q.answerType==='CHOICE' && q.choices && q.choices.length){
|
|
|
2554 |
body='<div class="opts" data-qid="'+q.id+'">'+q.choices.map(function(c){
|
|
|
2555 |
return '<button type="button" class="chkopt'+(q.answer===c?' sel':'')+'" data-choice="'+esc(c)+'">'+esc(c)+'</button>';
|
|
|
2556 |
}).join('')+'</div>';
|
|
|
2557 |
} else if(q.answerType==='YESNO'){
|
|
|
2558 |
body='<div class="opts" data-qid="'+q.id+'">'+['Yes','No'].map(function(c){
|
|
|
2559 |
return '<button type="button" class="chkopt'+(q.answer===c?' sel':'')+'" data-choice="'+c+'">'+c+'</button>';
|
|
|
2560 |
}).join('')+'</div>';
|
|
|
2561 |
} else {
|
|
|
2562 |
body='<input id="chkq'+q.id+'" class="inp" style="width:100%"'+
|
|
|
2563 |
(q.answerType==='NUMBER'?' type="number" step="0.1" min="0"':'')+
|
|
|
2564 |
' value="'+esc(q.answer==null?'':q.answer)+'">';
|
|
|
2565 |
}
|
|
|
2566 |
return '<div class="chkq"><div class="qp">'+(i+1)+'. '+esc(q.prompt)+
|
|
|
2567 |
(q.required?'<span class="req">*</span>':'')+
|
|
|
2568 |
(q.points?'<span class="note" style="display:inline;margin-left:6px">'+q.points+' pts</span>':'')+
|
|
|
2569 |
'</div>'+body+'</div>';
|
|
|
2570 |
}).join('');
|
|
|
2571 |
byId('chkModal').classList.remove('hide');
|
|
|
2572 |
}
|
|
|
2573 |
function closeChk(){ byId('chkModal').classList.add('hide'); }
|
|
|
2574 |
|
|
|
2575 |
/** Read the current answer for a question, whichever control rendered it. */
|
|
|
2576 |
function chkValue(q){
|
|
|
2577 |
if(q.answerType==='CHOICE' || q.answerType==='YESNO'){
|
|
|
2578 |
var sel=document.querySelector('#chkBody .opts[data-qid="'+q.id+'"] .chkopt.sel');
|
|
|
2579 |
return sel?sel.getAttribute('data-choice'):'';
|
|
|
2580 |
}
|
|
|
2581 |
var el=byId('chkq'+q.id);
|
|
|
2582 |
return el?(el.value||'').trim():'';
|
|
|
2583 |
}
|
|
|
2584 |
|
|
|
2585 |
function saveChk(){
|
|
|
2586 |
var answers=[], missing=[];
|
|
|
2587 |
CHK.questions.forEach(function(q){
|
|
|
2588 |
var v=chkValue(q);
|
|
|
2589 |
if(q.required && !v) missing.push(q.prompt);
|
|
|
2590 |
if(v) answers.push({questionId:q.id, answer:v});
|
|
|
2591 |
});
|
|
|
2592 |
if(missing.length){ toast('Still needed: '+missing[0]); return; }
|
|
|
2593 |
if(!answers.length){ toast('Nothing answered yet'); return; }
|
|
|
2594 |
var b=byId('chkSaveBtn'); b.disabled=true; b.textContent='Saving…';
|
|
|
2595 |
postJSON('/lms/checklist',{leadId:CHK.leadId, leadCallId:CHK.callId, answers:answers}, function(res){
|
|
|
2596 |
b.disabled=false; b.textContent='Save answers';
|
|
|
2597 |
var why=refusal(res); if(why){ toast(why); return; }
|
|
|
2598 |
closeChk();
|
|
|
2599 |
toast('Checklist saved ('+(res&&res.saved||0)+' answers)',1);
|
|
|
2600 |
if(REC && REC.id===CHK.leadId) reloadRecord();
|
|
|
2601 |
}, function(){ b.disabled=false; b.textContent='Save answers'; toast('Could not save the checklist'); });
|
|
|
2602 |
}
|
|
|
2603 |
|
| 37692 |
vikas |
2604 |
// ---- disposition --------------------------------------------------------------------------
|
|
|
2605 |
var DISPS=[['INTERESTED','Interested'],['CALLBACK','Callback'],['NOT_REACHABLE','Not reachable'],
|
|
|
2606 |
['WRONG_NUMBER','Wrong number'],['FOLLOW_UP','Follow-up'],['NOT_INTERESTED','Not interested'],
|
|
|
2607 |
['DO_NOT_CALL','Do not call']];
|
|
|
2608 |
var DISP=null;
|
| 37737 |
vikas |
2609 |
/** The lead this disposition is about: the one dialled, else the record on screen. */
|
|
|
2610 |
function dispLeadId(){ return CALL.leadId || (REC ? REC.id : null); }
|
|
|
2611 |
|
| 37692 |
vikas |
2612 |
function openDisp(){
|
| 37737 |
vikas |
2613 |
if(!dispLeadId()) return;
|
| 37692 |
vikas |
2614 |
DISP=null;
|
|
|
2615 |
byId('dispGrid').innerHTML=DISPS.map(function(d){
|
|
|
2616 |
return '<button class="btn ghost sm dispopt" data-d="'+d[0]+'" onclick="pickDisp(\''+d[0]+'\')">'+d[1]+'</button>'; }).join('');
|
|
|
2617 |
byId('dispExtra').innerHTML=''; byId('dispNote').value='';
|
|
|
2618 |
byId('dispModal').classList.remove('hide');
|
|
|
2619 |
}
|
|
|
2620 |
function closeDisp(){ byId('dispModal').classList.add('hide'); }
|
|
|
2621 |
function pickDisp(d){
|
|
|
2622 |
DISP=d;
|
|
|
2623 |
document.querySelectorAll('.dispopt').forEach(function(b){
|
|
|
2624 |
b.className='btn '+(b.getAttribute('data-d')===d?'coral':'ghost')+' sm dispopt'; });
|
|
|
2625 |
// Contextual sub-fields, mirroring SOP §12.2 and the in-app modal.
|
|
|
2626 |
var x='';
|
|
|
2627 |
if(d==='INTERESTED') x=fld('dxValue','Business value (₹ lakh / month) — required','');
|
|
|
2628 |
else if(d==='CALLBACK') x='<label class="lbl">Callback date & time — required</label><input id="dxCallback" type="datetime-local" class="inp" style="width:100%">';
|
|
|
2629 |
else if(d==='NOT_REACHABLE')x=fld('dxRetry','Retry schedule (optional)','');
|
|
|
2630 |
else if(d==='WRONG_NUMBER') x=fld('dxCorrected','Corrected number (blank closes the lead)','');
|
|
|
2631 |
else if(d==='FOLLOW_UP') x='<label class="lbl">Follow-up type</label><select id="dxFuType" class="inp" style="width:100%"><option value="CALL">Call</option><option value="MEETING">Meeting / visit</option></select>'+
|
|
|
2632 |
'<label class="lbl">Follow-up date & time — required</label><input id="dxFollowUp" type="datetime-local" class="inp" style="width:100%">';
|
|
|
2633 |
else if(d==='NOT_INTERESTED')x='<label class="lbl">Sub-reason</label><select id="dxSub" class="inp" style="width:100%">'+
|
|
|
2634 |
['Price','Terms','Already partnered','Category mismatch','Other'].map(function(o){return '<option>'+o+'</option>';}).join('')+'</select>';
|
|
|
2635 |
else if(d==='DO_NOT_CALL') x='<div class="note" style="color:#e03131">Blocks this number permanently across every lead, not just this one.</div>';
|
|
|
2636 |
byId('dispExtra').innerHTML=x;
|
|
|
2637 |
}
|
|
|
2638 |
function saveDisp(){
|
|
|
2639 |
if(!DISP){ toast('Pick a disposition'); return; }
|
| 37737 |
vikas |
2640 |
var lid=dispLeadId();
|
|
|
2641 |
if(!lid){ toast('No lead to disposition'); return; }
|
|
|
2642 |
var p={leadId:lid,disposition:DISP,note:byId('dispNote').value,leadCallId:CALL.id||null};
|
| 37692 |
vikas |
2643 |
if(DISP==='INTERESTED'){ var v=parseFloat(byId('dxValue').value);
|
|
|
2644 |
if(!v||v<=0){ toast('Business value is required'); return; } p.value=v; }
|
|
|
2645 |
else if(DISP==='CALLBACK'){ if(!byId('dxCallback').value){ toast('Callback date/time is required'); return; }
|
|
|
2646 |
p.callbackAt=byId('dxCallback').value; }
|
|
|
2647 |
else if(DISP==='NOT_REACHABLE') p.retrySchedule=byId('dxRetry').value;
|
|
|
2648 |
else if(DISP==='WRONG_NUMBER') p.correctedNumber=byId('dxCorrected').value;
|
|
|
2649 |
else if(DISP==='FOLLOW_UP'){ if(!byId('dxFollowUp').value){ toast('Follow-up date/time is required'); return; }
|
|
|
2650 |
p.followUpType=byId('dxFuType').value; p.followUpAt=byId('dxFollowUp').value; }
|
|
|
2651 |
else if(DISP==='NOT_INTERESTED') p.subReason=byId('dxSub').value;
|
|
|
2652 |
postJSON('/lms/disposition',p,function(res){
|
|
|
2653 |
var why=refusal(res); if(why){ toast(why); return; }
|
| 37737 |
vikas |
2654 |
closeDisp();
|
|
|
2655 |
var wasDialled=CALL.leadId;
|
|
|
2656 |
CALL.id=null; CALL.leadId=null; CALL.btn=null;
|
|
|
2657 |
hideBar();
|
|
|
2658 |
toast('Disposition saved: '+DISP.replace(/_/g,' '),1);
|
|
|
2659 |
// Refresh whichever view the user is actually looking at.
|
|
|
2660 |
if(REC && (!wasDialled || REC.id===wasDialled)) reloadRecord();
|
|
|
2661 |
if(!byId('cmd').classList.contains('hide')) loadPipeline();
|
|
|
2662 |
refresh();
|
| 37692 |
vikas |
2663 |
});
|
|
|
2664 |
}
|
|
|
2665 |
|
|
|
2666 |
// ---- manual geo verification ---------------------------------------------------------------
|
|
|
2667 |
function geoErr(msg){ var e=byId('geoErr'); e.textContent=msg; e.classList.remove('hide'); }
|
|
|
2668 |
function geoClearErr(){ byId('geoErr').classList.add('hide'); }
|
|
|
2669 |
|
|
|
2670 |
function openGeo(){
|
|
|
2671 |
if(!REC) return;
|
|
|
2672 |
geoClearErr();
|
|
|
2673 |
// Seed from what the record already knows, so a nudge is an edit rather than a re-entry.
|
|
|
2674 |
byId('geoAddress').value=[REC.address,REC.city,REC.state].filter(function(x){return x;}).join(', ');
|
|
|
2675 |
byId('geoLatLng').value=(REC.geoLat!=null&&REC.geoLng!=null)?(REC.geoLat+','+REC.geoLng):'';
|
|
|
2676 |
byId('geoRemark').value='';
|
|
|
2677 |
byId('geoPhoto').value='';
|
|
|
2678 |
byId('geoPhotoImg').classList.add('hide');
|
|
|
2679 |
byId('geoMapWrap').classList.add('hide');
|
|
|
2680 |
byId('geoModal').classList.remove('hide');
|
|
|
2681 |
if(REC.geoLat!=null&&REC.geoLng!=null) geoShowMap(REC.geoLat,REC.geoLng);
|
|
|
2682 |
}
|
|
|
2683 |
function closeGeo(){ byId('geoModal').classList.add('hide'); }
|
|
|
2684 |
|
|
|
2685 |
/** Accepts "lat,lng" or a Maps URL: /@lat,lng, ?q=lat,lng, ?ll=, ?destination=. */
|
|
|
2686 |
function parseLatLng(raw){
|
|
|
2687 |
if(!raw) return null;
|
|
|
2688 |
var m=String(raw).match(/(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)/);
|
|
|
2689 |
return m?{lat:parseFloat(m[1]),lng:parseFloat(m[2])}:null;
|
|
|
2690 |
}
|
|
|
2691 |
function geoShowMap(lat,lng){
|
|
|
2692 |
byId('geoMapFrame').src='https://www.google.com/maps?q='+lat+','+lng+'&z=17&output=embed';
|
|
|
2693 |
byId('geoOpenMap').href='https://www.google.com/maps?q='+lat+','+lng;
|
|
|
2694 |
byId('geoMapWrap').classList.remove('hide');
|
|
|
2695 |
}
|
|
|
2696 |
function geoPreview(){
|
|
|
2697 |
var p=parseLatLng(byId('geoLatLng').value);
|
|
|
2698 |
if(!p){ geoErr('Enter latitude,longitude first — like 28.6139,77.2090.'); return; }
|
|
|
2699 |
geoClearErr(); geoShowMap(p.lat,p.lng);
|
|
|
2700 |
}
|
|
|
2701 |
function geoGeocode(){
|
|
|
2702 |
var a=(byId('geoAddress').value||'').trim();
|
|
|
2703 |
if(!a){ geoErr('Enter an address first.'); return; }
|
|
|
2704 |
geoClearErr();
|
|
|
2705 |
var b=byId('geoFindBtn'); b.disabled=true; b.textContent='Finding…';
|
|
|
2706 |
postForm('/lead-geo/geocode-address',{address:a},function(r){
|
|
|
2707 |
b.disabled=false; b.textContent='Find on map';
|
|
|
2708 |
if(!r||!r.success){ geoErr((r&&r.message)||'Could not geocode that address.'); return; }
|
|
|
2709 |
byId('geoLatLng').value=r.latitude+','+r.longitude;
|
|
|
2710 |
geoShowMap(r.latitude,r.longitude);
|
|
|
2711 |
},function(){ b.disabled=false; b.textContent='Find on map'; geoErr('Geocode request failed.'); });
|
|
|
2712 |
}
|
|
|
2713 |
function geoPickPhoto(){
|
|
|
2714 |
var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];
|
|
|
2715 |
if(!f){ byId('geoPhotoImg').classList.add('hide'); return; }
|
|
|
2716 |
var fr=new FileReader();
|
|
|
2717 |
fr.onload=function(ev){ byId('geoPhotoImg').src=ev.target.result; byId('geoPhotoImg').classList.remove('hide'); };
|
|
|
2718 |
fr.readAsDataURL(f);
|
|
|
2719 |
}
|
|
|
2720 |
function geoSave(){
|
|
|
2721 |
var p=parseLatLng(byId('geoLatLng').value);
|
|
|
2722 |
if(!p){ geoErr('Could not read latitude/longitude. Paste like 28.6139,77.2090.'); return; }
|
|
|
2723 |
if(p.lat<-90||p.lat>90||p.lng<-180||p.lng>180||(p.lat===0&&p.lng===0)){ geoErr('Latitude/longitude out of range.'); return; }
|
|
|
2724 |
var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];
|
|
|
2725 |
if(!f){ geoErr('Attach a store-board photo — the server requires one.'); return; }
|
|
|
2726 |
geoClearErr();
|
|
|
2727 |
var b=byId('geoSaveBtn'); b.disabled=true; b.textContent='Uploading photo…';
|
|
|
2728 |
|
|
|
2729 |
// Upload first: manual-verify needs the document id, and uploading only on save means a
|
|
|
2730 |
// cancelled dialog never orphans a document.
|
|
|
2731 |
var fd=new FormData(); fd.append('file',f,f.name||'store-photo.jpg');
|
|
|
2732 |
fetch(CTX+'/lead-geo/upload-image',{method:'POST',credentials:'same-origin',
|
|
|
2733 |
headers:{'X-Requested-With':'XMLHttpRequest'},body:fd})
|
|
|
2734 |
.then(function(r){ return r.text(); })
|
|
|
2735 |
.then(function(t){
|
|
|
2736 |
var d=null; try{ d=JSON.parse(t); }catch(e){}
|
|
|
2737 |
var r=(d&&d.response!==undefined)?d.response:d;
|
|
|
2738 |
var docId=r&&(r.document_id||r.documentId||r.id);
|
|
|
2739 |
if(!docId){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); return; }
|
|
|
2740 |
b.textContent='Verifying…';
|
|
|
2741 |
postForm('/lead-geo/manual-verify',
|
|
|
2742 |
{leadId:REC.id,latitude:p.lat,longitude:p.lng,remark:byId('geoRemark').value,imageDocumentId:docId},
|
|
|
2743 |
function(res){
|
|
|
2744 |
b.disabled=false; b.textContent='Save & verify';
|
|
|
2745 |
var why=refusal(res);
|
|
|
2746 |
if(why){ geoErr(why); return; }
|
|
|
2747 |
closeGeo(); toast('Location verified — this lead can now go on a beat',1); reloadRecord();
|
|
|
2748 |
},
|
|
|
2749 |
function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Could not save the verification.'); });
|
|
|
2750 |
})
|
|
|
2751 |
.catch(function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); });
|
|
|
2752 |
}
|
|
|
2753 |
|
| 37125 |
vikas |
2754 |
// ====================== CREATE LEAD (real /lms/resolve-region + /createLead) ======================
|
|
|
2755 |
var CREATE_PATH='A';
|
|
|
2756 |
function setPath(p,btn){ CREATE_PATH=p;
|
|
|
2757 |
byId('pathA').className='btn '+(p==='A'?'coral':'ghost')+' sm'; byId('pathB').className='btn '+(p==='B'?'coral':'ghost')+' sm';
|
| 37651 |
vikas |
2758 |
byId('createDesc').textContent=(p==='A')?'Path A — captured by a BGC agent during an outbound call, after consent. Owner is the region BM.':'Path B — field encounter. Owner is you, the creator.';
|
|
|
2759 |
}
|
| 37125 |
vikas |
2760 |
function initials(n){ n=(n||'').trim(); if(!n) return '—'; var p=n.split(/\s+/); return ((p[0]||'')[0]||'').toUpperCase()+((p[1]||'')[0]||'').toUpperCase(); }
|
|
|
2761 |
function resolveBM(regionId){ if(!regionId){ byId('rName').textContent='Pick a region…'; byId('rRegion').textContent='—'; byId('raAv').textContent='—';
|
|
|
2762 |
byId('rEsc').textContent='The owning BM/RSM is resolved from the region (cs.position).'; return; }
|
|
|
2763 |
getJSON('/lms/resolve-region?regionId='+encodeURIComponent(regionId),function(d){ d=d||{};
|
|
|
2764 |
byId('rRegion').textContent=d.regionCode||''; var nm=d.bmName||'';
|
|
|
2765 |
if(d.assignmentStatus==='HOLD' || !nm){ byId('rName').textContent='No active BM/RSM'; byId('raAv').textContent='!';
|
|
|
2766 |
byId('raAv').style.background='#e03131'; byId('rEsc').innerHTML='Region has no active owner → <b style="color:#e03131">HOLD queue</b>, Sales Head notified.'; }
|
|
|
2767 |
else { byId('rName').textContent=nm; byId('raAv').textContent=initials(nm); byId('raAv').style.background='#1f2a5e';
|
|
|
2768 |
byId('rEsc').textContent='Auto-resolved from region · escalation contact copied on the assignment email.'; } }); }
|
| 37651 |
vikas |
2769 |
// ---- brand-wise business value ------------------------------------------------------------
|
|
|
2770 |
// Agents write "1L", "50k", "1.5 lakh" as readily as digits, and rejecting those would just push
|
|
|
2771 |
// them back to typing a single lump sum. Parsed here so the stored value is always plain rupees.
|
|
|
2772 |
function parseMoney(raw){
|
|
|
2773 |
if(raw===null||raw===undefined) return 0;
|
|
|
2774 |
var t=(''+raw).trim().toLowerCase().replace(/[₹,\s]/g,'');
|
|
|
2775 |
if(!t) return 0;
|
|
|
2776 |
var m=t.match(/^([0-9]*\.?[0-9]+)(k|l|lac|lacs|lakh|lakhs|cr|crore|crores)?$/);
|
|
|
2777 |
if(!m) return 0;
|
|
|
2778 |
var n=parseFloat(m[1]); if(isNaN(n)) return 0;
|
|
|
2779 |
switch(m[2]){
|
|
|
2780 |
case 'k': return Math.round(n*1000);
|
|
|
2781 |
case 'l': case 'lac': case 'lacs': case 'lakh': case 'lakhs': return Math.round(n*100000);
|
|
|
2782 |
case 'cr': case 'crore': case 'crores': return Math.round(n*10000000);
|
|
|
2783 |
default: return Math.round(n);
|
|
|
2784 |
}
|
|
|
2785 |
}
|
|
|
2786 |
function collectBrands(){
|
|
|
2787 |
var out=[];
|
|
|
2788 |
document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){
|
|
|
2789 |
var v=parseMoney(el.value);
|
|
|
2790 |
if(v>0) out.push({brand:el.getAttribute('data-brand'), value:v});
|
|
|
2791 |
});
|
|
|
2792 |
return out;
|
|
|
2793 |
}
|
|
|
2794 |
function brandTotal(){
|
|
|
2795 |
var t=0; collectBrands().forEach(function(b){ t+=b.value; });
|
|
|
2796 |
var el=byId('cValueTotal');
|
|
|
2797 |
if(el) el.textContent='total '+(t>0?fmtMoney(t):'₹0');
|
| 37760 |
vikas |
2798 |
syncCounter(t);
|
| 37651 |
vikas |
2799 |
return t;
|
|
|
2800 |
}
|
| 37760 |
vikas |
2801 |
// Counter size is the legacy name for the shop's monthly business in rupees (the Leads screen
|
|
|
2802 |
// stored 10,00,000-style values there), so it follows the brand total until the agent overrides
|
|
|
2803 |
// it by typing. Clearing the field hands it back to the sum.
|
|
|
2804 |
var COUNTER_MANUAL=false;
|
|
|
2805 |
function syncCounter(total){
|
|
|
2806 |
var c=byId('cCounter'); if(!c) return;
|
|
|
2807 |
if(COUNTER_MANUAL) return;
|
|
|
2808 |
c.value = total>0 ? String(total) : '';
|
|
|
2809 |
var h=byId('cCounterHint'); if(h) h.textContent='= brand total, editable';
|
|
|
2810 |
}
|
|
|
2811 |
function counterEdited(){
|
|
|
2812 |
var c=byId('cCounter'); if(!c) return;
|
|
|
2813 |
var v=(c.value||'').trim();
|
|
|
2814 |
COUNTER_MANUAL = v.length>0;
|
|
|
2815 |
var h=byId('cCounterHint');
|
|
|
2816 |
if(h) h.textContent = COUNTER_MANUAL ? 'manual — clear to use brand total' : '= brand total, editable';
|
|
|
2817 |
if(!COUNTER_MANUAL) syncCounter(brandTotal());
|
|
|
2818 |
}
|
| 37651 |
vikas |
2819 |
function fmtMoney(n){
|
|
|
2820 |
if(n>=10000000) return '₹'+(n/10000000).toFixed(n%10000000?2:0)+'Cr';
|
|
|
2821 |
if(n>=100000) return '₹'+(n/100000).toFixed(n%100000?2:0)+'L';
|
|
|
2822 |
if(n>=1000) return '₹'+(n/1000).toFixed(n%1000?1:0)+'K';
|
|
|
2823 |
return '₹'+n;
|
|
|
2824 |
}
|
|
|
2825 |
|
| 37125 |
vikas |
2826 |
function val(id){ var e=byId(id); return e?e.value:''; }
|
| 37749 |
vikas |
2827 |
// Store photos go through the same /document-upload the Leads screen uses (multipart "file" ->
|
|
|
2828 |
// response.document_id) and the ids ride along in the create payload as lead_detail columns.
|
|
|
2829 |
function uploadPhoto(input){
|
|
|
2830 |
var f=input&&input.files&&input.files[0]; if(!f) return Promise.resolve(0);
|
|
|
2831 |
var fd=new FormData(); fd.append('file',f);
|
|
|
2832 |
return fetch(CTX+'/document-upload',{method:'POST',credentials:'same-origin',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}})
|
|
|
2833 |
.then(function(r){ return r.json(); })
|
|
|
2834 |
.then(function(d){ var id=d&&d.response&&d.response.document_id; if(!id) throw new Error('upload'); return parseInt(id,10); });
|
|
|
2835 |
}
|
|
|
2836 |
function uploadPhotos(){
|
|
|
2837 |
var inputs=Array.prototype.slice.call(document.querySelectorAll('#cPhotoGrid .cphoto'));
|
|
|
2838 |
var ids={};
|
|
|
2839 |
return inputs.reduce(function(chain,inp){ return chain.then(function(){
|
|
|
2840 |
return uploadPhoto(inp).then(function(id){ if(id) ids[inp.getAttribute('data-photo')]=id; }); }); }, Promise.resolve())
|
|
|
2841 |
.then(function(){ return ids; });
|
|
|
2842 |
}
|
|
|
2843 |
var CREATE_BUSY=false;
|
|
|
2844 |
function submitLead(){ if(CREATE_BUSY) return;
|
|
|
2845 |
var fn=val('cRetailer').trim(), biz=val('cBusiness').trim();
|
| 37651 |
vikas |
2846 |
var phone=(val('cContact')||'').replace(/\D/g,'').slice(-10);
|
|
|
2847 |
var brands=collectBrands(); var value=brandTotal();
|
| 37125 |
vikas |
2848 |
var region=val('cRegion'); var path=CREATE_PATH;
|
| 37651 |
vikas |
2849 |
if(!fn){ toast('Retailer name is required'); return; }
|
|
|
2850 |
if(phone.length!==10){ toast('A valid 10-digit contact is required'); return; }
|
|
|
2851 |
if(!region){ toast('Pick a region'); return; }
|
| 37749 |
vikas |
2852 |
var address=val('cAddress').trim(), city=val('cCity').trim(), state=val('cState');
|
|
|
2853 |
if(address.length>128){ toast('Address is too long (128 max)'); return; }
|
|
|
2854 |
if(city.length>30){ toast('City is too long (30 max)'); return; }
|
| 37760 |
vikas |
2855 |
var counter=parseMoney(val('cCounter'));
|
|
|
2856 |
if(counter>2147483647){ toast('Counter size is too large — enter rupees per month'); return; }
|
| 37749 |
vikas |
2857 |
// No address check on either path: the capture link is what establishes location, and it is
|
|
|
2858 |
// generated for every lead regardless of path. Address / city / state are kept when typed.
|
|
|
2859 |
var payload={ firstName:fn, mobile:phone, outletName:biz||'', source:val('cSource')||'LMS Dashboard',
|
|
|
2860 |
regionId:parseInt(region,10), creationPath:path,
|
|
|
2861 |
address:address, city:city, state:state||'',
|
|
|
2862 |
conversionHigh:(val('cConversion')==='true'), remark:val('cRemark').trim() };
|
|
|
2863 |
if(!isNaN(counter)&&counter>0) payload.counterSize=counter;
|
| 37651 |
vikas |
2864 |
// potential is the sum, so the funnel's value column keeps working, while the per-brand split
|
|
|
2865 |
// is what actually tells a BM what the counter is worth.
|
| 37125 |
vikas |
2866 |
if(value && value>0) payload.potential=value;
|
| 37651 |
vikas |
2867 |
if(brands.length) payload.leadBrands=brands;
|
| 37749 |
vikas |
2868 |
CREATE_BUSY=true;
|
|
|
2869 |
var anyPhoto=Array.prototype.some.call(document.querySelectorAll('#cPhotoGrid .cphoto'),function(i){ return i.files&&i.files.length; });
|
|
|
2870 |
if(anyPhoto) toast('Uploading photos…');
|
|
|
2871 |
uploadPhotos().then(function(ids){
|
|
|
2872 |
for(var k in ids){ if(ids.hasOwnProperty(k)) payload[k]=ids[k]; }
|
|
|
2873 |
// /lms/createLead, NOT the shared /createLead: this one honours regionId and creationPath, so
|
|
|
2874 |
// the lead actually gets a stage, an LMS code, an owner and an SLA clock.
|
|
|
2875 |
return fetch(CTX+'/lms/createLead',{method:'POST',credentials:'same-origin',
|
| 37651 |
vikas |
2876 |
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},
|
| 37749 |
vikas |
2877 |
body:JSON.stringify(payload)}).then(function(r){ return r.text(); }); })
|
|
|
2878 |
.then(function(t){ CREATE_BUSY=false;
|
| 37651 |
vikas |
2879 |
var d=null; try{ d=JSON.parse(t); }catch(e){ d=null; }
|
|
|
2880 |
var res=(d&&d.response)?d.response:d;
|
|
|
2881 |
if(!res || res.ok!==true){
|
|
|
2882 |
// A refusal carries the reason as a bare string under `response` (badRequest("...")),
|
|
|
2883 |
// whereas success is an object. Surface the reason, not a generic failure.
|
|
|
2884 |
toast((typeof res==='string' && res) || (res&&res.message) || 'Create failed');
|
|
|
2885 |
return;
|
|
|
2886 |
}
|
|
|
2887 |
var held=(res.assignmentStatus==='HOLD');
|
|
|
2888 |
toast(held ? ('Lead '+res.lmsCode+' created · no BM for that region, parked in HOLD')
|
|
|
2889 |
: ('Lead '+res.lmsCode+' created · assigned to '+(res.ownerName||'the region BM')+' · SLA started'), 1);
|
| 37749 |
vikas |
2890 |
['cRetailer','cBusiness','cContact','cAddress','cCity','cCounter','cRemark'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });
|
|
|
2891 |
['cState'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });
|
|
|
2892 |
var cs=byId('cSource'); if(cs) cs.value='LMS Dashboard'; var cc=byId('cConversion'); if(cc) cc.value='false';
|
| 37651 |
vikas |
2893 |
document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){ el.value=''; });
|
| 37749 |
vikas |
2894 |
document.querySelectorAll('#cPhotoGrid .cphoto').forEach(function(el){ el.value=''; });
|
| 37760 |
vikas |
2895 |
COUNTER_MANUAL=false; brandTotal();
|
| 37651 |
vikas |
2896 |
|
|
|
2897 |
// Hand the capture link over immediately — the whole point of generating it on create is
|
|
|
2898 |
// that the agent is still on the call with the retailer.
|
|
|
2899 |
if(res.geoLink){
|
|
|
2900 |
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
|
2901 |
navigator.clipboard.writeText(res.geoLink).then(function(){
|
|
|
2902 |
alert('Capture link copied to your clipboard — send it to the retailer now:\n\n'+res.geoLink);
|
|
|
2903 |
}, function(){ prompt('Send this capture link to the retailer:', res.geoLink); });
|
|
|
2904 |
} else {
|
|
|
2905 |
prompt('Send this capture link to the retailer:', res.geoLink);
|
|
|
2906 |
}
|
|
|
2907 |
}
|
|
|
2908 |
|
|
|
2909 |
byId('recLookup').value=res.leadId; injectRecord(res.leadId);
|
| 37760 |
vikas |
2910 |
go('record',document.querySelector('.tab[data-screen="record"]')); refresh(); })
|
| 37749 |
vikas |
2911 |
.catch(function(e){ CREATE_BUSY=false; toast(e&&e.message==='upload'?'A photo failed to upload — the lead was not created':'Create failed'); }); }
|
| 37125 |
vikas |
2912 |
|
|
|
2913 |
// ====================== AUTO-ASSIGNMENT (real resolve per region) ======================
|
| 37651 |
vikas |
2914 |
function buildAssign(){ var opts=byId('cRegion').querySelectorAll('option'); var holdCount=0, assignedCount=0, pending=0;
|
|
|
2915 |
var body=byId('assignMap'); body.innerHTML='';
|
|
|
2916 |
// Rows are created up-front in region order, then filled in as each resolve lands — otherwise
|
|
|
2917 |
// the table would be ordered by whichever request happened to answer first.
|
| 37125 |
vikas |
2918 |
opts.forEach(function(o){ if(!o.value) return; pending++;
|
| 37651 |
vikas |
2919 |
var label=o.textContent;
|
|
|
2920 |
var tr=document.createElement('tr');
|
| 37760 |
vikas |
2921 |
tr.innerHTML='<td><b>'+esc(label)+'</b></td><td colspan="3"><span class="ph">Resolving…</span></td>';
|
| 37651 |
vikas |
2922 |
body.appendChild(tr);
|
|
|
2923 |
function done(){ if(--pending===0){ renderAssignKpis(assignedCount,holdCount); } }
|
|
|
2924 |
getJSON('/lms/resolve-region?regionId='+encodeURIComponent(o.value),function(d){ d=d||{};
|
| 37125 |
vikas |
2925 |
var hold=(d.assignmentStatus==='HOLD' || !d.bmName);
|
|
|
2926 |
if(hold) holdCount++; else assignedCount++;
|
|
|
2927 |
tr.innerHTML='<td><b>'+esc(label)+'</b></td>'+
|
|
|
2928 |
'<td>'+(hold?'<span class="warnflag">— vacant —</span>':esc(d.bmName))+'</td>'+
|
| 37760 |
vikas |
2929 |
'<td>'+(hold?'<span class="stage st-ni">HOLD</span>':'<span class="stage st-active">Active</span>')+'</td>'+
|
|
|
2930 |
'<td class="num"><button type="button" class="btn ghost sm" data-rgn="'+esc(o.value)+'" data-rgnlabel="'+esc(label)+'" data-rgnowner="'+esc(hold?'':(d.bmName||''))+'">'+(hold?'Assign':'Change')+'</button></td>';
|
| 37651 |
vikas |
2931 |
done();
|
| 37760 |
vikas |
2932 |
},function(){ tr.innerHTML='<td><b>'+esc(label)+'</b></td><td colspan="3"><span class="warnflag">could not resolve</span></td>';
|
| 37651 |
vikas |
2933 |
done(); }); });
|
| 37125 |
vikas |
2934 |
// HOLD queue leads
|
|
|
2935 |
getJSON('/lms/dashboard/leads?bucketType=ATTENTION&bucketKey=HOLD',function(d){ var leads=(d&&d.leads)?d.leads:[];
|
|
|
2936 |
if(!leads.length){ byId('holdQueue').innerHTML='<div class="ph">No leads in HOLD. 🎉</div>'; return; }
|
|
|
2937 |
var h=''; leads.slice(0,8).forEach(function(l){ h+='<div style="display:flex;align-items:center;gap:10px;font-size:12.5px;padding:5px 0" class="clickrow" data-leadid="'+l.id+'">'+
|
|
|
2938 |
'<span class="idpill">'+esc(l.lmsCode)+'</span> <span>'+esc(l.name||l.outlet||'—')+'</span></div>'; });
|
|
|
2939 |
byId('holdQueue').innerHTML=h; }); }
|
|
|
2940 |
function renderAssignKpis(assigned,hold){ var k=[
|
|
|
2941 |
{i:'📧',bg:'#eef1fb',fg:'#3b5bdb',l:'Regions mapped',v:assigned,f:'active BM/RSM'},
|
|
|
2942 |
{i:'⏸',bg:'#fff0f0',fg:'#e03131',l:'Regions on HOLD',v:hold,f:'no active BM',vc:'#e03131'},
|
|
|
2943 |
{i:'👤',bg:'#fff4e6',fg:'#e8590c',l:'HOLD leads',v:(DATA.summary&&attn('HOLD'))||0,f:'awaiting a BM',vc:'#e8590c'},
|
|
|
2944 |
{i:'⚡',bg:'#ebfbee',fg:'#2f9e44',l:'Resolve tiers',v:'L4→L5→L3',f:'cs.position'} ];
|
|
|
2945 |
var h=''; k.forEach(function(x){ h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+x.bg+';color:'+x.fg+'">'+x.i+'</div>'+
|
|
|
2946 |
'<div class="label">'+esc(x.l)+'</div></div><div class="val"'+(x.vc?' style="color:'+x.vc+'"':'')+'>'+(typeof x.v==='number'?fmtInt(x.v):x.v)+'</div><div class="foot">'+esc(x.f)+'</div></div>'; });
|
|
|
2947 |
byId('assignKpis').innerHTML=h; }
|
|
|
2948 |
|
|
|
2949 |
function attn(key){ var s=DATA.summary; if(!s||!s.attention) return 0; var f=0; s.attention.forEach(function(a){ if(a.key===key)f=a.count; }); return f; }
|
|
|
2950 |
|
|
|
2951 |
// ====================== RECORDINGS & AUDIT (live hygiene from attention) ======================
|
|
|
2952 |
function buildAudit(){
|
|
|
2953 |
// KPIs — missing dispositions is live; recording metrics are pending source.
|
|
|
2954 |
var miss=attn('MISSING_DISP'), br=attn('BREACHED_NO_CONTACT'), hold=attn('HOLD');
|
|
|
2955 |
var kp=[ {i:'🎵',bg:'#fff0ef',fg:'#f0483e',l:'Recordings (24 mo)',v:'—',f:'pending source'},
|
|
|
2956 |
{i:'💬',bg:'#fff4e6',fg:'#e8590c',l:'Missing dispositions',v:miss,f:'live',vc:'#e8590c'},
|
|
|
2957 |
{i:'🚫',bg:'#fff0f0',fg:'#e03131',l:'DND breaches',v:'—',f:'needs call log'},
|
|
|
2958 |
{i:'👁',bg:'#eef1fb',fg:'#3b5bdb',l:'Recording opens',v:'—',f:'pending source'},
|
|
|
2959 |
{i:'🧹',bg:'#e6fcf5',fg:'#0ca678',l:'Onboarded (deep audit)',v:fmtInt(onboardedCount()),f:'5% sampled'} ];
|
|
|
2960 |
var h=''; kp.forEach(function(x){ h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+x.bg+';color:'+x.fg+'">'+x.i+'</div>'+
|
|
|
2961 |
'<div class="label">'+esc(x.l)+'</div></div><div class="val"'+(x.vc?' style="color:'+x.vc+'"':'')+'>'+(typeof x.v==='number'?fmtInt(x.v):x.v)+'</div><div class="foot">'+esc(x.f)+'</div></div>'; });
|
|
|
2962 |
byId('auditKpis').innerHTML=h;
|
|
|
2963 |
// hygiene flags (clickable via data-att)
|
|
|
2964 |
var flags=[ {k:'MISSING_DISP',i:'💬',bg:'#fff4e6',fg:'#e8590c',t:'LMS with missing dispositions',d:'Next action stays locked until dispositioned',c:'warnflag',v:miss},
|
|
|
2965 |
{k:'BREACHED_NO_CONTACT',i:'⚠',bg:'#fff0f0',fg:'#e03131',t:'SLA breaches — first contact',d:'Escalated to BM/RSM per matrix',c:'badflag',v:br},
|
|
|
2966 |
{k:'HOLD',i:'👤',bg:'#fff0f0',fg:'#e03131',t:'Unassigned records (HOLD)',d:'Region awaiting a BM',c:'badflag',v:hold} ];
|
|
|
2967 |
var fh=''; flags.forEach(function(a){ fh+='<div class="att" data-att="'+a.k+'" data-attt="'+esc(a.t)+'"><div class="ic" style="background:'+a.bg+';color:'+a.fg+'">'+a.i+'</div>'+
|
|
|
2968 |
'<div class="ab"><div class="atitle">'+esc(a.t)+'</div><div class="adet">'+esc(a.d)+'</div></div><div class="acount '+a.c+'">'+fmtInt(a.v)+'</div></div>'; });
|
|
|
2969 |
byId('auditHygiene').innerHTML=fh; }
|
|
|
2970 |
function onboardedCount(){ var s=DATA.summary; if(!s||!s.funnel) return 0; var c=0; s.funnel.forEach(function(f){ if(f.stage==='ONBOARDED')c=f.count; }); return c; }
|
|
|
2971 |
|
| 37743 |
vikas |
2972 |
// ---- generic table search + sort ---------------------------------------------------------
|
|
|
2973 |
// Every list table in the console gets a sortable header, and one with enough rows to be worth
|
|
|
2974 |
// filtering also gets a search box. Attached by scanning the DOM rather than by editing each of
|
|
|
2975 |
// the fifteen render functions: tables here are built as HTML strings in a dozen places, and a
|
|
|
2976 |
// per-call-site flag would have been missed by the next one written.
|
|
|
2977 |
//
|
|
|
2978 |
// Client-side, so it sorts and filters WHAT IS ON SCREEN. That is the whole population for the
|
|
|
2979 |
// report tables, and the loaded slice for the ones the server pages (the drawer stops at 100,
|
|
|
2980 |
// the call log at its page size) — the count reads "12 of 100 shown" so it never implies more.
|
|
|
2981 |
// The Lead pipeline is excluded: it sorts and searches server-side across every page, and that
|
|
|
2982 |
// is strictly better wherever it exists.
|
|
|
2983 |
|
|
|
2984 |
var TS_SEARCH_MIN_ROWS = 8; // below this a search box is clutter; the sort still attaches
|
|
|
2985 |
var tsBusy = false; // our own DOM writes must not re-trigger the observer
|
|
|
2986 |
|
|
|
2987 |
/** Tables that own their behaviour, or are not lists at all, opt out here. */
|
|
|
2988 |
function tsEligible(t){
|
|
|
2989 |
if(t.hasAttribute('data-ts') || t.hasAttribute('data-nots')) return false;
|
|
|
2990 |
if(t.classList.contains('matrix') || t.classList.contains('heat')) return false;
|
|
|
2991 |
if(t.querySelector('th.sortable')) return false; // the pipeline: server-side sort
|
|
|
2992 |
var head=t.tHead;
|
|
|
2993 |
if(!head || head.rows.length!==1) return false; // no header, or grouped headers:
|
|
|
2994 |
if(!t.tBodies.length) return false; // column index is not 1:1 with th
|
|
|
2995 |
return true;
|
|
|
2996 |
}
|
|
|
2997 |
|
|
|
2998 |
/** Cell text for comparison; a cell may override it with data-sv (e.g. a raw timestamp). */
|
|
|
2999 |
function tsText(td){
|
|
|
3000 |
if(!td) return '';
|
|
|
3001 |
var v = td.getAttribute && td.getAttribute('data-sv');
|
|
|
3002 |
return ((v!==null&&v!==undefined) ? v : td.textContent).replace(/\s+/g,' ').trim();
|
|
|
3003 |
}
|
|
|
3004 |
|
|
|
3005 |
/**
|
|
|
3006 |
* Comparable number for a rendered cell, or null when it is not a number.
|
|
|
3007 |
* Sorting on the printed text would put ₹8L above ₹10L and 2m 40s above 10m, which is the kind
|
|
|
3008 |
* of wrong that is hard to notice — so the formats this console actually prints are parsed.
|
|
|
3009 |
*/
|
|
|
3010 |
function tsValue(txt){
|
|
|
3011 |
if(!txt || txt==='—' || txt==='-') return null;
|
|
|
3012 |
// duration: "2m 40s", "1h 05m", "45s"
|
|
|
3013 |
if(/\d/.test(txt) && /^(\d+h\s*)?(\d+m\s*)?(\d+s)?$/.test(txt)){
|
|
|
3014 |
var h=/(\d+)h/.exec(txt), mn=/(\d+)m/.exec(txt), sc=/(\d+)s/.exec(txt);
|
|
|
3015 |
return (h?+h[1]*3600:0)+(mn?+mn[1]*60:0)+(sc?+sc[1]:0);
|
|
|
3016 |
}
|
|
|
3017 |
// number, with optional currency prefix, thousands separators and a K / L / Cr / % suffix
|
|
|
3018 |
var m=/^[₹$]?\s*(-?[\d,]+(?:\.\d+)?)\s*(K|L|Cr|%)?$/i.exec(txt);
|
|
|
3019 |
if(m){
|
|
|
3020 |
var n=parseFloat(m[1].replace(/,/g,''));
|
|
|
3021 |
if(isNaN(n)) return null;
|
|
|
3022 |
var suf=(m[2]||'').toLowerCase();
|
|
|
3023 |
if(suf==='k') n*=1e3; else if(suf==='l') n*=1e5; else if(suf==='cr') n*=1e7;
|
|
|
3024 |
return n;
|
|
|
3025 |
}
|
|
|
3026 |
// "21 Sep 2026", with or without a time after it
|
|
|
3027 |
if(/^\d{1,2} [A-Za-z]{3} \d{4}/.test(txt)){ var d=Date.parse(txt); if(!isNaN(d)) return d; }
|
|
|
3028 |
return null;
|
|
|
3029 |
}
|
|
|
3030 |
|
|
|
3031 |
/** Data rows only — a colspan placeholder ("Loading…", "No rows") is not one. */
|
|
|
3032 |
function tsRows(t){
|
|
|
3033 |
var out=[], b=t.tBodies[0], cols=t.tHead.rows[0].cells.length;
|
|
|
3034 |
if(!b) return out;
|
|
|
3035 |
for(var i=0;i<b.rows.length;i++){
|
|
|
3036 |
var r=b.rows[i];
|
|
|
3037 |
if(r.cells.length>=cols && !r.hasAttribute('data-tsempty')) out.push(r);
|
|
|
3038 |
}
|
|
|
3039 |
return out;
|
|
|
3040 |
}
|
|
|
3041 |
|
|
|
3042 |
function tsSort(t, idx){
|
|
|
3043 |
var ths=t.tHead.rows[0].cells;
|
|
|
3044 |
var cur=t.getAttribute('data-tscol'), dir=t.getAttribute('data-tsdir')||'desc';
|
|
|
3045 |
// Same column flips; a new column starts descending — what people expect of values and dates,
|
|
|
3046 |
// and harmless on text.
|
|
|
3047 |
dir = (String(idx)===cur) ? (dir==='asc'?'desc':'asc') : 'desc';
|
|
|
3048 |
t.setAttribute('data-tscol', String(idx));
|
|
|
3049 |
t.setAttribute('data-tsdir', dir);
|
|
|
3050 |
|
|
|
3051 |
var rows=tsRows(t), numeric=false, sawValue=false, allNumeric=true;
|
|
|
3052 |
rows.forEach(function(r){
|
|
|
3053 |
var txt=tsText(r.cells[idx]);
|
|
|
3054 |
if(txt==='' || txt==='—') return;
|
|
|
3055 |
sawValue=true;
|
|
|
3056 |
if(tsValue(txt)===null) allNumeric=false;
|
|
|
3057 |
});
|
|
|
3058 |
numeric = sawValue && allNumeric;
|
|
|
3059 |
|
|
|
3060 |
rows.sort(function(a,b){
|
|
|
3061 |
var ta=tsText(a.cells[idx]), tb=tsText(b.cells[idx]);
|
|
|
3062 |
var va = numeric ? tsValue(ta) : (ta===''||ta==='—' ? null : ta.toLowerCase());
|
|
|
3063 |
var vb = numeric ? tsValue(tb) : (tb===''||tb==='—' ? null : tb.toLowerCase());
|
|
|
3064 |
if(va===null && vb===null) return 0;
|
|
|
3065 |
if(va===null) return 1; // blanks sit last in BOTH directions, never at the top
|
|
|
3066 |
if(vb===null) return -1;
|
|
|
3067 |
var c = numeric ? (va-vb) : (va<vb ? -1 : (va>vb ? 1 : 0));
|
|
|
3068 |
return dir==='asc' ? c : -c;
|
|
|
3069 |
});
|
|
|
3070 |
|
|
|
3071 |
tsBusy=true;
|
|
|
3072 |
var body=t.tBodies[0];
|
|
|
3073 |
rows.forEach(function(r){ body.appendChild(r); });
|
|
|
3074 |
for(var i=0;i<ths.length;i++){
|
|
|
3075 |
var on=(i===idx), label=ths[i].getAttribute('data-tslabel');
|
|
|
3076 |
ths[i].classList.toggle('on',on);
|
|
|
3077 |
ths[i].innerHTML = esc(label||'') + (on ? '<span class="arrow '+(dir==='asc'?'up':'down')+'"></span>' : '');
|
|
|
3078 |
}
|
|
|
3079 |
tsBusy=false;
|
|
|
3080 |
}
|
|
|
3081 |
|
|
|
3082 |
function tsFilter(t){
|
|
|
3083 |
var bar=t.__tsBar, q=(bar && bar.__input ? bar.__input.value : '').trim().toLowerCase();
|
|
|
3084 |
var rows=tsRows(t), shown=0;
|
|
|
3085 |
tsBusy=true;
|
|
|
3086 |
rows.forEach(function(r){
|
|
|
3087 |
var hit = !q || r.textContent.toLowerCase().indexOf(q)>=0;
|
|
|
3088 |
r.style.display = hit ? '' : 'none';
|
|
|
3089 |
if(hit) shown++;
|
|
|
3090 |
});
|
|
|
3091 |
// One "no matches" row, reused, so a filter that hides everything does not look like a crash.
|
|
|
3092 |
var body=t.tBodies[0], empty=body.querySelector('tr[data-tsempty]');
|
|
|
3093 |
if(q && !shown){
|
|
|
3094 |
if(!empty){
|
|
|
3095 |
empty=document.createElement('tr');
|
|
|
3096 |
empty.setAttribute('data-tsempty','1');
|
|
|
3097 |
empty.innerHTML='<td colspan="'+t.tHead.rows[0].cells.length+'"><div class="tsempty">No rows match “'+esc(q)+'”.</div></td>';
|
|
|
3098 |
body.appendChild(empty);
|
|
|
3099 |
} else {
|
|
|
3100 |
empty.style.display='';
|
|
|
3101 |
empty.firstChild.firstChild.textContent='No rows match “'+q+'”.';
|
|
|
3102 |
}
|
|
|
3103 |
} else if(empty){ empty.style.display='none'; }
|
|
|
3104 |
if(bar && bar.__count){
|
|
|
3105 |
var txt = q ? (shown+' of '+rows.length+' shown') : (rows.length+(rows.length===1?' row':' rows'));
|
|
|
3106 |
if(bar.__count.textContent!==txt) bar.__count.textContent=txt;
|
|
|
3107 |
bar.style.display = (rows.length>=TS_SEARCH_MIN_ROWS || q) ? '' : 'none';
|
|
|
3108 |
}
|
|
|
3109 |
tsBusy=false;
|
|
|
3110 |
}
|
|
|
3111 |
|
|
|
3112 |
function tsEnhance(t){
|
|
|
3113 |
t.setAttribute('data-ts','1');
|
|
|
3114 |
var ths=t.tHead.rows[0].cells;
|
|
|
3115 |
for(var i=0;i<ths.length;i++){
|
|
|
3116 |
(function(th,idx){
|
|
|
3117 |
th.setAttribute('data-tslabel', th.textContent.trim());
|
|
|
3118 |
th.classList.add('tsort');
|
|
|
3119 |
th.addEventListener('click', function(){ tsSort(t, idx); });
|
|
|
3120 |
})(ths[i], i);
|
|
|
3121 |
}
|
|
|
3122 |
// The toolbar goes OUTSIDE .pipewrap — inside it, it would scroll sideways with the table.
|
|
|
3123 |
var host = (t.parentNode && t.parentNode.classList && t.parentNode.classList.contains('pipewrap'))
|
|
|
3124 |
? t.parentNode : t;
|
|
|
3125 |
if(!host.parentNode) return;
|
|
|
3126 |
// A drawer table's filter belongs in the drawer header, beside the title that says what the
|
|
|
3127 |
// list is — not floating above the first row, where it pushes the table down and reads as
|
|
|
3128 |
// part of the data. Only the first table claims the slot; a report drawer showing two keeps the
|
|
|
3129 |
// second one's filter inline, where it stays obvious which table it filters.
|
|
|
3130 |
var slot = t.closest('#drawer') ? byId('dTools') : null;
|
|
|
3131 |
if(slot && slot.childElementCount) slot=null;
|
|
|
3132 |
var bar=document.createElement('div');
|
|
|
3133 |
bar.className='tstools';
|
|
|
3134 |
var input=document.createElement('input');
|
|
|
3135 |
input.type='text'; input.className='inp'; input.placeholder='Filter these rows…';
|
|
|
3136 |
var count=document.createElement('span'); count.className='tscount';
|
|
|
3137 |
bar.appendChild(input); bar.appendChild(count);
|
|
|
3138 |
bar.__input=input; bar.__count=count; t.__tsBar=bar;
|
|
|
3139 |
input.addEventListener('input', function(){ tsFilter(t); });
|
|
|
3140 |
tsBusy=true;
|
|
|
3141 |
if(slot) slot.appendChild(bar); else host.parentNode.insertBefore(bar, host);
|
|
|
3142 |
tsBusy=false;
|
|
|
3143 |
tsFilter(t);
|
|
|
3144 |
}
|
|
|
3145 |
|
|
|
3146 |
/** Enhance any new table, and re-count / re-filter ones whose rows were replaced under us. */
|
|
|
3147 |
function tsScan(){
|
|
|
3148 |
var tables=document.querySelectorAll('table');
|
|
|
3149 |
for(var i=0;i<tables.length;i++){
|
|
|
3150 |
var t=tables[i];
|
|
|
3151 |
if(tsEligible(t)) tsEnhance(t);
|
|
|
3152 |
else if(t.hasAttribute('data-ts') && t.__tsBar) tsFilter(t);
|
|
|
3153 |
}
|
|
|
3154 |
}
|
|
|
3155 |
|
|
|
3156 |
(function(){
|
|
|
3157 |
var obs=null, queued=false;
|
|
|
3158 |
function run(){
|
|
|
3159 |
queued=false;
|
|
|
3160 |
tsScan();
|
|
|
3161 |
// A MutationObserver delivers asynchronously, so tsBusy alone cannot stop a scan from seeing
|
|
|
3162 |
// its own writes and scanning again. Dropping the records we just produced ends it here.
|
|
|
3163 |
if(obs) obs.takeRecords();
|
|
|
3164 |
}
|
|
|
3165 |
tsScan();
|
|
|
3166 |
if(typeof MutationObserver!=='function') return;
|
|
|
3167 |
obs=new MutationObserver(function(){
|
|
|
3168 |
if(queued) return; // at most one scan per frame
|
|
|
3169 |
queued=true;
|
|
|
3170 |
requestAnimationFrame(run);
|
|
|
3171 |
});
|
|
|
3172 |
obs.observe(document.body, {childList:true, subtree:true});
|
|
|
3173 |
})();
|
|
|
3174 |
|
| 37125 |
vikas |
3175 |
// ---- initial paint from seeds, pipeline loads live
|
|
|
3176 |
renderSummary(SEED.summary); renderState(SEED.state); renderHeat(SEED.heat); renderAgeing(SEED.ageing);
|
| 37760 |
vikas |
3177 |
loadCallCenterUsers();
|
| 37743 |
vikas |
3178 |
setDashPeriod();
|
| 37125 |
vikas |
3179 |
loadPipeline();
|
|
|
3180 |
|
|
|
3181 |
// ---- deep-link: a URL hash such as dash or record opens that tab
|
| 37760 |
vikas |
3182 |
// Tabs are looked up by data-screen, not position: a tab the viewer is not allowed to see (Create
|
|
|
3183 |
// Lead below L2) is simply absent, and a hash for it is ignored rather than opening the screen.
|
|
|
3184 |
(function(){ function tabFor(k){ return document.querySelector('.tab[data-screen="'+k+'"]'); }
|
|
|
3185 |
function open(k){ var b=tabFor(k); if(k && b){ go(k, b); } }
|
|
|
3186 |
open((location.hash||'').replace('#','').trim());
|
|
|
3187 |
window.addEventListener('hashchange',function(){ open((location.hash||'').replace('#','').trim()); }); })();
|
| 37125 |
vikas |
3188 |
</script>
|
|
|
3189 |
</body>
|
|
|
3190 |
</html>
|