Subversion Repositories SmartDukaan

Rev

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