Subversion Repositories SmartDukaan

Rev

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