Subversion Repositories SmartDukaan

Rev

Rev 37783 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
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">&#128269;</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 &amp; 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 &amp; 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 &amp; 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 &rarr; full LMS record &amp; 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)">&larr; Prev</button>
505
            <span class="note" id="pipePageLbl"></span>
506
            <button class="btn ghost sm" id="pipeNext" onclick="pipePage(1)">Next &rarr;</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 &amp; 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 &amp; 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 &amp; 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 &rarr; 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 &rarr; 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 &rarr; 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 &middot; 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 &mdash; 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&hellip;</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 &mdash; 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&hellip;</div></td></tr></tbody>
719
        </table>
720
      </div>
721
      <div class="note">Status is derived from call activity &mdash; 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 &mdash; 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&hellip;</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)">&larr; Prev</button>
754
          <span class="note" id="clPageLbl"></span>
755
          <button class="btn ghost sm" id="clNext" onclick="clPage(1)">Next &rarr;</button>
756
        </span>
757
      </div>
758
      <div class="note">Ring is dial &rarr; answer; Talk is answer &rarr; 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 &amp; 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">&mdash;</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 &rarr; 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>&#128200; <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New &rarr; onboarded by region, BM, ASM</td></tr>
798
          <tr class="clickrow" data-rep="SLA Compliance"><td>&#9878; <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>&#8987; <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>&#128081; <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>&#128202; <b>BGC Daily</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Calls, connect rate &amp; talk time per agent per day</td></tr>
802
          <tr class="clickrow" data-rep="Audit Hygiene"><td>&#129529; <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>&#128260; <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 &middot; 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&hellip;</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 &mdash;
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 &amp; 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 &amp; 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">&#10003;</td><td class="mk-n">&mdash;</td><td class="mk-n">&mdash;</td><td class="mk-n">&mdash;</td><td class="mk-s">own only</td></tr>
869
            <tr><td>Reporting Manager</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-n">&mdash;</td><td class="mk-n">&mdash;</td><td class="mk-s">reports'</td></tr>
870
            <tr><td>BM / RSM</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-n">&mdash;</td><td class="mk-s">state-wide</td></tr>
871
            <tr><td>Audit Team</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-s">all · read-only</td></tr>
872
            <tr><td>Upper Management</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</td><td class="mk-y">&#10003;</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 &amp; compliance program (SOP §17)</h2>
888
        <div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">&#127925;</div><div class="ab"><div class="atitle">Weekly recording sampling</div><div class="adet">Across BGC, ASM &amp; RBM for quality &amp; process adherence.</div></div></div>
889
        <div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">&#129529;</div><div class="ab"><div class="atitle">5% deep audit of onboarded</div><div class="adet">Verify data integrity &amp; process compliance.</div></div></div>
890
        <div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff0f0;color:#e03131">&#128683;</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">&#128451;</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 &amp; 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()">&times;</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 &sect;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 &rarr;</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 &amp; 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">&mdash;</span>
979
    <span class="cb-time mono" id="gCallTimer">00:00</span>
980
  </div>
981
  <div class="cb-state" id="gCallState">Starting&hellip;</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&hellip;</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 &middot; <span id="chkWho">&mdash;</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 &mdash; 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">&nbsp;</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">&mdash;</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">&mdash;</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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 '&#8377;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 '&#8377;'+(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">&#10003;</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+='&regionId='+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">&lt; 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">&mdash;</span>'; } }
1164
 
1165
  // ---- COMMAND CENTER
1166
  var CC_KPI={ 'new':{i:'&#128229;',bg:'#eef1fb',fg:'#3b5bdb'}, awaiting:{i:'&#9203;',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'},
1167
    sla:{i:'&#9888;',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, contacted:{i:'&#128222;',bg:'#e3fafc',fg:'#0c8599'},
1168
    qualified:{i:'&#10003;',bg:'#e6fcf5',fg:'#0ca678'}, onboarded:{i:'&#127942;',bg:'#ebfbee',fg:'#2f9e44'} };
1169
  var ATT_META={ BREACHED_NO_CONTACT:{i:'&#9888;',bg:'#fff0f0',fg:'#e03131',c:'badflag'}, HOLD:{i:'&#128100;',bg:'#fff0f0',fg:'#e03131',c:'badflag'},
1170
    SLA_UNDER_1H:{i:'&#9203;',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'}, MISSING_DISP:{i:'&#128172;',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'},
1171
    QUALIFIED_NO_BEAT:{i:'&#128337;',bg:'#eef1f6',fg:'#69748c',c:''}, ONBOARDED_PENDING:{i:'&#128260;',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:'&#128202;',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:'&#128202;',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?(' · &minus;'+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+'>&minus;'+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">&#10148;</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+='&regionId='+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))+'">&#9742;</button>'+
1265
              '<button class="rowedit" data-editlead="'+l.id+'"'+
1266
                ' title="Edit '+esc(l.name||('lead #'+l.id))+'">&#9998;</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))+'">&#128100;</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 &rarr; full LMS record &amp; 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:'&#128229;',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'&#128200;',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},
1333
    {i:'&#9878;',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'&#128222;',bg:'#e3fafc',fg:'#0c8599'},
1334
    {i:'&#8987;',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'&#128176;',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 &rarr; 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 &gt; '+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">&rarr; 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">&rarr; 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 &gt; 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">&rarr; 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':'')+'>&larr; 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 &rarr;</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">&#9679;</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?'&#10003;':(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()">&#9998; Edit</button></h2>'+
1668
      '<p class="desc">ID, dates &amp; 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 &middot; disposition required on disconnect.</p>'+
37737 vikas 1721
      '<button class="btn coral" id="callBtn" onclick="startCall()">&#9742; 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> &mdash; 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&hellip;</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&hellip;</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 &mdash; <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="">&mdash; Not set &mdash;</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 (&#8377; 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?(' &middot; currently '+esc(currentOwner)):' &middot; 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> &middot; '+(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 (&#8377; lakh / month) — required','');
2628
    else if(d==='CALLBACK')     x='<label class="lbl">Callback date &amp; 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 &amp; 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 &rarr; <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:'&#128231;',bg:'#eef1fb',fg:'#3b5bdb',l:'Regions mapped',v:assigned,f:'active BM/RSM'},
2942
      {i:'&#9208;',bg:'#fff0f0',fg:'#e03131',l:'Regions on HOLD',v:hold,f:'no active BM',vc:'#e03131'},
2943
      {i:'&#128100;',bg:'#fff4e6',fg:'#e8590c',l:'HOLD leads',v:(DATA.summary&&attn('HOLD'))||0,f:'awaiting a BM',vc:'#e8590c'},
2944
      {i:'&#9889;',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:'&#127925;',bg:'#fff0ef',fg:'#f0483e',l:'Recordings (24 mo)',v:'—',f:'pending source'},
2956
      {i:'&#128172;',bg:'#fff4e6',fg:'#e8590c',l:'Missing dispositions',v:miss,f:'live',vc:'#e8590c'},
2957
      {i:'&#128683;',bg:'#fff0f0',fg:'#e03131',l:'DND breaches',v:'—',f:'needs call log'},
2958
      {i:'&#128065;',bg:'#eef1fb',fg:'#3b5bdb',l:'Recording opens',v:'—',f:'pending source'},
2959
      {i:'&#129529;',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:'&#128172;',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:'&#9888;',bg:'#fff0f0',fg:'#e03131',t:'SLA breaches — first contact',d:'Escalated to BM/RSM per matrix',c:'badflag',v:br},
2966
      {k:'HOLD',i:'&#128100;',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>