Subversion Repositories SmartDukaan

Rev

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