| Line 127... |
Line 127... |
| 127 |
.slabar{display:inline-block;width:52px;height:6px;border-radius:4px;background:#eef1f6;overflow:hidden;vertical-align:middle}
|
127 |
.slabar{display:inline-block;width:52px;height:6px;border-radius:4px;background:#eef1f6;overflow:hidden;vertical-align:middle}
|
| 128 |
.slabar>i{display:block;height:100%}
|
128 |
.slabar>i{display:block;height:100%}
|
| 129 |
|
129 |
|
| 130 |
/* attention */
|
130 |
/* attention */
|
| 131 |
.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}
|
131 |
.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}
|
| - |
|
132 |
/* Across the top rather than down a narrow column, so the nine-column table gets the full width. */
|
| - |
|
133 |
.attstrip{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
|
| - |
|
134 |
.attstrip .att{margin-bottom:0}
|
| - |
|
135 |
.attstrip .atitle{font-size:12.5px;line-height:1.25}
|
| - |
|
136 |
.attstrip .adet{font-size:11.5px;margin-top:1px}
|
| - |
|
137 |
/* Wide content scrolls inside its own card, never the page body. */
|
| - |
|
138 |
.pipewrap{overflow-x:auto}
|
| - |
|
139 |
/* table-layout:fixed + a colgroup so the columns keep sane proportions on a wide screen instead
|
| - |
|
140 |
of the browser handing City and Created-by a third of the table each. Business/Retailer takes
|
| - |
|
141 |
the slack because it is the column people actually read. */
|
| - |
|
142 |
.pipewrap table{min-width:1040px;table-layout:fixed}
|
| - |
|
143 |
.pipewrap td{vertical-align:middle;overflow:hidden;text-overflow:ellipsis}
|
| - |
|
144 |
.pipewrap td.wrapok{white-space:normal}
|
| - |
|
145 |
.pipewrap td.nowrap{white-space:nowrap}
|
| 132 |
.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
146 |
.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
|
| 133 |
.att .ab{flex:1}
|
147 |
.att .ab{flex:1}
|
| 134 |
.att .atitle{font-size:13px;font-weight:650}
|
148 |
.att .atitle{font-size:13px;font-weight:650}
|
| 135 |
.att .adet{font-size:12px;color:var(--muted);margin-top:2px}
|
149 |
.att .adet{font-size:12px;color:var(--muted);margin-top:2px}
|
| 136 |
.att .acount{font-size:18px;font-weight:780}
|
150 |
.att .acount{font-size:18px;font-weight:780}
|
| Line 141... |
Line 155... |
| 141 |
.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
155 |
.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}
|
| 142 |
.tile .v{font-size:21px;font-weight:760;margin-top:5px}
|
156 |
.tile .v{font-size:21px;font-weight:760;margin-top:5px}
|
| 143 |
.tile .v small{font-size:12px;color:var(--soft);font-weight:600}
|
157 |
.tile .v small{font-size:12px;color:var(--soft);font-weight:600}
|
| 144 |
|
158 |
|
| 145 |
.hide{display:none !important}
|
159 |
.hide{display:none !important}
|
| - |
|
160 |
th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
|
| - |
|
161 |
th.sortable:hover{color:var(--brand2)}
|
| - |
|
162 |
th.sortable.on{color:var(--brand2)}
|
| - |
|
163 |
/* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */
|
| - |
|
164 |
th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;
|
| - |
|
165 |
border-left:4px solid transparent;border-right:4px solid transparent}
|
| - |
|
166 |
th.sortable .arrow.up{border-bottom:5px solid currentColor}
|
| - |
|
167 |
th.sortable .arrow.down{border-top:5px solid currentColor}
|
| - |
|
168 |
/* Field label outside the filter bar (.ctl .lbl is scoped to it) + the record-action modals. */
|
| - |
|
169 |
.lbl{display:block;font-size:12px;font-weight:600;color:var(--soft);margin:10px 0 4px}
|
| - |
|
170 |
.modal{position:fixed;inset:0;z-index:90;background:rgba(14,18,40,.44);display:flex;align-items:flex-start;
|
| - |
|
171 |
justify-content:center;padding:6vh 16px;overflow:auto}
|
| - |
|
172 |
.modal .box{background:#fff;border-radius:14px;width:min(640px,100%);box-shadow:0 26px 70px rgba(10,15,40,.3)}
|
| - |
|
173 |
.modal .mh{padding:18px 22px 0}
|
| - |
|
174 |
.modal .mh h2{margin:0;font-size:17px;color:var(--brand)}
|
| - |
|
175 |
.modal .mb{padding:4px 22px 18px}
|
| - |
|
176 |
.modal .mf{padding:14px 22px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}
|
| - |
|
177 |
.dispgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:6px}
|
| 146 |
|
178 |
|
| 147 |
/* interactivity */
|
179 |
/* interactivity */
|
| 148 |
.kpi,.fstep,.att,#dash tbody tr,.heat td{cursor:pointer}
|
180 |
.kpi,.fstep,.att,#dash tbody tr,.heat td{cursor:pointer}
|
| 149 |
.kpi,.fstep{transition:transform .12s,box-shadow .12s}
|
181 |
.kpi,.fstep{transition:transform .12s,box-shadow .12s}
|
| 150 |
.kpi:hover,.fstep:hover{box-shadow:0 4px 14px rgba(20,30,60,.10);transform:translateY(-1px)}
|
182 |
.kpi:hover,.fstep:hover{box-shadow:0 4px 14px rgba(20,30,60,.10);transform:translateY(-1px)}
|
| 151 |
.att:hover{border-color:#c7d0e8}
|
183 |
.att:hover{border-color:#c7d0e8}
|
| 152 |
|
184 |
|
| 153 |
.drawer-ov{position:fixed;inset:0;background:rgba(20,26,50,.42);z-index:70;opacity:0;pointer-events:none;transition:opacity .2s}
|
185 |
.drawer-ov{position:fixed;inset:0;background:rgba(20,26,50,.42);z-index:70;opacity:0;pointer-events:none;transition:opacity .2s}
|
| 154 |
.drawer-ov.open{opacity:1;pointer-events:auto}
|
186 |
.drawer-ov.open{opacity:1;pointer-events:auto}
|
| 155 |
.drawer{position:fixed;top:0;right:0;height:100%;width:min(580px,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}
|
187 |
.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}
|
| 156 |
.drawer.open{transform:none}
|
188 |
.drawer.open{transform:none}
|
| 157 |
.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}
|
189 |
.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}
|
| 158 |
.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}
|
190 |
.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}
|
| 159 |
.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}
|
191 |
.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}
|
| 160 |
.drawer .dsub{color:var(--muted);font-size:12.5px}
|
192 |
.drawer .dsub{color:var(--muted);font-size:12.5px}
|
| Line 226... |
Line 258... |
| 226 |
.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}
|
258 |
.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}
|
| 227 |
.resolvebox .rt{font-size:11px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
|
259 |
.resolvebox .rt{font-size:11px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
|
| 228 |
.resolvebox .rn{font-size:15px;font-weight:750;margin-top:1px}
|
260 |
.resolvebox .rn{font-size:15px;font-weight:750;margin-top:1px}
|
| 229 |
.resolvebox .rc{font-size:11.5px;color:var(--muted);margin-top:2px}
|
261 |
.resolvebox .rc{font-size:11.5px;color:var(--muted);margin-top:2px}
|
| 230 |
.trail{position:relative;padding-left:22px}
|
262 |
.trail{position:relative;padding-left:22px}
|
| - |
|
263 |
/* When the trail scrolls, bleed it out to the card's edge (.card padding is 17px 19px) and put
|
| - |
|
264 |
that 19px back as padding — the scrollbar then sits flush against the card border instead of
|
| - |
|
265 |
floating in a gutter, and the entries stay where they were. */
|
| - |
|
266 |
.trail.scrolls{margin-right:-19px;padding-right:19px}
|
| 231 |
.trail:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--line)}
|
267 |
.trail:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--line)}
|
| 232 |
.tevt{position:relative;padding:0 0 14px}
|
268 |
.tevt{position:relative;padding:0 0 14px}
|
| 233 |
.tevt:last-child{padding-bottom:0}
|
269 |
.tevt:last-child{padding-bottom:0}
|
| 234 |
.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)}
|
270 |
.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)}
|
| 235 |
.tevt.sys .tdot{border-color:var(--soft);color:var(--soft)}
|
271 |
.tevt.sys .tdot{border-color:var(--soft);color:var(--soft)}
|
| 236 |
.tevt.win .tdot{border-color:var(--good);color:var(--good)}
|
272 |
.tevt.win .tdot{border-color:var(--good);color:var(--good)}
|
| 237 |
.tevt.call .tdot{border-color:var(--coral);color:var(--coral)}
|
273 |
.tevt.call .tdot{border-color:var(--coral);color:var(--coral)}
|
| 238 |
.tevt .tt{font-size:13px;font-weight:650}
|
274 |
.tevt .tt{font-size:13px;font-weight:650}
|
| 239 |
.tevt .tm{font-size:11.5px;color:var(--soft);margin-top:1px}
|
275 |
.tevt .tm{font-size:11.5px;color:var(--soft);margin-top:1px}
|
| 240 |
.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}
|
276 |
.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}
|
| - |
|
277 |
.tevt .trec{display:flex;align-items:center;gap:8px;margin-top:6px}
|
| - |
|
278 |
.tevt .trec audio{height:30px;max-width:230px}
|
| 241 |
|
279 |
|
| 242 |
/* record stepper */
|
280 |
/* record stepper */
|
| 243 |
.rec-stepper{display:flex;align-items:center;gap:0;margin:2px 0 4px;flex-wrap:wrap}
|
281 |
.rec-stepper{display:flex;align-items:center;gap:0;margin:2px 0 4px;flex-wrap:wrap}
|
| 244 |
.rec-step{display:flex;align-items:center;gap:8px;flex:1;min-width:96px;position:relative}
|
282 |
.rec-step{display:flex;align-items:center;gap:8px;flex:1;min-width:96px;position:relative}
|
| 245 |
.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)}
|
283 |
.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)}
|
| Line 323... |
Line 361... |
| 323 |
<div class="kpis" id="ccKpis"></div>
|
361 |
<div class="kpis" id="ccKpis"></div>
|
| 324 |
|
362 |
|
| 325 |
<div class="sectionlabel">Lifecycle Funnel — selected period</div>
|
363 |
<div class="sectionlabel">Lifecycle Funnel — selected period</div>
|
| 326 |
<div class="card"><div class="funnel" id="ccFunnel"></div></div>
|
364 |
<div class="card"><div class="funnel" id="ccFunnel"></div></div>
|
| 327 |
|
365 |
|
| 328 |
<div class="grid g3">
|
366 |
<div class="card">
|
| - |
|
367 |
<h2>Needs attention now</h2>
|
| - |
|
368 |
<p class="desc">Actionable queues · click to work them.</p>
|
| - |
|
369 |
<div id="ccAttention" class="attstrip"></div>
|
| - |
|
370 |
</div>
|
| - |
|
371 |
|
| 329 |
<div class="card">
|
372 |
<div class="card">
|
| - |
|
373 |
<h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline
|
| - |
|
374 |
<span style="display:flex;gap:6px;align-items:center">
|
| - |
|
375 |
<input id="pipeQ" class="inp" style="width:230px;font-size:12.5px;padding:7px 10px"
|
| - |
|
376 |
placeholder="Name, outlet, mobile, city, created by">
|
| - |
|
377 |
<button class="btn ghost sm" onclick="pipeSearch()">Search</button>
|
| - |
|
378 |
<button class="btn ghost sm hide" id="pipeClear" onclick="pipeClearSearch()">Clear</button>
|
| - |
|
379 |
</span>
|
| 330 |
<h2>Lead pipeline</h2>
|
380 |
</h2>
|
| 331 |
<p class="desc">Click any row → full LMS record & trail. Rows shaded by SLA urgency.</p>
|
381 |
<p class="desc" id="pipeDesc">Click any row → full LMS record & trail. Rows shaded by SLA urgency.</p>
|
| - |
|
382 |
<div class="pipewrap">
|
| 332 |
<table>
|
383 |
<table>
|
| - |
|
384 |
<colgroup>
|
| 333 |
<thead><tr><th>LMS ID</th><th>Business / Retailer</th><th>Path</th><th>Stage</th><th>Owner</th><th>SLA</th><th class="num">Value</th></tr></thead>
|
385 |
<col style="width:132px"><col><col style="width:64px"><col style="width:112px">
|
| - |
|
386 |
<col style="width:130px"><col style="width:130px"><col style="width:130px">
|
| - |
|
387 |
<col style="width:104px"><col style="width:84px">
|
| - |
|
388 |
</colgroup>
|
| - |
|
389 |
<thead><tr>
|
| - |
|
390 |
<th class="sortable" data-sort="created">LMS ID</th>
|
| - |
|
391 |
<th class="sortable" data-sort="name">Business / Retailer</th>
|
| - |
|
392 |
<th>Path</th>
|
| - |
|
393 |
<th class="sortable" data-sort="stage">Stage</th>
|
| - |
|
394 |
<th>Owner</th>
|
| - |
|
395 |
<th class="sortable" data-sort="city">City</th>
|
| - |
|
396 |
<th class="sortable" data-sort="createdBy">Created by</th>
|
| - |
|
397 |
<th class="sortable" data-sort="sla">SLA</th>
|
| - |
|
398 |
<th class="sortable num" data-sort="value">Value</th>
|
| - |
|
399 |
</tr></thead>
|
| 334 |
<tbody id="ccPipeline"><tr><td colspan="7"><div class="ph">Loading…</div></td></tr></tbody>
|
400 |
<tbody id="ccPipeline"><tr><td colspan="9"><div class="ph">Loading…</div></td></tr></tbody>
|
| 335 |
</table>
|
401 |
</table>
|
| 336 |
</div>
|
402 |
</div>
|
| - |
|
403 |
<div id="pipeFoot" style="display:flex;align-items:center;justify-content:space-between;gap:10px;
|
| 337 |
<div class="card">
|
404 |
margin-top:10px;flex-wrap:wrap">
|
| 338 |
<h2>Needs attention now</h2>
|
405 |
<span class="note" id="pipeCount"></span>
|
| 339 |
<p class="desc">Actionable queues · click to work them.</p>
|
406 |
<span style="display:flex;gap:6px;align-items:center">
|
| - |
|
407 |
<button class="btn ghost sm" id="pipePrev" onclick="pipePage(-1)">← Prev</button>
|
| 340 |
<div id="ccAttention"></div>
|
408 |
<span class="note" id="pipePageLbl"></span>
|
| - |
|
409 |
<button class="btn ghost sm" id="pipeNext" onclick="pipePage(1)">Next →</button>
|
| - |
|
410 |
</span>
|
| 341 |
</div>
|
411 |
</div>
|
| 342 |
</div>
|
412 |
</div>
|
| 343 |
</div>
|
413 |
</div>
|
| 344 |
|
414 |
|
| 345 |
<!-- ======================= LEAD RECORD ========================= -->
|
415 |
<!-- ======================= LEAD RECORD ========================= -->
|
| 346 |
<div id="record" class="hide">
|
416 |
<div id="record" class="hide">
|
| Line 356... |
Line 426... |
| 356 |
<button class="btn sm" onclick="loadRecord()">Load</button>
|
426 |
<button class="btn sm" onclick="loadRecord()">Load</button>
|
| 357 |
</div>
|
427 |
</div>
|
| 358 |
</div>
|
428 |
</div>
|
| 359 |
</div>
|
429 |
</div>
|
| 360 |
<div class="recordHost" id="recordHost"><div class="card"><div class="ph">Loading the most recent lead…</div></div></div>
|
430 |
<div class="recordHost" id="recordHost"><div class="card"><div class="ph">Loading the most recent lead…</div></div></div>
|
| 361 |
<div class="note" style="margin-left:2px">Read-only preview of the real record. Stage/disposition actions run in the app — use <b>Open in app to act</b>.</div>
|
431 |
<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>
|
| 362 |
</div>
|
432 |
</div>
|
| 363 |
|
433 |
|
| 364 |
<!-- ======================= CREATE LEAD ========================= -->
|
434 |
<!-- ======================= CREATE LEAD ========================= -->
|
| 365 |
<div id="create" class="hide">
|
435 |
<div id="create" class="hide">
|
| 366 |
<div class="pagehead">
|
436 |
<div class="pagehead">
|
| Line 591... |
Line 661... |
| 591 |
<div class="drawer-ov" id="ov" onclick="closeDrawer()"></div>
|
661 |
<div class="drawer-ov" id="ov" onclick="closeDrawer()"></div>
|
| 592 |
<div class="drawer" id="drawer">
|
662 |
<div class="drawer" id="drawer">
|
| 593 |
<div class="dh"><div><div class="crumb2" id="dCrumb"></div><h3 id="dTitle"></h3><div class="dsub" id="dSub"></div></div><button class="x" onclick="closeDrawer()">×</button></div>
|
663 |
<div class="dh"><div><div class="crumb2" id="dCrumb"></div><h3 id="dTitle"></h3><div class="dsub" id="dSub"></div></div><button class="x" onclick="closeDrawer()">×</button></div>
|
| 594 |
<div class="dbody" id="dBody"></div>
|
664 |
<div class="dbody" id="dBody"></div>
|
| 595 |
</div>
|
665 |
</div>
|
| - |
|
666 |
<!-- Record actions: edit the record, and log a call outcome. Both write to the same endpoints
|
| - |
|
667 |
the in-app lead record uses. -->
|
| - |
|
668 |
<div class="modal hide" id="editModal">
|
| - |
|
669 |
<div class="box">
|
| - |
|
670 |
<div class="mh"><h2>Edit record</h2>
|
| - |
|
671 |
<p class="desc">Every change is written to the trail against your name.</p></div>
|
| - |
|
672 |
<div class="mb" id="editBody"></div>
|
| - |
|
673 |
<div class="mf"><button class="btn ghost" onclick="closeEdit()">Cancel</button>
|
| - |
|
674 |
<button class="btn" style="background:#1f2a5e;color:#fff" onclick="saveEdit()">Save changes</button></div>
|
| - |
|
675 |
</div>
|
| - |
|
676 |
</div>
|
| - |
|
677 |
|
| - |
|
678 |
<div class="modal hide" id="dispModal">
|
| - |
|
679 |
<div class="box">
|
| - |
|
680 |
<div class="mh"><h2>Call disposition</h2>
|
| - |
|
681 |
<p class="desc">Structured outcome on every interaction (SOP §12.2).</p></div>
|
| - |
|
682 |
<div class="mb">
|
| - |
|
683 |
<label class="lbl">Outcome (required)</label>
|
| - |
|
684 |
<div class="dispgrid" id="dispGrid"></div>
|
| - |
|
685 |
<div id="dispExtra"></div>
|
| - |
|
686 |
<label class="lbl">Note (attaches to the trail)</label>
|
| - |
|
687 |
<textarea id="dispNote" class="inp" rows="2" style="width:100%;resize:vertical"></textarea>
|
| - |
|
688 |
</div>
|
| - |
|
689 |
<div class="mf"><button class="btn ghost" onclick="closeDisp()">Cancel</button>
|
| - |
|
690 |
<button class="btn coral" onclick="saveDisp()">Save disposition</button></div>
|
| - |
|
691 |
</div>
|
| - |
|
692 |
</div>
|
| - |
|
693 |
|
| - |
|
694 |
<!-- Manual geo verification (SOP §6: geo-pin). Same endpoints the Leads screen uses:
|
| - |
|
695 |
/lead-geo/geocode-address, /lead-geo/upload-image, /lead-geo/manual-verify. -->
|
| - |
|
696 |
<div class="modal hide" id="geoModal">
|
| - |
|
697 |
<div class="box">
|
| - |
|
698 |
<div class="mh"><h2>Verify location manually</h2>
|
| - |
|
699 |
<p class="desc">For when the retailer will not tap the capture link — confirm the coordinates
|
| - |
|
700 |
over the phone, or read them off a Maps link they shared. Saved as approved, against your name.</p></div>
|
| - |
|
701 |
<div class="mb">
|
| - |
|
702 |
<label class="lbl">Address</label>
|
| - |
|
703 |
<div style="display:flex;gap:6px">
|
| - |
|
704 |
<input id="geoAddress" class="inp" style="flex:1" placeholder="Shop address with city, state, pincode">
|
| - |
|
705 |
<button class="btn ghost sm" id="geoFindBtn" onclick="geoGeocode()">Find on map</button>
|
| - |
|
706 |
</div>
|
| - |
|
707 |
<label class="lbl">Latitude, longitude</label>
|
| - |
|
708 |
<div style="display:flex;gap:6px">
|
| - |
|
709 |
<input id="geoLatLng" class="inp" style="flex:1" placeholder="28.6139,77.2090 — or paste a Google Maps link">
|
| - |
|
710 |
<button class="btn ghost sm" onclick="geoPreview()">Show on map</button>
|
| - |
|
711 |
</div>
|
| - |
|
712 |
<div id="geoMapWrap" class="hide" style="margin-top:10px">
|
| - |
|
713 |
<iframe id="geoMapFrame" style="width:100%;height:220px;border:1px solid var(--line);border-radius:10px" loading="lazy"></iframe>
|
| - |
|
714 |
<a id="geoOpenMap" class="note" target="_blank" rel="noopener" href="#">Open in Google Maps →</a>
|
| - |
|
715 |
</div>
|
| - |
|
716 |
<label class="lbl">Store-board photo (required)</label>
|
| - |
|
717 |
<input id="geoPhoto" type="file" accept="image/jpeg,image/png" class="inp" onchange="geoPickPhoto()">
|
| - |
|
718 |
<div class="note">The only evidence that someone actually saw the store-board.</div>
|
| - |
|
719 |
<img id="geoPhotoImg" class="hide" style="margin-top:8px;max-height:130px;border-radius:8px;border:1px solid var(--line)">
|
| - |
|
720 |
<label class="lbl">Remark</label>
|
| - |
|
721 |
<input id="geoRemark" class="inp" placeholder="Optional — how the location was confirmed">
|
| - |
|
722 |
<div id="geoErr" class="hide" style="margin-top:10px;color:#e03131;font-size:12.5px"></div>
|
| - |
|
723 |
</div>
|
| - |
|
724 |
<div class="mf"><button class="btn ghost" onclick="closeGeo()">Cancel</button>
|
| - |
|
725 |
<button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save & verify</button></div>
|
| - |
|
726 |
</div>
|
| - |
|
727 |
</div>
|
| - |
|
728 |
|
| 596 |
<div class="toastwrap" id="toasts"></div>
|
729 |
<div class="toastwrap" id="toasts"></div>
|
| 597 |
|
730 |
|
| 598 |
<script>
|
731 |
<script>
|
| 599 |
var CTX = "${rc.contextPath}";
|
732 |
var CTX = "${rc.contextPath}";
|
| 600 |
var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
|
733 |
var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };
|
| Line 605... |
Line 738... |
| 605 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
738 |
function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
| 606 |
function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
|
739 |
function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }
|
| 607 |
function fmtInr(v){ if(v==null||v===''||isNaN(v)||Number(v)<=0) return '₹0'; v=Number(v);
|
740 |
function fmtInr(v){ if(v==null||v===''||isNaN(v)||Number(v)<=0) return '₹0'; v=Number(v);
|
| 608 |
if(v>=1e7) return '₹'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '₹'+(v/1e5).toFixed(1)+'<small> L</small>';
|
741 |
if(v>=1e7) return '₹'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '₹'+(v/1e5).toFixed(1)+'<small> L</small>';
|
| 609 |
if(v>=1e3) return '₹'+Math.round(v/1e3)+'<small>K</small>'; return '₹'+Math.round(v); }
|
742 |
if(v>=1e3) return '₹'+Math.round(v/1e3)+'<small>K</small>'; return '₹'+Math.round(v); }
|
| - |
|
743 |
/**
|
| - |
|
744 |
* Business value in lakhs.
|
| - |
|
745 |
*
|
| - |
|
746 |
* Two conventions live in user.lead.potential: the form captures LAKHS (rows hold 1–99), but
|
| - |
|
747 |
* ~1,355 older rows were entered in RUPEES (1,000 – 2,00,00,000). Nothing between 100 and 999
|
| - |
|
748 |
* exists, so the gap is a safe separator: 1000 and above is read as rupees and brought onto the
|
| - |
|
749 |
* lakh scale. Normalised on read only — no stored value is rewritten.
|
| - |
|
750 |
*/
|
| - |
|
751 |
var LAKH_CUTOFF=1000;
|
| - |
|
752 |
function toLakh(v){ v=Number(v); return v>=LAKH_CUTOFF ? v/100000 : v; }
|
| 610 |
function inrShort(v){ if(v==null||isNaN(v)||Number(v)<=0) return '₹0'; v=Number(v);
|
753 |
function inrShort(v){ if(v==null||isNaN(v)||Number(v)<=0) return '₹0';
|
| - |
|
754 |
var l=toLakh(v);
|
| 611 |
if(v>=1e7) return '₹'+(v/1e7).toFixed(1)+'Cr'; if(v>=1e5) return '₹'+(v/1e5).toFixed(1)+'L';
|
755 |
// Under a lakh needs two decimals: a ₹1,000 lead is 0.01L, and one decimal would print it as
|
| - |
|
756 |
// ₹0L — the same information loss the old scaling caused, just at the other end.
|
| 612 |
if(v>=1e3) return '₹'+Math.round(v/1e3)+'k'; return '₹'+Math.round(v); }
|
757 |
var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
|
| - |
|
758 |
return '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }
|
| 613 |
|
759 |
|
| 614 |
// ---- tabs
|
760 |
// ---- tabs
|
| 615 |
var SCREENS=['cmd','record','create','assign','dash','audit'];
|
761 |
var SCREENS=['cmd','record','create','assign','dash','audit'];
|
| 616 |
var LOADED={};
|
762 |
var LOADED={};
|
| 617 |
function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
|
763 |
function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });
|
| Line 681... |
Line 827... |
| 681 |
'<div class="fbar" style="width:'+Math.max(s.pct,3)+'%"></div>'+cap+(last?'':'<span class="arrow">➤</span>')+'</div>';
|
827 |
'<div class="fbar" style="width:'+Math.max(s.pct,3)+'%"></div>'+cap+(last?'':'<span class="arrow">➤</span>')+'</div>';
|
| 682 |
prev=s; });
|
828 |
prev=s; });
|
| 683 |
byId('ccFunnel').innerHTML=h||'<div class="ph">No leads in scope.</div>';
|
829 |
byId('ccFunnel').innerHTML=h||'<div class="ph">No leads in scope.</div>';
|
| 684 |
}
|
830 |
}
|
| 685 |
|
831 |
|
| - |
|
832 |
// ---- Lead pipeline: filtered, sorted and paged on the server ------------------------------
|
| - |
|
833 |
// PIPE.q is the search term. While it is set the date filter does not apply — a search hunts for
|
| - |
|
834 |
// one known retailer, and quietly scoping it to the selected month would report "not found" for a
|
| - |
|
835 |
// lead that exists. The caption says which mode is in force so that is never a surprise.
|
| - |
|
836 |
var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };
|
| - |
|
837 |
|
| - |
|
838 |
function loadPipeline(){
|
| 686 |
function loadPipeline(){ byId('ccPipeline').innerHTML='<tr><td colspan="7"><div class="ph">Loading…</div></td></tr>';
|
839 |
byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">Loading…</div></td></tr>';
|
| 687 |
getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
840 |
var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+
|
| - |
|
841 |
'&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);
|
| - |
|
842 |
if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q);
|
| - |
|
843 |
if(STATE.regionId) p+='®ionId='+encodeURIComponent(STATE.regionId); }
|
| - |
|
844 |
else { p+=qs(); }
|
| - |
|
845 |
getJSON(p,function(d){
|
| - |
|
846 |
d=d||{}; var rows=d.rows||[];
|
| - |
|
847 |
PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;
|
| - |
|
848 |
renderPipeHead();
|
| - |
|
849 |
if(!rows.length){
|
| 688 |
if(!leads.length){ byId('ccPipeline').innerHTML='<tr><td colspan="7"><div class="ph">No leads in scope.</div></td></tr>'; return; }
|
850 |
byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">'+
|
| - |
|
851 |
(PIPE.q?('No lead matches “'+esc(PIPE.q)+'”.'):'No leads in scope.')+'</div></td></tr>';
|
| - |
|
852 |
} else {
|
| - |
|
853 |
var h='';
|
| - |
|
854 |
rows.forEach(function(l){
|
| 689 |
var h=''; leads.slice(0,12).forEach(function(l){ var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');
|
855 |
var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');
|
| 690 |
h+='<tr class="clickrow '+rc+'" data-leadid="'+l.id+'">'+
|
856 |
h+='<tr class="clickrow '+rc+'" data-leadid="'+l.id+'">'+
|
| 691 |
'<td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
857 |
'<td class="nowrap"><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+
|
| 692 |
'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
858 |
'<td class="wrapok"><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+
|
| 693 |
'<td>'+(l.path==='B'?'<span class="tag b">B · Field</span>':'<span class="tag a">A · BGC</span>')+'</td>'+
|
859 |
'<td class="nowrap">'+(l.path==='B'?'<span class="tag b">B · Field</span>':'<span class="tag a">A · BGC</span>')+'</td>'+
|
| 694 |
'<td>'+stagePill(l.stage,l.stageLabel)+'</td>'+
|
860 |
'<td class="nowrap">'+stagePill(l.stage,l.stageLabel)+'</td>'+
|
| - |
|
861 |
// title= so a name clipped by the fixed width is still readable on hover.
|
| 695 |
'<td>'+esc(l.owner||'—')+'</td>'+
|
862 |
'<td class="nowrap" title="'+esc(l.owner||'')+'">'+esc(l.owner||'—')+'</td>'+
|
| - |
|
863 |
'<td class="nowrap" title="'+esc(l.city||'')+'">'+esc(l.city||'—')+'</td>'+
|
| - |
|
864 |
'<td class="nowrap" title="'+esc(l.createdBy||'')+'">'+esc(l.createdBy||'—')+'</td>'+
|
| 696 |
'<td>'+slaCell(l.slaState)+'</td>'+
|
865 |
'<td class="nowrap">'+slaCell(l.slaState)+'</td>'+
|
| 697 |
'<td class="num">'+inrShort(l.value)+'</td></tr>'; });
|
866 |
'<td class="num nowrap">'+inrShort(l.value)+'</td></tr>';
|
| - |
|
867 |
});
|
| 698 |
byId('ccPipeline').innerHTML=h;
|
868 |
byId('ccPipeline').innerHTML=h;
|
| - |
|
869 |
}
|
| - |
|
870 |
// Say how many there actually are, not how many were drawn.
|
| - |
|
871 |
var shown=rows.length, first=(PIPE.page-1)*PIPE.pageSize+(shown?1:0), last=(PIPE.page-1)*PIPE.pageSize+shown;
|
| - |
|
872 |
byId('pipeCount').textContent=PIPE.total
|
| - |
|
873 |
? ('Showing '+first+'–'+last+' of '+fmtInt(PIPE.total)+(PIPE.q?' matching leads (all dates)':' leads in the selected period'))
|
| - |
|
874 |
: '';
|
| - |
|
875 |
byId('pipePageLbl').textContent='Page '+PIPE.page+' / '+Math.max(PIPE.pages,1);
|
| - |
|
876 |
byId('pipePrev').disabled=(PIPE.page<=1);
|
| - |
|
877 |
byId('pipeNext').disabled=(PIPE.page>=PIPE.pages);
|
| - |
|
878 |
byId('pipeDesc').innerHTML=PIPE.q
|
| - |
|
879 |
? 'Searching every lead for “<b>'+esc(PIPE.q)+'</b>” — the date filter is ignored while a search is active.'
|
| - |
|
880 |
: 'Click any row → full LMS record & trail. Rows shaded by SLA urgency.';
|
| - |
|
881 |
byId('pipeClear').classList.toggle('hide', !PIPE.q);
|
| 699 |
}); }
|
882 |
});
|
| - |
|
883 |
}
|
| - |
|
884 |
|
| - |
|
885 |
/**
|
| - |
|
886 |
* Mark the active column and its direction.
|
| - |
|
887 |
*
|
| - |
|
888 |
* The label is captured once into data-label and re-read from there. Deriving it from
|
| - |
|
889 |
* textContent instead re-read the arrow this function had just appended, so every sort click
|
| - |
|
890 |
* stacked another one onto the header.
|
| - |
|
891 |
*/
|
| - |
|
892 |
function renderPipeHead(){
|
| - |
|
893 |
document.querySelectorAll('th.sortable').forEach(function(th){
|
| - |
|
894 |
if(!th.hasAttribute('data-label')) th.setAttribute('data-label', th.textContent.trim());
|
| - |
|
895 |
var on=(th.getAttribute('data-sort')===PIPE.sort);
|
| - |
|
896 |
th.classList.toggle('on',on);
|
| - |
|
897 |
th.innerHTML=esc(th.getAttribute('data-label'))+
|
| - |
|
898 |
(on?'<span class="arrow '+(PIPE.dir==='asc'?'up':'down')+'"></span>':'');
|
| - |
|
899 |
});
|
| - |
|
900 |
}
|
| - |
|
901 |
|
| - |
|
902 |
function pipeSort(col){
|
| - |
|
903 |
// Same column toggles direction; a new column starts descending, which is what people expect
|
| - |
|
904 |
// from value and dates and is harmless for the text columns.
|
| - |
|
905 |
if(PIPE.sort===col){ PIPE.dir=(PIPE.dir==='asc'?'desc':'asc'); }
|
| - |
|
906 |
else { PIPE.sort=col; PIPE.dir='desc'; }
|
| - |
|
907 |
PIPE.page=1; loadPipeline();
|
| - |
|
908 |
}
|
| - |
|
909 |
function pipeSearch(){
|
| - |
|
910 |
var v=(byId('pipeQ').value||'').trim();
|
| - |
|
911 |
if(v && v.length<2){ toast('Type at least 2 characters'); return; }
|
| - |
|
912 |
PIPE.q=v; PIPE.page=1; loadPipeline();
|
| - |
|
913 |
}
|
| - |
|
914 |
function pipeClearSearch(){ byId('pipeQ').value=''; PIPE.q=''; PIPE.page=1; loadPipeline(); }
|
| - |
|
915 |
function pipePage(step){
|
| - |
|
916 |
var n=PIPE.page+step;
|
| - |
|
917 |
if(n<1||n>PIPE.pages) return;
|
| - |
|
918 |
PIPE.page=n; loadPipeline();
|
| - |
|
919 |
}
|
| 700 |
|
920 |
|
| 701 |
function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];
|
921 |
function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];
|
| 702 |
case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];
|
922 |
case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];
|
| 703 |
case'qualified':return['STAGE','QUALIFIED'];case'onboarded':return['STAGE','ONBOARDED'];default:return['ALL','']; } }
|
923 |
case'qualified':return['STAGE','QUALIFIED'];case'onboarded':return['STAGE','ONBOARDED'];default:return['ALL','']; } }
|
| 704 |
|
924 |
|
| Line 795... |
Line 1015... |
| 795 |
if(seq!==searchSeq) return; // a newer search already won
|
1015 |
if(seq!==searchSeq) return; // a newer search already won
|
| 796 |
var leads=(d&&d.leads)?d.leads:[];
|
1016 |
var leads=(d&&d.leads)?d.leads:[];
|
| 797 |
openDrawer('Search','\u201c'+q+'\u201d',leads.length+' match(es)',drawerLeadTable(leads)); },
|
1017 |
openDrawer('Search','\u201c'+q+'\u201d',leads.length+' match(es)',drawerLeadTable(leads)); },
|
| 798 |
function(){ if(seq!==searchSeq) return;
|
1018 |
function(){ if(seq!==searchSeq) return;
|
| 799 |
openDrawer('Search','\u201c'+q+'\u201d','Search failed','<div class="ph">Could not run the search.</div>'); }); }
|
1019 |
openDrawer('Search','\u201c'+q+'\u201d','Search failed','<div class="ph">Could not run the search.</div>'); }); }
|
| - |
|
1020 |
byId('pipeQ').addEventListener('keydown',function(e){
|
| - |
|
1021 |
if(e.key==='Enter'){ e.preventDefault(); pipeSearch(); }
|
| - |
|
1022 |
else if(e.key==='Escape'){ pipeClearSearch(); } });
|
| - |
|
1023 |
|
| 800 |
byId('lmsSearch').addEventListener('keydown',function(e){
|
1024 |
byId('lmsSearch').addEventListener('keydown',function(e){
|
| 801 |
if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); runSearch(); }
|
1025 |
if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); runSearch(); }
|
| 802 |
else if(e.key==='Escape'){ this.blur(); closeDrawer(); } });
|
1026 |
else if(e.key==='Escape'){ this.blur(); closeDrawer(); } });
|
| 803 |
byId('lmsSearch').addEventListener('input',function(){ clearTimeout(searchTimer);
|
1027 |
byId('lmsSearch').addEventListener('input',function(){ clearTimeout(searchTimer);
|
| 804 |
var v=(this.value||'').trim(); if(v.length<2) return;
|
1028 |
var v=(this.value||'').trim(); if(v.length<2) return;
|
| Line 812... |
Line 1036... |
| 812 |
'Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.',
|
1036 |
'Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.',
|
| 813 |
'RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };
|
1037 |
'RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };
|
| 814 |
|
1038 |
|
| 815 |
// ---- delegated clicks
|
1039 |
// ---- delegated clicks
|
| 816 |
document.addEventListener('click',function(e){
|
1040 |
document.addEventListener('click',function(e){
|
| - |
|
1041 |
var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }
|
| 817 |
var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
|
1042 |
var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));
|
| 818 |
drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
|
1043 |
drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }
|
| 819 |
var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
|
1044 |
var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');
|
| 820 |
drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }
|
1045 |
drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }
|
| 821 |
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; }
|
1046 |
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; }
|
| Line 851... |
Line 1076... |
| 851 |
function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }
|
1076 |
function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }
|
| 852 |
function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
1077 |
function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];
|
| 853 |
if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }
|
1078 |
if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }
|
| 854 |
else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }
|
1079 |
else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }
|
| 855 |
|
1080 |
|
| - |
|
1081 |
/** Trail entries shown before the list starts scrolling. */
|
| - |
|
1082 |
var TRAIL_VISIBLE=5;
|
| - |
|
1083 |
|
| - |
|
1084 |
/**
|
| - |
|
1085 |
* Cap the trail at TRAIL_VISIBLE entries and scroll the rest.
|
| - |
|
1086 |
*
|
| - |
|
1087 |
* Measured from the 6th entry's own offset rather than assumed from a row height — entries vary a
|
| - |
|
1088 |
* lot (a one-line note, a wrapped remark, one carrying an audio player), so any fixed px guess
|
| - |
|
1089 |
* would cut an entry in half on most leads.
|
| - |
|
1090 |
*/
|
| - |
|
1091 |
function capTrail(){
|
| - |
|
1092 |
var box=byId('trailScroll'); if(!box) return;
|
| - |
|
1093 |
var items=box.children;
|
| - |
|
1094 |
if(items.length<=TRAIL_VISIBLE){ box.style.maxHeight=''; box.style.overflowY=''; box.classList.remove('scrolls'); return; }
|
| - |
|
1095 |
var cut=items[TRAIL_VISIBLE];
|
| - |
|
1096 |
box.style.maxHeight=Math.round(cut.offsetTop-box.firstElementChild.offsetTop)+'px';
|
| - |
|
1097 |
box.style.overflowY='auto';
|
| - |
|
1098 |
box.classList.add('scrolls');
|
| - |
|
1099 |
}
|
| - |
|
1100 |
|
| 856 |
function renderRecord(d){
|
1101 |
function renderRecord(d){
|
| - |
|
1102 |
REC=d; CALL.id=null;
|
| 857 |
// header
|
1103 |
// header
|
| 858 |
var head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+
|
1104 |
var head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+
|
| 859 |
'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+
|
1105 |
'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+
|
| 860 |
'<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>'+
|
1106 |
'<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>'+
|
| 861 |
'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+
|
1107 |
'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+
|
| 862 |
' <span class="stage" style="background:#eef1f6;color:#69748c">SLA: '+esc(d.slaState||'NA')+'</span>'+
|
1108 |
' <span class="stage" style="background:#eef1f6;color:#69748c">SLA: '+esc(d.slaState||'NA')+'</span>'+
|
| 863 |
(d.assignmentStatus==='HOLD'?' <span class="stage st-ni">HOLD</span>':'')+'</div></div>';
|
1109 |
(d.assignmentStatus==='HOLD'?' <span class="stage st-ni">HOLD</span>':'')+'</div></div>';
|
| 864 |
// stepper
|
1110 |
// stepper
|
| 865 |
var idx=(d.stageIndex==null?-1:d.stageIndex);
|
1111 |
var idx=(d.stageIndex==null?-1:d.stageIndex);
|
| - |
|
1112 |
// A terminal lead has no stepper to draw, and a whole card repeating what the header pill and
|
| - |
|
1113 |
// the Record card's Current stage already say is just a band of empty space. Omit it.
|
| - |
|
1114 |
var step='';
|
| - |
|
1115 |
if(!d.terminal){
|
| 866 |
var step='<div class="card" style="padding:16px 22px"><div class="rec-stepper">';
|
1116 |
step='<div class="card" style="padding:16px 22px"><div class="rec-stepper">';
|
| 867 |
if(d.terminal){ step+='<div style="font-weight:700;color:#e8590c">'+esc(d.stageLabel)+' · terminal state</div>'; }
|
- |
|
| 868 |
else { STEPS.forEach(function(s,i){ var cls=i<idx?'done':(i===idx?'cur':''); var last=(i===STEPS.length-1);
|
1117 |
STEPS.forEach(function(s,i){ var cls=i<idx?'done':(i===idx?'cur':''); var last=(i===STEPS.length-1);
|
| 869 |
step+='<div class="rec-step '+cls+'"><div class="dot">'+(i<idx?'✓':(i+1))+'</div><div class="sl">'+esc(s[1])+'</div>'+(last?'':'<div class="bar"></div>')+'</div>'; }); }
|
1118 |
step+='<div class="rec-step '+cls+'"><div class="dot">'+(i<idx?'✓':(i+1))+'</div><div class="sl">'+esc(s[1])+'</div>'+(last?'':'<div class="bar"></div>')+'</div>'; });
|
| 870 |
step+='</div></div>';
|
1119 |
step+='</div></div>';
|
| - |
|
1120 |
}
|
| 871 |
// details
|
1121 |
// details
|
| 872 |
var details='<div class="card"><h2>Record</h2><p class="desc">Mandatory fields captured at creation. ID, dates & assignment are system-set.</p>'+
|
1122 |
var details='<div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px">Record'+
|
| - |
|
1123 |
'<button class="btn ghost sm" onclick="openEdit()">✎ Edit</button></h2>'+
|
| - |
|
1124 |
'<p class="desc">ID, dates & auto-assignment are system-set and immutable. Everything else is correctable.</p>'+
|
| 873 |
'<div class="grid g2" style="gap:20px"><div class="kv">'+
|
1125 |
'<div class="grid g2" style="gap:20px"><div class="kv">'+
|
| 874 |
kvRow('LMS ID', d.lmsCode?('<span class="mono">'+esc(d.lmsCode)+'</span>'):'—')+
|
1126 |
kvRow('LMS ID', d.lmsCode?('<span class="mono">'+esc(d.lmsCode)+'</span>'):'—')+
|
| 875 |
kvRow('Created', fmtDT(d.created)+(d.createdBy?(' · '+esc(d.createdBy)):''))+
|
1127 |
kvRow('Created', fmtDT(d.created)+(d.createdBy?(' · '+esc(d.createdBy)):''))+
|
| 876 |
kvRow('Creation path', d.path?('<span class="tag '+(d.path==='B'?'b':'a')+'">'+(d.path==='B'?'B · Field':'A · BGC')+'</span>'):'—')+
|
1128 |
kvRow('Creation path', d.path?('<span class="tag '+(d.path==='B'?'b':'a')+'">'+(d.path==='B'?'B · Field':'A · BGC')+'</span>'):'—')+
|
| 877 |
kvRow('Retailer', esc(d.name))+
|
1129 |
kvRow('Retailer', esc(d.name))+
|
| Line 897... |
Line 1149... |
| 897 |
'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+
|
1149 |
'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+
|
| 898 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';
|
1150 |
kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';
|
| 899 |
// trail
|
1151 |
// trail
|
| 900 |
var trail='<div class="card"><h2>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></h2>';
|
1152 |
var trail='<div class="card"><h2>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></h2>';
|
| 901 |
if(!d.trail||!d.trail.length){ trail+='<div class="ph">No activity yet.</div>'; }
|
1153 |
if(!d.trail||!d.trail.length){ trail+='<div class="ph">No activity yet.</div>'; }
|
| - |
|
1154 |
else { trail+='<div class="trail" id="trailScroll">'; d.trail.forEach(function(t){
|
| 902 |
else { trail+='<div class="trail">'; d.trail.forEach(function(t){ trail+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+
|
1155 |
trail+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+
|
| 903 |
'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+
|
1156 |
'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+
|
| 904 |
'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+
|
1157 |
'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+
|
| 905 |
(t.body?('<div class="td">'+esc(t.body)+'</div>'):'')+'</div>'; }); trail+='</div>'; }
|
1158 |
(t.body?('<div class="td">'+esc(t.body)+'</div>'):'');
|
| - |
|
1159 |
// The recording of the call this entry came from, so the agent's account of the conversation
|
| - |
|
1160 |
// and the conversation itself sit together instead of in two separate lists.
|
| - |
|
1161 |
if(t.callId){
|
| - |
|
1162 |
trail+='<div class="trec"><audio controls preload="none" src="'+CTX+'/lms/recording/'+t.callId+'"></audio>'+
|
| - |
|
1163 |
(t.durationSeconds?('<span class="note">'+t.durationSeconds+'s</span>'):'')+
|
| - |
|
1164 |
(t.archived?'':'<span class="note">archiving…</span>')+'</div>';
|
| - |
|
1165 |
}
|
| - |
|
1166 |
trail+='</div>'; }); trail+='</div>'; }
|
| 906 |
trail+='</div>';
|
1167 |
trail+='</div>';
|
| 907 |
|
1168 |
|
| - |
|
1169 |
// ---- Click-to-call (SOP §12). VBC places the call server-side: our own device rings first,
|
| - |
|
1170 |
// then the retailer. A blocked number never gets a button at all — the refusal belongs in
|
| - |
|
1171 |
// front of the agent, not in the response to a dial they already started.
|
| - |
|
1172 |
var call='<div class="card"><h2>Click-to-call</h2>';
|
| - |
|
1173 |
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>'; }
|
| - |
|
1174 |
else if(!d.contact){ call+='<p class="desc">No contact number on this lead.</p>'; }
|
| - |
|
1175 |
else { call+='<p class="desc">Bridged call · disposition required on disconnect.</p>'+
|
| - |
|
1176 |
'<button class="btn coral" id="callBtn" onclick="startCall()">☎ Call retailer (C2C)</button>'+
|
| - |
|
1177 |
'<div id="callBar" class="hide" style="margin-top:12px;padding:12px;border:1px solid #e6e9f2;border-radius:10px;background:#f8f9fc">'+
|
| - |
|
1178 |
'<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">'+
|
| - |
|
1179 |
'<span id="callState" style="font-weight:700;color:#1f2a5e">Starting…</span>'+
|
| - |
|
1180 |
'<span id="callTimer" class="mono" style="color:#69748c">00:00</span></div>'+
|
| - |
|
1181 |
'<div style="margin-top:10px;display:flex;gap:8px">'+
|
| - |
|
1182 |
'<button class="btn ghost sm" onclick="endCall()">Hang up</button>'+
|
| - |
|
1183 |
'<button class="btn ghost sm" onclick="openDisp()">Disposition…</button></div>'+
|
| - |
|
1184 |
'<div id="callErr" class="hide" style="margin-top:8px;color:#e03131;font-size:12px"></div></div>'; }
|
| 908 |
var openBtn='<div style="text-align:right;margin-bottom:12px"><button class="btn coral sm" onclick="window.open(CTX+\'/dashboard?leadRecord='+d.id+'\',\'_blank\')">Open in app to act →</button></div>';
|
1185 |
call+='<div class="note" style="margin-top:10px">A call is only worth what its disposition says — log one on every disconnect.</div></div>';
|
| - |
|
1186 |
|
| - |
|
1187 |
// ---- Beat plan. Read-only on purpose: adding a stop belongs to the beat planner, and the
|
| - |
|
1188 |
// gate that matters (an approved geo-pin) is what this card exists to report.
|
| - |
|
1189 |
// The card used to state the geo gate and leave the agent to go and find the Leads screen.
|
| - |
|
1190 |
// The action that clears the gate now lives on it.
|
| - |
|
1191 |
var mapLink=(d.geoLat!=null&&d.geoLng!=null)
|
| - |
|
1192 |
? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.google.com/maps?q='+d.geoLat+','+d.geoLng+'">View on map</a>' : '';
|
| - |
|
1193 |
var photoLink=(d.geoPhotoId>0)
|
| - |
|
1194 |
? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="'+CTX+'/open-attachment?documentId='+d.geoPhotoId+'">Store photo</a>' : '';
|
| - |
|
1195 |
var beat='<div class="card"><h2>Beat plan</h2>'+
|
| - |
|
1196 |
(d.geoApproved
|
| - |
|
1197 |
? '<p class="desc"><span class="stage st-visited">Geo verified</span> — this lead can be added to a beat.</p>'
|
| - |
|
1198 |
: '<p class="desc" style="color:#a9720a">Geo-pin not yet approved'+
|
| - |
|
1199 |
(d.geoStatus?(' ('+esc(d.geoStatus.toLowerCase())+')'):'')+
|
| - |
|
1200 |
'. Verify the location before adding this lead to a beat.</p>')+
|
| - |
|
1201 |
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">'+
|
| - |
|
1202 |
'<button class="btn ghost sm" onclick="openGeo()">'+(d.geoApproved?'Re-verify location':'Verify manually')+'</button>'+
|
| - |
|
1203 |
mapLink+photoLink+'</div>'+
|
| - |
|
1204 |
'<div class="note">Check-in at the retailer moves the stage to Visited.</div></div>';
|
| - |
|
1205 |
|
| - |
|
1206 |
// ---- Advance stage. Forward-only and comment-mandatory, same rules the endpoint enforces.
|
| - |
|
1207 |
var adv='';
|
| - |
|
1208 |
if(!d.terminal){
|
| - |
|
1209 |
var opts='<option value="" selected disabled>Move to…</option>';
|
| - |
|
1210 |
STEPS.forEach(function(st,i){ if(i>idx) opts+='<option value="'+st[0]+'">'+esc(st[1])+'</option>'; });
|
| - |
|
1211 |
['NOT_INTERESTED','DROPPED'].forEach(function(t){ opts+='<option value="'+t+'">'+t.replace('_',' ')+'</option>'; });
|
| - |
|
1212 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">Forward-only. The comment becomes the trail entry.</p>'+
|
| - |
|
1213 |
'<select id="advStage" class="inp" style="width:100%;margin-bottom:8px">'+opts+'</select>'+
|
| - |
|
1214 |
'<input id="advComment" class="inp" style="width:100%;margin-bottom:10px" placeholder="Comment (required)">'+
|
| - |
|
1215 |
'<button class="btn" style="background:#1f2a5e;color:#fff" onclick="advance()">Advance</button></div>';
|
| - |
|
1216 |
} else {
|
| - |
|
1217 |
adv='<div class="card"><h2>Advance stage</h2><p class="desc">'+esc(d.stageLabel)+' is terminal — this lead cannot be moved on.</p></div>';
|
| - |
|
1218 |
}
|
| - |
|
1219 |
|
| - |
|
1220 |
byId('recordHost').innerHTML=head+step+
|
| 909 |
byId('recordHost').innerHTML=head+step+openBtn+'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+'</div></div>';
|
1221 |
'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';
|
| - |
|
1222 |
capTrail();
|
| - |
|
1223 |
}
|
| - |
|
1224 |
|
| - |
|
1225 |
// ====================== RECORD ACTIONS (edit · call · disposition · stage) =================
|
| - |
|
1226 |
// Every control here drives the SAME endpoints the in-app record uses, so the two screens cannot
|
| - |
|
1227 |
// drift into disagreeing about what a disposition or a stage move means.
|
| - |
|
1228 |
|
| - |
|
1229 |
var REC = null; // the record currently rendered, for the action panels
|
| - |
|
1230 |
var CALL = { id:null, poll:null, tick:null, startedAt:0 };
|
| - |
|
1231 |
|
| - |
|
1232 |
/** POST form-encoded (advanceStage / dialer take @RequestParam, not a JSON body). */
|
| - |
|
1233 |
function postForm(path, params, cb, onErr){
|
| - |
|
1234 |
var body = Object.keys(params).map(function(k){
|
| - |
|
1235 |
return encodeURIComponent(k)+'='+encodeURIComponent(params[k]==null?'':params[k]); }).join('&');
|
| - |
|
1236 |
fetch(CTX+path,{method:'POST',credentials:'same-origin',
|
| - |
|
1237 |
headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:body})
|
| - |
|
1238 |
.then(function(r){ return r.text(); })
|
| - |
|
1239 |
.then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}
|
| - |
|
1240 |
var res=(d&&d.response!==undefined)?d.response:d; cb(res); })
|
| - |
|
1241 |
.catch(function(e){ console.error('LMS post failed',path,e);
|
| - |
|
1242 |
if(typeof onErr==='function') onErr(e); else toast('Request failed'); });
|
| - |
|
1243 |
}
|
| - |
|
1244 |
|
| - |
|
1245 |
/** POST JSON (disposition / updateLead take @RequestBody). */
|
| - |
|
1246 |
function postJSON(path, payload, cb, onErr){
|
| - |
|
1247 |
fetch(CTX+path,{method:'POST',credentials:'same-origin',
|
| - |
|
1248 |
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify(payload)})
|
| - |
|
1249 |
.then(function(r){ return r.text(); })
|
| - |
|
1250 |
.then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}
|
| - |
|
1251 |
var res=(d&&d.response!==undefined)?d.response:d; cb(res); })
|
| - |
|
1252 |
.catch(function(e){ console.error('LMS post failed',path,e);
|
| - |
|
1253 |
if(typeof onErr==='function') onErr(e); else toast('Request failed'); });
|
| - |
|
1254 |
}
|
| - |
|
1255 |
|
| - |
|
1256 |
/** A refusal comes back as a bare string under `response`; success is an object. */
|
| - |
|
1257 |
function refusal(res){ return (typeof res==='string' && res) ? res : null; }
|
| - |
|
1258 |
|
| - |
|
1259 |
function reloadRecord(){ if(REC) injectRecord(REC.id); }
|
| - |
|
1260 |
|
| - |
|
1261 |
// ---- stage --------------------------------------------------------------------------------
|
| - |
|
1262 |
function advance(){
|
| - |
|
1263 |
var to=byId('advStage').value, c=(byId('advComment').value||'').trim();
|
| - |
|
1264 |
if(!to){ toast('Choose a stage'); return; }
|
| - |
|
1265 |
if(!c){ toast('A comment is required'); return; }
|
| - |
|
1266 |
postForm('/lms/advanceStage',{leadId:REC.id,toStage:to,comment:c},function(res){
|
| - |
|
1267 |
var why=refusal(res); if(why){ toast(why); return; }
|
| - |
|
1268 |
toast('Stage advanced to '+to.replace('_',' '),1); reloadRecord(); refresh();
|
| - |
|
1269 |
});
|
| - |
|
1270 |
}
|
| - |
|
1271 |
|
| - |
|
1272 |
// ---- edit ---------------------------------------------------------------------------------
|
| - |
|
1273 |
function openEdit(){
|
| - |
|
1274 |
if(!REC) return;
|
| - |
|
1275 |
var regionOpts='<option value="">— Not set —</option>';
|
| - |
|
1276 |
// Same region list the Create tab renders, so the two forms cannot offer different regions.
|
| - |
|
1277 |
byId('cRegion').querySelectorAll('option').forEach(function(o){
|
| - |
|
1278 |
if(!o.value) return;
|
| - |
|
1279 |
regionOpts+='<option value="'+esc(o.value)+'"'+(String(REC.regionId)===String(o.value)?' selected':'')+'>'+esc(o.textContent)+'</option>';
|
| - |
|
1280 |
});
|
| - |
|
1281 |
byId('editBody').innerHTML=
|
| - |
|
1282 |
'<div class="kv" style="margin-bottom:14px">'+
|
| - |
|
1283 |
kvRow('LMS ID','<span class="mono">'+esc(REC.lmsCode||('#'+REC.id))+'</span> <span class="note" style="display:inline">locked</span>')+
|
| - |
|
1284 |
kvRow('Created', fmtDT(REC.created)+' <span class="note" style="display:inline">locked</span>')+
|
| - |
|
1285 |
'</div>'+
|
| - |
|
1286 |
'<div class="grid g2" style="gap:12px">'+
|
| - |
|
1287 |
fld('eName','Retailer name (required)',REC.name)+
|
| - |
|
1288 |
fld('eBusiness','Business name',REC.outlet)+
|
| - |
|
1289 |
fld('eContact','Contact number (required)',REC.contact)+
|
| - |
|
1290 |
fld('eValue','Business value (₹ lakh / month)',(REC.value>0?Math.round(toLakh(REC.value)*10)/10:''))+
|
| - |
|
1291 |
'</div>'+
|
| - |
|
1292 |
fld('eAddress','Address',REC.address)+
|
| - |
|
1293 |
'<div class="grid g2" style="gap:12px">'+fld('eCity','City',REC.city)+fld('eState','State',REC.state)+'</div>'+
|
| - |
|
1294 |
'<label class="lbl">Region</label><select id="eRegion" class="inp" style="width:100%">'+regionOpts+'</select>'+
|
| - |
|
1295 |
'<div class="note">Changing the region re-runs auto-assignment. A lead already mapped to a named owner keeps that owner.</div>';
|
| - |
|
1296 |
byId('editModal').classList.remove('hide');
|
| - |
|
1297 |
}
|
| - |
|
1298 |
function fld(id,label,val){
|
| - |
|
1299 |
return '<div><label class="lbl">'+label+'</label><input id="'+id+'" class="inp" style="width:100%" value="'+esc(val==null?'':val)+'"></div>';
|
| - |
|
1300 |
}
|
| - |
|
1301 |
function closeEdit(){ byId('editModal').classList.add('hide'); }
|
| - |
|
1302 |
function saveEdit(){
|
| - |
|
1303 |
var name=(byId('eName').value||'').trim();
|
| - |
|
1304 |
if(!name){ toast('Retailer name is required'); return; }
|
| - |
|
1305 |
var mob=(byId('eContact').value||'').replace(/\D/g,'');
|
| - |
|
1306 |
if(mob.length<10){ toast('A valid 10-digit contact number is required'); return; }
|
| - |
|
1307 |
var v=parseFloat(byId('eValue').value), r=byId('eRegion').value;
|
| - |
|
1308 |
postJSON('/lms/updateLead',{leadId:REC.id,retailerName:name,businessName:byId('eBusiness').value,
|
| - |
|
1309 |
mobile:mob,address:byId('eAddress').value,city:byId('eCity').value,state:byId('eState').value,
|
| - |
|
1310 |
regionId:r?parseInt(r,10):null,potential:isNaN(v)?null:v},function(res){
|
| - |
|
1311 |
var why=refusal(res); if(why){ toast(why); return; }
|
| - |
|
1312 |
closeEdit(); toast('Record updated · '+(res&&res.changes||0)+' change(s) logged',1); reloadRecord();
|
| - |
|
1313 |
});
|
| - |
|
1314 |
}
|
| - |
|
1315 |
|
| - |
|
1316 |
// ---- call ---------------------------------------------------------------------------------
|
| - |
|
1317 |
function callErr(msg){ var e=byId('callErr'); if(!e) return; e.textContent=msg; e.classList.remove('hide'); }
|
| - |
|
1318 |
function callState(t){ var e=byId('callState'); if(e) e.textContent=t; }
|
| - |
|
1319 |
function startCall(){
|
| - |
|
1320 |
if(!REC || CALL.id) return;
|
| - |
|
1321 |
byId('callBtn').disabled=true;
|
| - |
|
1322 |
// Pre-check server-side: DND and the 09:00-21:00 calling window. A check that only lives in
|
| - |
|
1323 |
// the browser is not a check, which is why this endpoint exists at all.
|
| - |
|
1324 |
postForm('/lms/dialer/precheck',{leadId:REC.id},function(res){
|
| - |
|
1325 |
var why=refusal(res);
|
| - |
|
1326 |
if(why || !res || res.ok!==true){ byId('callBtn').disabled=false; toast(why||(res&&res.reason)||'Cannot place the call'); return; }
|
| - |
|
1327 |
byId('callBar').classList.remove('hide'); callState('Ringing your device…');
|
| - |
|
1328 |
postForm('/lms/dialer/dial',{leadId:REC.id},function(r2){
|
| - |
|
1329 |
var w2=refusal(r2);
|
| - |
|
1330 |
if(w2 || !r2 || r2.ok!==true){ byId('callBtn').disabled=false; byId('callBar').classList.add('hide');
|
| - |
|
1331 |
toast(w2||'Could not place the call'); return; }
|
| - |
|
1332 |
CALL.id=r2.leadCallId; CALL.startedAt=Date.now();
|
| - |
|
1333 |
CALL.tick=setInterval(function(){ var s=Math.floor((Date.now()-CALL.startedAt)/1000);
|
| - |
|
1334 |
byId('callTimer').textContent=('0'+Math.floor(s/60)).slice(-2)+':'+('0'+(s%60)).slice(-2); },1000);
|
| - |
|
1335 |
// click2dial has no event webhook, so the browser has to ask.
|
| - |
|
1336 |
CALL.poll=setInterval(pollCall,3000);
|
| - |
|
1337 |
});
|
| - |
|
1338 |
});
|
| - |
|
1339 |
}
|
| - |
|
1340 |
function pollCall(){
|
| - |
|
1341 |
if(!CALL.id) return;
|
| - |
|
1342 |
getJSON('/lms/dialer/state?leadCallId='+CALL.id,function(st){
|
| - |
|
1343 |
if(!st) return;
|
| - |
|
1344 |
if(st.status) callState(String(st.status).replace(/_/g,' ').toLowerCase());
|
| - |
|
1345 |
if(st.terminal){ stopCall(); callState('Call ended'); openDisp(); }
|
| - |
|
1346 |
},function(){ /* a dropped poll is not a dropped call — keep waiting */ });
|
| - |
|
1347 |
}
|
| - |
|
1348 |
function stopCall(){ if(CALL.poll) clearInterval(CALL.poll); if(CALL.tick) clearInterval(CALL.tick);
|
| - |
|
1349 |
CALL.poll=CALL.tick=null; var b=byId('callBtn'); if(b) b.disabled=false; }
|
| - |
|
1350 |
function endCall(){
|
| - |
|
1351 |
if(!CALL.id){ stopCall(); return; }
|
| - |
|
1352 |
postForm('/lms/dialer/hangup',{leadCallId:CALL.id},function(){ stopCall(); callState('Call ended'); openDisp(); });
|
| - |
|
1353 |
}
|
| - |
|
1354 |
|
| - |
|
1355 |
// ---- disposition --------------------------------------------------------------------------
|
| - |
|
1356 |
var DISPS=[['INTERESTED','Interested'],['CALLBACK','Callback'],['NOT_REACHABLE','Not reachable'],
|
| - |
|
1357 |
['WRONG_NUMBER','Wrong number'],['FOLLOW_UP','Follow-up'],['NOT_INTERESTED','Not interested'],
|
| - |
|
1358 |
['DO_NOT_CALL','Do not call']];
|
| - |
|
1359 |
var DISP=null;
|
| - |
|
1360 |
function openDisp(){
|
| - |
|
1361 |
if(!REC) return;
|
| - |
|
1362 |
DISP=null;
|
| - |
|
1363 |
byId('dispGrid').innerHTML=DISPS.map(function(d){
|
| - |
|
1364 |
return '<button class="btn ghost sm dispopt" data-d="'+d[0]+'" onclick="pickDisp(\''+d[0]+'\')">'+d[1]+'</button>'; }).join('');
|
| - |
|
1365 |
byId('dispExtra').innerHTML=''; byId('dispNote').value='';
|
| - |
|
1366 |
byId('dispModal').classList.remove('hide');
|
| - |
|
1367 |
}
|
| - |
|
1368 |
function closeDisp(){ byId('dispModal').classList.add('hide'); }
|
| - |
|
1369 |
function pickDisp(d){
|
| - |
|
1370 |
DISP=d;
|
| - |
|
1371 |
document.querySelectorAll('.dispopt').forEach(function(b){
|
| - |
|
1372 |
b.className='btn '+(b.getAttribute('data-d')===d?'coral':'ghost')+' sm dispopt'; });
|
| - |
|
1373 |
// Contextual sub-fields, mirroring SOP §12.2 and the in-app modal.
|
| - |
|
1374 |
var x='';
|
| - |
|
1375 |
if(d==='INTERESTED') x=fld('dxValue','Business value (₹ lakh / month) — required','');
|
| - |
|
1376 |
else if(d==='CALLBACK') x='<label class="lbl">Callback date & time — required</label><input id="dxCallback" type="datetime-local" class="inp" style="width:100%">';
|
| - |
|
1377 |
else if(d==='NOT_REACHABLE')x=fld('dxRetry','Retry schedule (optional)','');
|
| - |
|
1378 |
else if(d==='WRONG_NUMBER') x=fld('dxCorrected','Corrected number (blank closes the lead)','');
|
| - |
|
1379 |
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>'+
|
| - |
|
1380 |
'<label class="lbl">Follow-up date & time — required</label><input id="dxFollowUp" type="datetime-local" class="inp" style="width:100%">';
|
| - |
|
1381 |
else if(d==='NOT_INTERESTED')x='<label class="lbl">Sub-reason</label><select id="dxSub" class="inp" style="width:100%">'+
|
| - |
|
1382 |
['Price','Terms','Already partnered','Category mismatch','Other'].map(function(o){return '<option>'+o+'</option>';}).join('')+'</select>';
|
| - |
|
1383 |
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>';
|
| - |
|
1384 |
byId('dispExtra').innerHTML=x;
|
| - |
|
1385 |
}
|
| - |
|
1386 |
function saveDisp(){
|
| - |
|
1387 |
if(!DISP){ toast('Pick a disposition'); return; }
|
| - |
|
1388 |
var p={leadId:REC.id,disposition:DISP,note:byId('dispNote').value,leadCallId:CALL.id||null};
|
| - |
|
1389 |
if(DISP==='INTERESTED'){ var v=parseFloat(byId('dxValue').value);
|
| - |
|
1390 |
if(!v||v<=0){ toast('Business value is required'); return; } p.value=v; }
|
| - |
|
1391 |
else if(DISP==='CALLBACK'){ if(!byId('dxCallback').value){ toast('Callback date/time is required'); return; }
|
| - |
|
1392 |
p.callbackAt=byId('dxCallback').value; }
|
| - |
|
1393 |
else if(DISP==='NOT_REACHABLE') p.retrySchedule=byId('dxRetry').value;
|
| - |
|
1394 |
else if(DISP==='WRONG_NUMBER') p.correctedNumber=byId('dxCorrected').value;
|
| - |
|
1395 |
else if(DISP==='FOLLOW_UP'){ if(!byId('dxFollowUp').value){ toast('Follow-up date/time is required'); return; }
|
| - |
|
1396 |
p.followUpType=byId('dxFuType').value; p.followUpAt=byId('dxFollowUp').value; }
|
| - |
|
1397 |
else if(DISP==='NOT_INTERESTED') p.subReason=byId('dxSub').value;
|
| - |
|
1398 |
postJSON('/lms/disposition',p,function(res){
|
| - |
|
1399 |
var why=refusal(res); if(why){ toast(why); return; }
|
| - |
|
1400 |
closeDisp(); CALL.id=null; toast('Disposition saved: '+DISP.replace(/_/g,' '),1); reloadRecord(); refresh();
|
| - |
|
1401 |
});
|
| - |
|
1402 |
}
|
| - |
|
1403 |
|
| - |
|
1404 |
// ---- manual geo verification ---------------------------------------------------------------
|
| - |
|
1405 |
function geoErr(msg){ var e=byId('geoErr'); e.textContent=msg; e.classList.remove('hide'); }
|
| - |
|
1406 |
function geoClearErr(){ byId('geoErr').classList.add('hide'); }
|
| - |
|
1407 |
|
| - |
|
1408 |
function openGeo(){
|
| - |
|
1409 |
if(!REC) return;
|
| - |
|
1410 |
geoClearErr();
|
| - |
|
1411 |
// Seed from what the record already knows, so a nudge is an edit rather than a re-entry.
|
| - |
|
1412 |
byId('geoAddress').value=[REC.address,REC.city,REC.state].filter(function(x){return x;}).join(', ');
|
| - |
|
1413 |
byId('geoLatLng').value=(REC.geoLat!=null&&REC.geoLng!=null)?(REC.geoLat+','+REC.geoLng):'';
|
| - |
|
1414 |
byId('geoRemark').value='';
|
| - |
|
1415 |
byId('geoPhoto').value='';
|
| - |
|
1416 |
byId('geoPhotoImg').classList.add('hide');
|
| - |
|
1417 |
byId('geoMapWrap').classList.add('hide');
|
| - |
|
1418 |
byId('geoModal').classList.remove('hide');
|
| - |
|
1419 |
if(REC.geoLat!=null&&REC.geoLng!=null) geoShowMap(REC.geoLat,REC.geoLng);
|
| - |
|
1420 |
}
|
| - |
|
1421 |
function closeGeo(){ byId('geoModal').classList.add('hide'); }
|
| - |
|
1422 |
|
| - |
|
1423 |
/** Accepts "lat,lng" or a Maps URL: /@lat,lng, ?q=lat,lng, ?ll=, ?destination=. */
|
| - |
|
1424 |
function parseLatLng(raw){
|
| - |
|
1425 |
if(!raw) return null;
|
| - |
|
1426 |
var m=String(raw).match(/(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)/);
|
| - |
|
1427 |
return m?{lat:parseFloat(m[1]),lng:parseFloat(m[2])}:null;
|
| - |
|
1428 |
}
|
| - |
|
1429 |
function geoShowMap(lat,lng){
|
| - |
|
1430 |
byId('geoMapFrame').src='https://www.google.com/maps?q='+lat+','+lng+'&z=17&output=embed';
|
| - |
|
1431 |
byId('geoOpenMap').href='https://www.google.com/maps?q='+lat+','+lng;
|
| - |
|
1432 |
byId('geoMapWrap').classList.remove('hide');
|
| - |
|
1433 |
}
|
| - |
|
1434 |
function geoPreview(){
|
| - |
|
1435 |
var p=parseLatLng(byId('geoLatLng').value);
|
| - |
|
1436 |
if(!p){ geoErr('Enter latitude,longitude first — like 28.6139,77.2090.'); return; }
|
| - |
|
1437 |
geoClearErr(); geoShowMap(p.lat,p.lng);
|
| - |
|
1438 |
}
|
| - |
|
1439 |
function geoGeocode(){
|
| - |
|
1440 |
var a=(byId('geoAddress').value||'').trim();
|
| - |
|
1441 |
if(!a){ geoErr('Enter an address first.'); return; }
|
| - |
|
1442 |
geoClearErr();
|
| - |
|
1443 |
var b=byId('geoFindBtn'); b.disabled=true; b.textContent='Finding…';
|
| - |
|
1444 |
postForm('/lead-geo/geocode-address',{address:a},function(r){
|
| - |
|
1445 |
b.disabled=false; b.textContent='Find on map';
|
| - |
|
1446 |
if(!r||!r.success){ geoErr((r&&r.message)||'Could not geocode that address.'); return; }
|
| - |
|
1447 |
byId('geoLatLng').value=r.latitude+','+r.longitude;
|
| - |
|
1448 |
geoShowMap(r.latitude,r.longitude);
|
| - |
|
1449 |
},function(){ b.disabled=false; b.textContent='Find on map'; geoErr('Geocode request failed.'); });
|
| - |
|
1450 |
}
|
| - |
|
1451 |
function geoPickPhoto(){
|
| - |
|
1452 |
var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];
|
| - |
|
1453 |
if(!f){ byId('geoPhotoImg').classList.add('hide'); return; }
|
| - |
|
1454 |
var fr=new FileReader();
|
| - |
|
1455 |
fr.onload=function(ev){ byId('geoPhotoImg').src=ev.target.result; byId('geoPhotoImg').classList.remove('hide'); };
|
| - |
|
1456 |
fr.readAsDataURL(f);
|
| - |
|
1457 |
}
|
| - |
|
1458 |
function geoSave(){
|
| - |
|
1459 |
var p=parseLatLng(byId('geoLatLng').value);
|
| - |
|
1460 |
if(!p){ geoErr('Could not read latitude/longitude. Paste like 28.6139,77.2090.'); return; }
|
| - |
|
1461 |
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; }
|
| - |
|
1462 |
var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];
|
| - |
|
1463 |
if(!f){ geoErr('Attach a store-board photo — the server requires one.'); return; }
|
| - |
|
1464 |
geoClearErr();
|
| - |
|
1465 |
var b=byId('geoSaveBtn'); b.disabled=true; b.textContent='Uploading photo…';
|
| - |
|
1466 |
|
| - |
|
1467 |
// Upload first: manual-verify needs the document id, and uploading only on save means a
|
| - |
|
1468 |
// cancelled dialog never orphans a document.
|
| - |
|
1469 |
var fd=new FormData(); fd.append('file',f,f.name||'store-photo.jpg');
|
| - |
|
1470 |
fetch(CTX+'/lead-geo/upload-image',{method:'POST',credentials:'same-origin',
|
| - |
|
1471 |
headers:{'X-Requested-With':'XMLHttpRequest'},body:fd})
|
| - |
|
1472 |
.then(function(r){ return r.text(); })
|
| - |
|
1473 |
.then(function(t){
|
| - |
|
1474 |
var d=null; try{ d=JSON.parse(t); }catch(e){}
|
| - |
|
1475 |
var r=(d&&d.response!==undefined)?d.response:d;
|
| - |
|
1476 |
var docId=r&&(r.document_id||r.documentId||r.id);
|
| - |
|
1477 |
if(!docId){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); return; }
|
| - |
|
1478 |
b.textContent='Verifying…';
|
| - |
|
1479 |
postForm('/lead-geo/manual-verify',
|
| - |
|
1480 |
{leadId:REC.id,latitude:p.lat,longitude:p.lng,remark:byId('geoRemark').value,imageDocumentId:docId},
|
| - |
|
1481 |
function(res){
|
| - |
|
1482 |
b.disabled=false; b.textContent='Save & verify';
|
| - |
|
1483 |
var why=refusal(res);
|
| - |
|
1484 |
if(why){ geoErr(why); return; }
|
| - |
|
1485 |
closeGeo(); toast('Location verified — this lead can now go on a beat',1); reloadRecord();
|
| - |
|
1486 |
},
|
| - |
|
1487 |
function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Could not save the verification.'); });
|
| - |
|
1488 |
})
|
| - |
|
1489 |
.catch(function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); });
|
| 910 |
}
|
1490 |
}
|
| 911 |
|
1491 |
|
| 912 |
// ====================== CREATE LEAD (real /lms/resolve-region + /createLead) ======================
|
1492 |
// ====================== CREATE LEAD (real /lms/resolve-region + /createLead) ======================
|
| 913 |
var CREATE_PATH='A';
|
1493 |
var CREATE_PATH='A';
|
| 914 |
function setPath(p,btn){ CREATE_PATH=p;
|
1494 |
function setPath(p,btn){ CREATE_PATH=p;
|