Subversion Repositories SmartDukaan

Rev

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