Subversion Repositories SmartDukaan

Rev

Rev 37651 | Rev 37737 | Go to most recent revision | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 37651 Rev 37692
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 &rarr; full LMS record &amp; trail. Rows shaded by SLA urgency.</p>
381
        <p class="desc" id="pipeDesc">Click any row &rarr; full LMS record &amp; 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)">&larr; 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 &rarr;</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()">&times;</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()">&times;</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 &sect;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 &rarr;</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 &amp; 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
738
  function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 '&#8377;0'; v=Number(v);
740
  function fmtInr(v){ if(v==null||v===''||isNaN(v)||Number(v)<=0) return '&#8377;0'; v=Number(v);
608
    if(v>=1e7) return '&#8377;'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '&#8377;'+(v/1e5).toFixed(1)+'<small> L</small>';
741
    if(v>=1e7) return '&#8377;'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '&#8377;'+(v/1e5).toFixed(1)+'<small> L</small>';
609
    if(v>=1e3) return '&#8377;'+Math.round(v/1e3)+'<small>K</small>'; return '&#8377;'+Math.round(v); }
742
    if(v>=1e3) return '&#8377;'+Math.round(v/1e3)+'<small>K</small>'; return '&#8377;'+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 '&#8377;0'; v=Number(v);
753
  function inrShort(v){ if(v==null||isNaN(v)||Number(v)<=0) return '&#8377;0';
-
 
754
    var l=toLakh(v);
611
    if(v>=1e7) return '&#8377;'+(v/1e7).toFixed(1)+'Cr'; if(v>=1e5) return '&#8377;'+(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 '&#8377;'+Math.round(v/1e3)+'k'; return '&#8377;'+Math.round(v); }
757
    var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;
-
 
758
    return '&#8377;'+(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">&#10148;</span>')+'</div>';
827
        '<div class="fbar" style="width:'+Math.max(s.pct,3)+'%"></div>'+cap+(last?'':'<span class="arrow">&#10148;</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+='&regionId='+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 &rarr; full LMS record &amp; 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?'&#10003;':(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?'&#10003;':(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 &amp; 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()">&#9998; Edit</button></h2>'+
-
 
1124
      '<p class="desc">ID, dates &amp; 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">&#9679;</div>'+
1155
      trail+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">&#9679;</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 &middot; disposition required on disconnect.</p>'+
-
 
1176
      '<button class="btn coral" id="callBtn" onclick="startCall()">&#9742; 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&hellip;</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&hellip;</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 &rarr;</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> &mdash; 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&hellip;</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="">&mdash; Not set &mdash;</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 (&#8377; 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 (&#8377; lakh / month) — required','');
-
 
1376
    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%">';
-
 
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 &amp; 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;