Subversion Repositories SmartDukaan

Rev

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