Rev 37651 | Rev 37737 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed
## LMS Dashboard — STANDALONE full page. Faithful copy of sts-mockup.html design (app bar + tabs + hand-drawn## funnel/heat-map, NO charts), wired to REAL lead data. Rendered as a complete HTML doc (layout.vm = $screen_content).## Served at GET /lms/dashboard. Seeds: regions[], summaryJson, stateFunnelJson, slaHeatmapJson, ageingJson, fromDate, toDate.<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>LMS — Lead Dashboard</title><style>:root{--bg:#eef1f7; --card:#ffffff; --ink:#1d2433; --muted:#69748c; --soft:#97a1b5;--line:#e6e9f1; --line2:#eef1f6;--navy:#1f2a5e; --navy2:#2f3d78;--brand:#3b5bdb; --brand2:#5c7cfa; --teal:#0ca678; --cyan:#1098ad;--amber:#f59f00; --orange:#e8590c; --red:#e03131; --coral:#f0483e; --violet:#7048e8; --pink:#d6336c;--good:#2f9e44; --warn:#f08c00; --bad:#e03131;}*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;font-size:14px}.wrap{max-width:1340px;margin:0 auto;padding:0 24px 90px}a{color:var(--brand);text-decoration:none}code,.mono{font-family:ui-monospace,Menlo,Consolas,monospace}/* ===== app bar ===== */.appbar{position:sticky;top:0;z-index:40;background:var(--navy);color:#fff;margin:0;padding:0 24px}.appbar .inner{max-width:1340px;margin:0 auto;display:flex;align-items:center;gap:18px;height:58px}.logo{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:-.01em;font-size:16px}.logo .mk{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--coral),#ff7a5c);display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800}.logo small{display:block;font-size:10px;font-weight:600;color:#aab3d6;letter-spacing:.06em}.search{flex:1;max-width:560px;position:relative}.search input{width:100%;background:#2b3768;border:1px solid #3a488a;color:#fff;border-radius:9px;padding:9px 12px 9px 34px;font-size:13px;font-weight:500}.search input::placeholder{color:#9aa4cc}.search .si{position:absolute;left:11px;top:9px;color:#9aa4cc;font-size:14px}.search .hint{position:absolute;right:10px;top:8px;font-size:10px;color:#9aa4cc;border:1px solid #3a488a;border-radius:5px;padding:2px 6px}.abtn{background:#2b3768;border:1px solid #3a488a;color:#fff;border-radius:9px;padding:8px 11px;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px}.abtn.coral{background:var(--coral);border-color:var(--coral)}.role{display:flex;align-items:center;gap:9px}.role .av{width:31px;height:31px;border-radius:50%;background:#3a488a;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px}.role .who{font-size:12px;line-height:1.25}.role .who b{font-weight:700}.role .who span{color:#aab3d6}/* ===== tabs ===== */.tabbar{position:sticky;top:58px;z-index:39;background:var(--card);border-bottom:1px solid var(--line);margin:0 0 20px;padding:0 24px;box-shadow:0 1px 2px rgba(20,30,60,.03)}.tabbar .inner{max-width:1340px;margin:0 auto;display:flex;gap:2px;overflow-x:auto}.tab{padding:13px 4px;margin-right:24px;font-size:13.5px;font-weight:650;color:var(--muted);cursor:pointer;border:0;background:transparent;border-bottom:2.5px solid transparent;margin-bottom:-1px;white-space:nowrap}.tab:hover{color:var(--ink)}.tab.active{color:var(--navy);border-bottom-color:var(--coral)}/* ===== generic ===== */.crumb{font-size:12px;color:var(--soft);font-weight:600}h1.page{margin:2px 0 2px;font-size:23px;font-weight:760;letter-spacing:-.01em}.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}.pagehead .sub{color:var(--muted);font-size:13px;margin-top:2px}.hd-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.ctl{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;font-size:13px;color:var(--ink);font-weight:600}.ctl .lbl{color:var(--soft);font-weight:500;margin-right:5px}select.ctl{appearance:none;cursor:pointer;padding-right:26px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 10px center}.btn{background:var(--navy);border:0;color:#fff;font-weight:650;padding:9px 15px;border-radius:9px;cursor:pointer;font-size:13px}.btn.coral{background:var(--coral)}.btn.ghost{background:#fff;color:var(--ink);border:1px solid var(--line)}.btn.sm{padding:6px 11px;font-size:12px}.sectionlabel{font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);font-weight:700;margin:24px 2px 12px}.grid{display:grid;gap:16px}.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:1.6fr 1fr}.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:17px 19px;margin-bottom:16px;box-shadow:0 1px 2px rgba(20,30,60,.04)}.card h2{margin:0 0 3px;font-size:15px;font-weight:720;letter-spacing:-.01em}.card .desc{color:var(--muted);font-size:12.5px;margin:0 0 14px}/* kpis */.kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:8px}.kpi{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 15px;box-shadow:0 1px 2px rgba(20,30,60,.04)}.kpi .top{display:flex;align-items:center;gap:8px}.kpi .ic{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}.kpi .label{font-size:11.5px;color:var(--muted);font-weight:600;line-height:1.2}.kpi .val{font-size:26px;font-weight:760;margin-top:9px;letter-spacing:-.02em;line-height:1}.kpi .val small{font-size:13px;color:var(--soft);font-weight:600}.kpi .foot{font-size:11.5px;color:var(--soft);margin-top:7px}/* funnel */.funnel{display:flex;gap:8px;align-items:stretch;flex-wrap:wrap}.fstep{flex:1;min-width:118px;border:1px solid var(--line);border-radius:11px;padding:12px 11px;position:relative;text-align:center;background:#fbfcfe}.fstep .fn{font-size:10.5px;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:700}.fstep .fv{font-size:24px;font-weight:770;margin-top:6px}.fstep .fbar{height:5px;border-radius:3px;margin-top:9px;background:linear-gradient(90deg,var(--navy),var(--brand))}.fstep .drop{font-size:10.5px;color:var(--red);margin-top:6px;font-weight:600}.fstep .keep{font-size:10.5px;color:var(--good);margin-top:6px;font-weight:600}.fstep .arrow{position:absolute;right:-11px;top:50%;transform:translateY(-50%);z-index:2;color:#c5cce0;font-size:14px}/* table */table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:9px 9px;border-bottom:1px solid var(--line2)}th{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700;white-space:nowrap}th.num,td.num{text-align:right}tbody tr:hover{background:#f7f9fd}.clickrow{cursor:pointer}.rowwarn{background:#fff9f0}.rowbad{background:#fff5f5}.warnflag{color:var(--orange);font-weight:700}.badflag{color:var(--red);font-weight:700}/* pills / stages */.idpill{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;font-weight:700;color:var(--navy);background:#eef1fb;border:1px solid #dfe4f7;border-radius:6px;padding:2px 7px;white-space:nowrap}.stage{display:inline-flex;align-items:center;gap:6px;font-weight:700;font-size:11px;padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}.st-new{background:#eef1fb;color:#3b5bdb}.st-assigned{background:#e7f5ff;color:#1971c2}.st-contacted{background:#e3fafc;color:#0c8599}.st-qualified{background:#e6fcf5;color:#0ca678}.st-beat{background:#f3f0ff;color:#7048e8}.st-visited{background:#fff0f6;color:#c2255c}.st-onboarded{background:#ebfbee;color:#2f9e44}.st-active{background:#d3f9d8;color:#2b8a3e}.st-ni{background:#fff4e6;color:#e8590c}.st-dead{background:#f1f3f5;color:#868e96}.tag{display:inline-block;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px}.tag.a{background:#fff0ef;color:var(--coral)}.tag.b{background:#e6fcf5;color:var(--teal)}/* SLA pills */.sla{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700}.sla .clk{font-family:ui-monospace,Menlo,Consolas,monospace}.sla.ok{color:var(--good)} .sla.warn{color:var(--orange)} .sla.bad{color:var(--red)}.slabar{display:inline-block;width:52px;height:6px;border-radius:4px;background:#eef1f6;overflow:hidden;vertical-align:middle}.slabar>i{display:block;height:100%}/* attention */.att{display:flex;align-items:center;gap:12px;padding:11px 12px;border:1px solid var(--line);border-radius:11px;margin-bottom:9px}/* Across the top rather than down a narrow column, so the nine-column table gets the full width. */.attstrip{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}.attstrip .att{margin-bottom:0}.attstrip .atitle{font-size:12.5px;line-height:1.25}.attstrip .adet{font-size:11.5px;margin-top:1px}/* Wide content scrolls inside its own card, never the page body. */.pipewrap{overflow-x:auto}/* table-layout:fixed + a colgroup so the columns keep sane proportions on a wide screen insteadof the browser handing City and Created-by a third of the table each. Business/Retailer takesthe slack because it is the column people actually read. */.pipewrap table{min-width:1040px;table-layout:fixed}.pipewrap td{vertical-align:middle;overflow:hidden;text-overflow:ellipsis}.pipewrap td.wrapok{white-space:normal}.pipewrap td.nowrap{white-space:nowrap}.att .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}.att .ab{flex:1}.att .atitle{font-size:13px;font-weight:650}.att .adet{font-size:12px;color:var(--muted);margin-top:2px}.att .acount{font-size:18px;font-weight:780}/* tiles */.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}.tile{background:#f8f9fc;border:1px solid var(--line2);border-radius:11px;padding:12px}.tile .t{font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:600}.tile .v{font-size:21px;font-weight:760;margin-top:5px}.tile .v small{font-size:12px;color:var(--soft);font-weight:600}.hide{display:none !important}th.sortable{cursor:pointer;user-select:none;white-space:nowrap}th.sortable:hover{color:var(--brand2)}th.sortable.on{color:var(--brand2)}/* Triangle drawn with borders — no glyph, so it cannot fall back to a tofu box the way U+2195 did. */th.sortable .arrow{display:inline-block;width:0;height:0;margin-left:5px;vertical-align:middle;border-left:4px solid transparent;border-right:4px solid transparent}th.sortable .arrow.up{border-bottom:5px solid currentColor}th.sortable .arrow.down{border-top:5px solid currentColor}/* Field label outside the filter bar (.ctl .lbl is scoped to it) + the record-action modals. */.lbl{display:block;font-size:12px;font-weight:600;color:var(--soft);margin:10px 0 4px}.modal{position:fixed;inset:0;z-index:90;background:rgba(14,18,40,.44);display:flex;align-items:flex-start;justify-content:center;padding:6vh 16px;overflow:auto}.modal .box{background:#fff;border-radius:14px;width:min(640px,100%);box-shadow:0 26px 70px rgba(10,15,40,.3)}.modal .mh{padding:18px 22px 0}.modal .mh h2{margin:0;font-size:17px;color:var(--brand)}.modal .mb{padding:4px 22px 18px}.modal .mf{padding:14px 22px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.dispgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:6px}/* interactivity */.kpi,.fstep,.att,#dash tbody tr,.heat td{cursor:pointer}.kpi,.fstep{transition:transform .12s,box-shadow .12s}.kpi:hover,.fstep:hover{box-shadow:0 4px 14px rgba(20,30,60,.10);transform:translateY(-1px)}.att:hover{border-color:#c7d0e8}.drawer-ov{position:fixed;inset:0;background:rgba(20,26,50,.42);z-index:70;opacity:0;pointer-events:none;transition:opacity .2s}.drawer-ov.open{opacity:1;pointer-events:auto}.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}.drawer.open{transform:none}.drawer .dh{padding:17px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:12px;flex:none}.drawer .crumb2{font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--coral)}.drawer h3{margin:2px 0 1px;font-size:18px;font-weight:770;letter-spacing:-.01em}.drawer .dsub{color:var(--muted);font-size:12.5px}.drawer .x{margin-left:auto;cursor:pointer;font-size:22px;line-height:1;color:var(--soft);background:none;border:0;padding:0 2px}.drawer .dbody{padding:16px 18px 40px;overflow:auto;flex:1}.drawer .card{margin-bottom:12px}.dstat{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:12px}.dstat .box{background:#fff;border:1px solid var(--line);border-radius:11px;padding:11px 12px}.dstat .t{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--soft);font-weight:700}.dstat .v{font-size:20px;font-weight:770;margin-top:3px}.dnote{font-size:12px;color:var(--muted);line-height:1.55;margin-top:8px}.toastwrap{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:90;display:flex;flex-direction:column;gap:8px;align-items:center}.toast{background:#1f2a5e;color:#fff;padding:11px 16px;border-radius:11px;font-size:13px;font-weight:600;box-shadow:0 10px 34px rgba(10,15,40,.32);display:flex;align-items:center;gap:10px;animation:tin .18s ease-out}.toast .ok{color:#69db7c;font-weight:800}@keyframes tin{from{opacity:0;transform:translateY(10px)}}/* heatmap */.heat td{width:34px;height:30px;text-align:center;font-size:11px;font-weight:700;color:#fff;border:2px solid #fff;border-radius:6px}/* callout */.callout{background:linear-gradient(180deg,#fbfcff,#f4f7ff);border:1px solid #dfe6fb;border-radius:14px;padding:18px 20px;margin-top:6px}.callout h2{margin:0 0 3px;font-size:16px}.fix{display:flex;gap:13px;padding:13px 0;border-bottom:1px solid #e6ebf8}.fix:last-child{border-bottom:0}.fix .sev{width:6px;border-radius:4px;flex:none}.fix .fb{flex:1}.fix .ft{font-size:14px;font-weight:700}.fix .fd{font-size:13px;color:#3a4459;margin-top:4px;line-height:1.5}.fix .fd b{color:var(--ink)}.fix .act{font-size:12.5px;color:var(--good);margin-top:6px;font-weight:650}.note{font-size:11.5px;color:var(--soft);margin-top:10px;line-height:1.6}.ph{font-size:12.5px;color:var(--soft);font-style:italic;padding:16px 0;text-align:center}.rbadge{font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:10px;margin-left:6px;vertical-align:middle}.rbadge.live{background:#e6f7ee;color:var(--good)}.rbadge.partial{background:#fff3dc;color:var(--warn)}.rbadge.soon{background:#eef0f5;color:var(--soft)}/* record / kv / trail */.kv{display:grid;grid-template-columns:130px 1fr;gap:7px 12px;font-size:13px}.kv .k{color:var(--soft);font-weight:600}.kv .v{font-weight:600}.divider{height:1px;background:var(--line);margin:14px 0}.recordHost{min-height:120px}.lookup{display:flex;gap:8px;align-items:center;margin-bottom:14px}.lookup input{border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:13px;width:280px}/* form */.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px}.fld{display:flex;flex-direction:column;gap:5px}.fld.full{grid-column:1/3}.fld label{font-size:11.5px;font-weight:700;color:var(--muted)}.fld label .m{color:var(--red)}.fld label .auto{float:right;font-size:10px;font-weight:700;color:var(--brand);background:#eef1fb;border-radius:5px;padding:1px 6px;text-transform:uppercase;letter-spacing:.03em}.inp{border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:13px;font-weight:500;background:#fff;color:var(--ink);width:100%}.inp:focus{outline:none;border-color:var(--brand2);box-shadow:0 0 0 3px #eaefff}.inp[readonly],.inp.ro{background:#f5f6fa;color:var(--muted);font-weight:600}select.inp{appearance:none;cursor:pointer;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%2369748c' fill='none' stroke-width='1.5'/></svg>");background-repeat:no-repeat;background-position:right 11px center}.hinttext{font-size:11px;color:var(--soft)}/* Brand-wise value capture. auto-fill keeps it compact on a laptop and collapses to one columnon a phone, which is where Path B (field encounter) is actually used. */.brandgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(148px,1fr));gap:8px;margin-top:2px}.brandcell{display:flex;align-items:center;gap:6px;background:#f7f8fc;border:1px solid var(--line);border-radius:8px;padding:4px 8px}.brandcell .bname{font-size:11.5px;font-weight:700;color:var(--ink);white-space:nowrap;min-width:58px}.brandcell .bval{border:0;background:transparent;padding:3px 0;height:auto;font-size:12.5px;text-align:right}.brandcell .bval:focus{outline:0;box-shadow:none}.brandcell:focus-within{border-color:var(--brand);background:#fff}.resolvebox{background:#f5f7ff;border:1px solid #dfe4f7;border-radius:11px;padding:13px 15px;display:flex;align-items:center;gap:13px}.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}.resolvebox .rt{font-size:11px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}.resolvebox .rn{font-size:15px;font-weight:750;margin-top:1px}.resolvebox .rc{font-size:11.5px;color:var(--muted);margin-top:2px}.trail{position:relative;padding-left:22px}/* When the trail scrolls, bleed it out to the card's edge (.card padding is 17px 19px) and putthat 19px back as padding — the scrollbar then sits flush against the card border instead offloating in a gutter, and the entries stay where they were. */.trail.scrolls{margin-right:-19px;padding-right:19px}.trail:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--line)}.tevt{position:relative;padding:0 0 14px}.tevt:last-child{padding-bottom:0}.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)}.tevt.sys .tdot{border-color:var(--soft);color:var(--soft)}.tevt.win .tdot{border-color:var(--good);color:var(--good)}.tevt.call .tdot{border-color:var(--coral);color:var(--coral)}.tevt .tt{font-size:13px;font-weight:650}.tevt .tm{font-size:11.5px;color:var(--soft);margin-top:1px}.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}.tevt .trec{display:flex;align-items:center;gap:8px;margin-top:6px}.tevt .trec audio{height:30px;max-width:230px}/* record stepper */.rec-stepper{display:flex;align-items:center;gap:0;margin:2px 0 4px;flex-wrap:wrap}.rec-step{display:flex;align-items:center;gap:8px;flex:1;min-width:96px;position:relative}.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)}.rec-step.done .dot{background:var(--good);border-color:var(--good);color:#fff}.rec-step.cur .dot{background:var(--navy);border-color:var(--navy);color:#fff;box-shadow:0 0 0 4px #e7ebfa}.rec-step .sl{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.02em;color:var(--muted)}.rec-step.cur .sl{color:var(--navy)}.rec-step .bar{position:absolute;left:26px;right:0;top:13px;height:2px;background:var(--line);z-index:-1}.rec-step.done .bar{background:var(--good)}/* matrix */.matrix{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}.matrix th,.matrix td{padding:9px 8px;border-bottom:1px solid var(--line2);text-align:center}.matrix th:first-child,.matrix td:first-child{text-align:left;font-weight:650}.matrix thead th{background:#f8f9fc;color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.03em}.mk-y{color:var(--good);font-weight:800}.mk-n{color:#ccd2e0}.mk-s{color:var(--orange);font-weight:800;font-size:11px}@media (max-width:1040px){.kpis{grid-template-columns:repeat(3,1fr)}.g2,.g3{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}.fld.full{grid-column:1}.search{display:none}}</style></head><body><!-- ============ APP BAR ============ --><div class="appbar"><div class="inner"><div class="logo"><span class="mk">SD</span><div>SmartDukaan · LMS<small>LEAD MANAGEMENT SYSTEM</small></div></div><div class="search"><span class="si">🔍</span><input id="lmsSearch" placeholder="Search LMS ID, business, phone, retailer… e.g. LMS-UPW-26-000123"><span class="hint">/ to focus</span></div><button class="abtn coral" onclick="window.open(CTX+'/getOpenLead','_blank')">+ New Lead</button><div class="role"><div class="av">SD</div><div class="who"><b>Lead Dashboard</b><br><span>live · real data</span></div></div></div></div><!-- ============ TABS ============ --><div class="tabbar"><div class="inner"><button class="tab active" onclick="go('cmd',this)">Command Center</button><button class="tab" onclick="go('record',this)">Lead Record</button><button class="tab" onclick="go('create',this)">Create Lead</button><button class="tab" onclick="go('assign',this)">Auto-Assignment</button><button class="tab" onclick="go('dash',this)">Dashboards</button><button class="tab" onclick="go('audit',this)">Recordings & Audit</button></div></div><div class="wrap"><!-- ======================= COMMAND CENTER ========================= --><div id="cmd"><div class="pagehead"><div><div class="crumb">Sales · Lead Pipeline</div><h1 class="page">Command Center</h1><div class="sub">Every lead you own or oversee — from creation to onboarding. SLA clock runs on all NEW records.</div></div><div class="hd-right"><select class="ctl" id="fRegion"><option value="">All regions</option>#foreach($r in $regions)<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>#end</select><input type="date" class="ctl" id="fFrom" value="$!fromDate"><input type="date" class="ctl" id="fTo" value="$!toDate"></div></div><div class="kpis" id="ccKpis"></div><div class="sectionlabel">Lifecycle Funnel — selected period</div><div class="card"><div class="funnel" id="ccFunnel"></div></div><div class="card"><h2>Needs attention now</h2><p class="desc">Actionable queues · click to work them.</p><div id="ccAttention" class="attstrip"></div></div><div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">Lead pipeline<span style="display:flex;gap:6px;align-items:center"><input id="pipeQ" class="inp" style="width:230px;font-size:12.5px;padding:7px 10px"placeholder="Name, outlet, mobile, city, created by"><button class="btn ghost sm" onclick="pipeSearch()">Search</button><button class="btn ghost sm hide" id="pipeClear" onclick="pipeClearSearch()">Clear</button></span></h2><p class="desc" id="pipeDesc">Click any row → full LMS record & trail. Rows shaded by SLA urgency.</p><div class="pipewrap"><table><colgroup><col style="width:132px"><col><col style="width:64px"><col style="width:112px"><col style="width:130px"><col style="width:130px"><col style="width:130px"><col style="width:104px"><col style="width:84px"></colgroup><thead><tr><th class="sortable" data-sort="created">LMS ID</th><th class="sortable" data-sort="name">Business / Retailer</th><th>Path</th><th class="sortable" data-sort="stage">Stage</th><th>Owner</th><th class="sortable" data-sort="city">City</th><th class="sortable" data-sort="createdBy">Created by</th><th class="sortable" data-sort="sla">SLA</th><th class="sortable num" data-sort="value">Value</th></tr></thead><tbody id="ccPipeline"><tr><td colspan="9"><div class="ph">Loading…</div></td></tr></tbody></table></div><div id="pipeFoot" style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:10px;flex-wrap:wrap"><span class="note" id="pipeCount"></span><span style="display:flex;gap:6px;align-items:center"><button class="btn ghost sm" id="pipePrev" onclick="pipePage(-1)">← Prev</button><span class="note" id="pipePageLbl"></span><button class="btn ghost sm" id="pipeNext" onclick="pipePage(1)">Next →</button></span></div></div></div><!-- ======================= LEAD RECORD ========================= --><div id="record" class="hide"><div class="pagehead"><div><div class="crumb">Command Center · Lead Record</div><h1 class="page">Lead Record</h1><div class="sub">The single source of truth for a lead — stepper, immutable trail, SLA & disposition. Loads the real record.</div></div><div class="hd-right"><div class="lookup"><input id="recLookup" placeholder="Lead # (e.g. 123)" inputmode="numeric"><button class="btn sm" onclick="loadRecord()">Load</button></div></div></div><div class="recordHost" id="recordHost"><div class="card"><div class="ph">Loading the most recent lead…</div></div></div><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></div><!-- ======================= CREATE LEAD ========================= --><div id="create" class="hide"><div class="pagehead"><div><div class="crumb">Create · New Lead</div><h1 class="page">Create Lead</h1><div class="sub">Only what an agent can know on a first call. LMS ID is generated on submit and the lead is auto-assigned from region. Address, store photo and geo-pin come from the capture link, which is created automatically and copied to your clipboard.</div></div><div class="hd-right"><div class="ctl" style="padding:4px;display:flex;gap:4px"><button class="btn coral sm" id="pathA" style="border-radius:7px" onclick="setPath('A',this)">Path A · BGC outbound</button><button class="btn ghost sm" id="pathB" style="border-radius:7px" onclick="setPath('B',this)">Path B · Field encounter</button></div></div></div><div class="grid g3"><div class="card"><h2>Lead details</h2><p class="desc" id="createDesc">Path A — captured by BGC agent during an outbound call, after consent.</p><div class="form-grid"><div class="fld"><label>Retailer name <span class="m">*</span></label><input class="inp" id="cRetailer" placeholder="Contact person"></div><div class="fld"><label>Business name <span class="m">*</span></label><input class="inp" id="cBusiness" placeholder="Shop / trade name"></div><div class="fld"><label>Contact number <span class="m">*</span></label><input class="inp mono" id="cContact" placeholder="10-digit mobile" inputmode="numeric"></div><div class="fld full"><label>Monthly business value — brand wise <span class="auto" id="cValueTotal">total ₹0</span></label><div class="brandgrid" id="cBrandGrid">#foreach($b in $leadBrands)<div class="brandcell"><span class="bname">$b</span><input class="inp bval mono" data-brand="$b" placeholder="0" inputmode="numeric"oninput="brandTotal()"></div>#end</div><div class="hinttext">Type in rupees per month — 100000 or 1L, 50k both work. Leave a brand blank if it is not stocked.</div></div><div class="fld"><label>Interest status</label><select class="inp" id="cInterest"><option>Interested</option><option>Callback</option><option>Not Interested</option></select></div><div class="fld full"><label>Region / State <span class="m">*</span> <span class="auto">drives auto-assignment</span></label><select class="inp" id="cRegion" onchange="resolveBM(this.value)"><option value="">Select a region…</option>#foreach($r in $regions)<option value="$r.getId()">$!r.getName()#if($r.getRegionCode()) ($r.getRegionCode())#end</option>#end</select></div></div><div style="display:flex;gap:9px;margin-top:16px;justify-content:flex-end"><button class="btn coral" onclick="submitLead()">Submit & generate LMS ID</button></div></div><div><div class="card"><h2>Auto-assignment preview</h2><p class="desc">On Region select, the engine resolves the BM/RSM and shows it for confirmation before submit.</p><div class="resolvebox" id="resolveBox"><div class="ra" id="raAv">—</div><div><div class="rt">Resolved BM / RSM · <span id="rRegion">—</span></div><div class="rn" id="rName">Pick a region…</div><div class="rc" id="rEsc">The owning BM/RSM is resolved from the region (cs.position).</div></div></div><div class="note">If a region has no active BM/RSM, the LMS enters a <b>HOLD queue</b> and the Sales Head is notified.</div></div><div class="card"><h2>What happens on submit</h2><div class="trail" style="margin-top:4px"><div class="tevt win"><div class="tdot">1</div><div class="tt">Validate fields & generate LMS ID</div><div class="tm">immutable, searchable</div></div><div class="tevt sys"><div class="tdot">2</div><div class="tt">Set stage = Assigned</div><div class="tm">BM/RSM resolved from region</div></div><div class="tevt sys"><div class="tdot">3</div><div class="tt">Dispatch assignment email</div><div class="tm">BM/RSM + CC Reporting Mgr</div></div><div class="tevt call"><div class="tdot">4</div><div class="tt">Start 5 hr SLA timer</div><div class="tm">first-contact clock begins</div></div></div></div></div></div></div><!-- ======================= AUTO-ASSIGNMENT ========================= --><div id="assign" class="hide"><div class="pagehead"><div><div class="crumb">Sales Ops · Routing</div><h1 class="page">Auto-Assignment Engine</h1><div class="sub">Region → BM/RSM mapping resolves ownership at submit — from the live cs.position table. HOLD when a region has no active BM.</div></div></div><div class="kpis" style="grid-template-columns:repeat(4,1fr)" id="assignKpis"></div><div class="grid g3"><div class="card"><h2>Region → BM/RSM mapping</h2><p class="desc">The live routing table — resolved for each region right now.</p><table><thead><tr><th>Region / State</th><th>Assigned BM / RSM</th><th>Status</th></tr></thead><tbody id="assignMap"><tr><td colspan="3"><div class="ph">Resolving…</div></td></tr></tbody></table></div><div><div class="card"><h2>Engine rules</h2><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#eef1fb;color:#3b5bdb">1</div><div class="ab"><div class="atitle">Resolve at submit</div><div class="adet">Region drives ownership; BM/RSM shown for confirmation.</div></div></div><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff4e6;color:#e8590c">2</div><div class="ab"><div class="atitle">Tier fallback</div><div class="adet">L4 → L5 → L3 within the region (cs.position).</div></div></div><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff0f0;color:#e03131">3</div><div class="ab"><div class="atitle">HOLD queue</div><div class="adet">No active BM/RSM → LMS held; Sales Head notified.</div></div></div><div class="att" style="margin-bottom:0"><div class="ic" style="background:#f3f0ff;color:#7048e8">4</div><div class="ab"><div class="atitle">Manual reassignment</div><div class="adet">BM/RSM/Sales Head can override — always logged.</div></div></div></div><div class="card" style="background:#fff5f5;border-color:#ffd9d5"><h2 style="color:#c92a2a">HOLD queue</h2><p class="desc" style="margin-bottom:8px">LMS with no active BM — waiting until one is mapped.</p><div id="holdQueue"><div class="ph">Loading…</div></div></div></div></div></div><!-- ======================= DASHBOARDS ========================= --><div id="dash" class="hide"><div class="pagehead"><div><div class="crumb">Management · Cross-region roll-up</div><h1 class="page">Dashboards</h1><div class="sub">Funnel, SLA compliance, ageing & servicing across all regions — derived from the lead record.</div></div><div class="hd-right"><span class="ctl"><span class="lbl">Period</span><span id="dashPeriod">—</span></span><button class="btn ghost sm" onclick="toast('Export uses the app report suite',1)">Export</button></div></div><div class="kpis" id="dashKpis"></div><div class="sectionlabel">State funnel — NEW → ONBOARDED by region</div><div class="card"><table><thead><tr><th>Region</th><th class="num">New</th><th class="num">Contacted</th><th class="num">Qualified</th><th class="num">Visited</th><th class="num">Onboarded</th><th class="num">Conv%</th><th class="num">SLA%</th></tr></thead><tbody id="dashState"></tbody></table></div><div class="grid g2"><div class="card"><h2>SLA compliance — owner heat-map</h2><p class="desc">% first contact within SLA, by BM (rows) across recent weeks (cols). Red = chronic breach.</p><div id="dashHeat"></div><div class="note">SLA = first contact within 5 hrs of creation (SOP §11). Reminder T-1h; escalation to BM at T+5h.</div></div><div class="card"><h2>Standard dashboards</h2><p class="desc">SOP §18.2 report suite — Live = from the lead record; Partial/Pending need their source system.</p><table><tbody style="font-size:12.5px"><tr class="clickrow" data-rep="State Funnel"><td>📈 <b>State Funnel</b><span class="rbadge live">Live</span></td><td style="color:#69748c">New → onboarded by region, BM, ASM</td></tr><tr class="clickrow" data-rep="SLA Compliance"><td>⚖ <b>SLA Compliance</b><span class="rbadge live">Live</span></td><td style="color:#69748c">% within SLA, owner heat-map</td></tr><tr class="clickrow" data-rep="Ageing"><td>⌛ <b>Ageing</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Stuck by stage, days-since-action</td></tr><tr class="clickrow" data-rep="Management Roll-up"><td>👑 <b>Management Roll-up</b><span class="rbadge live">Live</span></td><td style="color:#69748c">Cross-region leads, onboardings, value</td></tr><tr class="clickrow" data-rep="BGC Daily"><td>📊 <b>BGC Daily</b><span class="rbadge partial">Partial</span></td><td style="color:#69748c">Created + dispositions real; calls are a proxy (no telephony)</td></tr><tr class="clickrow" data-rep="Audit Hygiene"><td>🧹 <b>Audit Hygiene</b><span class="rbadge partial">Partial</span></td><td style="color:#69748c">Missing dispositions / HOLD; DND & recordings pending</td></tr><tr class="clickrow" data-rep="RBM Servicing"><td>🔄 <b>RBM Servicing</b><span class="rbadge soon">Pending data</span></td><td style="color:#69748c">Repeat-order / complaint / TAT need order data</td></tr></tbody></table></div></div><div class="sectionlabel">What to fix</div><div class="callout"><h2>Recommended actions — from this period's data</h2><p class="desc">Each links to the section above.</p><div id="dashFix"></div></div></div><!-- ======================= RECORDINGS & AUDIT ========================= --><div id="audit" class="hide"><div class="pagehead"><div><div class="crumb">Audit · Access Control</div><h1 class="page">Recordings & Audit</h1><div class="sub">Recordings are gated by role + reporting hierarchy; every open is logged. Hygiene flags are live; the recording repo is a pending source system.</div></div></div><div class="kpis" style="grid-template-columns:repeat(5,1fr)" id="auditKpis"></div><div class="grid g2"><div class="card"><h2>Access & recording visibility matrix</h2><p class="desc">What each role can open. Recording access is scoped by role + reporting hierarchy (SOP §15).</p><table class="matrix"><thead><tr><th>Role</th><th>Own LMS</th><th>Reports'</th><th>State-wide</th><th>All records</th><th>Recordings</th></tr></thead><tbody><tr><td>Individual (BGC/ASM)</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-s">own only</td></tr><tr><td>Reporting Manager</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-n">—</td><td class="mk-s">reports'</td></tr><tr><td>BM / RSM</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-n">—</td><td class="mk-s">state-wide</td></tr><tr><td>Audit Team</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-s">all · read-only</td></tr><tr><td>Upper Management</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-y">✓</td><td class="mk-s">via BI drill</td></tr></tbody></table><div class="note">Recording repository (signed URLs, DND enforcement, 24-mo retention) is a Phase-2 source system — not yet wired to real data.</div></div><div class="card"><h2>Weekly hygiene flags <span style="color:#97a1b5;font-weight:500;font-size:12px">· live</span></h2><p class="desc">Derived from the lead record — click to list the leads.</p><div id="auditHygiene"><div class="ph">Loading…</div></div></div></div><div class="grid g2"><div class="card"><h2>Audit & compliance program (SOP §17)</h2><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">🎵</div><div class="ab"><div class="atitle">Weekly recording sampling</div><div class="adet">Across BGC, ASM & RBM for quality & process adherence.</div></div></div><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#e6fcf5;color:#0ca678">🧹</div><div class="ab"><div class="atitle">5% deep audit of onboarded</div><div class="adet">Verify data integrity & process compliance.</div></div></div><div class="att" style="margin-bottom:8px"><div class="ic" style="background:#fff0f0;color:#e03131">🚫</div><div class="ab"><div class="atitle">DND enforcement</div><div class="adet">Audit maintains the list; repeat calls auto-flag the user.</div></div></div><div class="att" style="margin-bottom:0"><div class="ic" style="background:#eef1f6;color:#69748c">🗃</div><div class="ab"><div class="atitle">Retention policy</div><div class="adet">24 months for recordings · 5 years for LMS records.</div></div></div></div><div class="card"><h2>Recording repository <span style="color:#97a1b5;font-weight:500;font-size:12px">· pending source</span></h2><p class="desc">Each recording would be tagged with LMS ID, user, timestamp, duration & disposition.</p><div class="ph">No recording repository connected yet — this arrives with the C2C telephony bridge (Phase 2/3).</div></div></div></div></div><!-- drawer + toast --><div class="drawer-ov" id="ov" onclick="closeDrawer()"></div><div class="drawer" id="drawer"><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><div class="dbody" id="dBody"></div></div><!-- Record actions: edit the record, and log a call outcome. Both write to the same endpointsthe in-app lead record uses. --><div class="modal hide" id="editModal"><div class="box"><div class="mh"><h2>Edit record</h2><p class="desc">Every change is written to the trail against your name.</p></div><div class="mb" id="editBody"></div><div class="mf"><button class="btn ghost" onclick="closeEdit()">Cancel</button><button class="btn" style="background:#1f2a5e;color:#fff" onclick="saveEdit()">Save changes</button></div></div></div><div class="modal hide" id="dispModal"><div class="box"><div class="mh"><h2>Call disposition</h2><p class="desc">Structured outcome on every interaction (SOP §12.2).</p></div><div class="mb"><label class="lbl">Outcome (required)</label><div class="dispgrid" id="dispGrid"></div><div id="dispExtra"></div><label class="lbl">Note (attaches to the trail)</label><textarea id="dispNote" class="inp" rows="2" style="width:100%;resize:vertical"></textarea></div><div class="mf"><button class="btn ghost" onclick="closeDisp()">Cancel</button><button class="btn coral" onclick="saveDisp()">Save disposition</button></div></div></div><!-- Manual geo verification (SOP §6: geo-pin). Same endpoints the Leads screen uses:/lead-geo/geocode-address, /lead-geo/upload-image, /lead-geo/manual-verify. --><div class="modal hide" id="geoModal"><div class="box"><div class="mh"><h2>Verify location manually</h2><p class="desc">For when the retailer will not tap the capture link — confirm the coordinatesover the phone, or read them off a Maps link they shared. Saved as approved, against your name.</p></div><div class="mb"><label class="lbl">Address</label><div style="display:flex;gap:6px"><input id="geoAddress" class="inp" style="flex:1" placeholder="Shop address with city, state, pincode"><button class="btn ghost sm" id="geoFindBtn" onclick="geoGeocode()">Find on map</button></div><label class="lbl">Latitude, longitude</label><div style="display:flex;gap:6px"><input id="geoLatLng" class="inp" style="flex:1" placeholder="28.6139,77.2090 — or paste a Google Maps link"><button class="btn ghost sm" onclick="geoPreview()">Show on map</button></div><div id="geoMapWrap" class="hide" style="margin-top:10px"><iframe id="geoMapFrame" style="width:100%;height:220px;border:1px solid var(--line);border-radius:10px" loading="lazy"></iframe><a id="geoOpenMap" class="note" target="_blank" rel="noopener" href="#">Open in Google Maps →</a></div><label class="lbl">Store-board photo (required)</label><input id="geoPhoto" type="file" accept="image/jpeg,image/png" class="inp" onchange="geoPickPhoto()"><div class="note">The only evidence that someone actually saw the store-board.</div><img id="geoPhotoImg" class="hide" style="margin-top:8px;max-height:130px;border-radius:8px;border:1px solid var(--line)"><label class="lbl">Remark</label><input id="geoRemark" class="inp" placeholder="Optional — how the location was confirmed"><div id="geoErr" class="hide" style="margin-top:10px;color:#e03131;font-size:12.5px"></div></div><div class="mf"><button class="btn ghost" onclick="closeGeo()">Cancel</button><button class="btn" style="background:#1f2a5e;color:#fff" id="geoSaveBtn" onclick="geoSave()">Save & verify</button></div></div></div><div class="toastwrap" id="toasts"></div><script>var CTX = "${rc.contextPath}";var SEED = { summary:$summaryJson, state:$stateFunnelJson, heat:$slaHeatmapJson, ageing:$ageingJson };var STATE = { regionId:'', from:'$!fromDate', to:'$!toDate' };var DATA = { summary:null, state:null, heat:null, ageing:null };function byId(x){ return document.getElementById(x); }function esc(s){ if(s==null) return ''; return (''+s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }function fmtInt(n){ if(n==null) return '0'; return (''+n).replace(/\B(?=(\d{3})+(?!\d))/g,','); }function fmtInr(v){ if(v==null||v===''||isNaN(v)||Number(v)<=0) return '₹0'; v=Number(v);if(v>=1e7) return '₹'+(v/1e7).toFixed(1)+'<small> Cr</small>'; if(v>=1e5) return '₹'+(v/1e5).toFixed(1)+'<small> L</small>';if(v>=1e3) return '₹'+Math.round(v/1e3)+'<small>K</small>'; return '₹'+Math.round(v); }/*** Business value in lakhs.** Two conventions live in user.lead.potential: the form captures LAKHS (rows hold 1–99), but* ~1,355 older rows were entered in RUPEES (1,000 – 2,00,00,000). Nothing between 100 and 999* exists, so the gap is a safe separator: 1000 and above is read as rupees and brought onto the* lakh scale. Normalised on read only — no stored value is rewritten.*/var LAKH_CUTOFF=1000;function toLakh(v){ v=Number(v); return v>=LAKH_CUTOFF ? v/100000 : v; }function inrShort(v){ if(v==null||isNaN(v)||Number(v)<=0) return '₹0';var l=toLakh(v);// Under a lakh needs two decimals: a ₹1,000 lead is 0.01L, and one decimal would print it as// ₹0L — the same information loss the old scaling caused, just at the other end.var n=l<1?Math.round(l*100)/100:Math.round(l*10)/10;return '₹'+(n%1===0?n:n.toFixed(l<1?2:1))+'L'; }// ---- tabsvar SCREENS=['cmd','record','create','assign','dash','audit'];var LOADED={};function go(id,btn){ SCREENS.forEach(function(s){ byId(s).classList.toggle('hide',s!==id); });document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); }); if(btn) btn.classList.add('active');if(id==='dash') byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||'');if(id==='record' && !LOADED.record){ LOADED.record=true; loadRecentRecord(); }if(id==='assign' && !LOADED.assign){ LOADED.assign=true; buildAssign(); }if(id==='audit'){ buildAudit(); } }// ---- toastfunction toast(msg,ok){ var w=byId('toasts'); var t=document.createElement('div'); t.className='toast';t.innerHTML=(ok?'<span class="ok">✓</span>':'')+esc(msg); w.appendChild(t);setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity .3s'; setTimeout(function(){ t.remove(); },300); },2600); }// ---- filters + fetchfunction qs(){ var q=''; if(STATE.regionId)q+='®ionId='+encodeURIComponent(STATE.regionId);if(STATE.from)q+='&from='+encodeURIComponent(STATE.from); if(STATE.to)q+='&to='+encodeURIComponent(STATE.to); return q; }function getJSON(path,cb,onErr){ fetch(CTX+path,{credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'}}).then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }).then(function(j){ cb((j&&j.response!==undefined)?j.response:j); }).catch(function(e){ console.error('LMS fetch failed',path,e);if(typeof onErr==='function'){ onErr(e); } else { toast('Could not load data'); } }); }// ---- stage helpersvar STCLASS={NEW:'st-new',ASSIGNED:'st-assigned',CONTACTED:'st-contacted',QUALIFIED:'st-qualified',BEAT_PLANNED:'st-beat',VISITED:'st-visited',ONBOARDED:'st-onboarded',ACTIVE:'st-active',NOT_INTERESTED:'st-ni',DROPPED:'st-dead'};function stagePill(st,lb){ if(!st) return '<span class="stage st-new">—</span>'; return '<span class="stage '+(STCLASS[st]||'st-new')+'">'+esc(lb||st)+'</span>'; }function slaCell(state){ switch(state){case'MET': return '<span class="sla ok"><span class="slabar"><i style="width:100%;background:#2f9e44"></i></span><span class="clk">met</span></span>';case'RUNNING': return '<span class="sla ok"><span class="slabar"><i style="width:50%;background:#1f2a5e"></i></span><span class="clk">running</span></span>';case'AT_RISK': return '<span class="sla warn"><span class="slabar"><i style="width:85%;background:#f08c00"></i></span><span class="clk">< 1 hr</span></span>';case'BREACHED': return '<span class="sla bad"><span class="slabar"><i style="width:100%;background:#e03131"></i></span><span class="clk">breached</span></span>';default: return '<span style="color:#97a1b5;font-size:12px">—</span>'; } }// ---- COMMAND CENTERvar CC_KPI={ 'new':{i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, awaiting:{i:'⏳',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'},sla:{i:'⚠',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, contacted:{i:'📞',bg:'#e3fafc',fg:'#0c8599'},qualified:{i:'✓',bg:'#e6fcf5',fg:'#0ca678'}, onboarded:{i:'🏆',bg:'#ebfbee',fg:'#2f9e44'} };var ATT_META={ BREACHED_NO_CONTACT:{i:'⚠',bg:'#fff0f0',fg:'#e03131',c:'badflag'}, HOLD:{i:'👤',bg:'#fff0f0',fg:'#e03131',c:'badflag'},SLA_UNDER_1H:{i:'⏳',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'}, MISSING_DISP:{i:'💬',bg:'#fff4e6',fg:'#e8590c',c:'warnflag'},QUALIFIED_NO_BEAT:{i:'🕑',bg:'#eef1f6',fg:'#69748c',c:''}, ONBOARDED_PENDING:{i:'🔄',bg:'#f3f0ff',fg:'#7048e8',c:''} };function renderSummary(d){ DATA.summary=d;var kh=''; (d.kpis||[]).forEach(function(k){ var m=CC_KPI[k.key]||{i:'📊',bg:'#eef1f6',fg:'#69748c'};kh+='<div class="kpi" data-kpi="'+esc(k.key)+'"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+'<div class="label">'+esc(k.label)+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+fmtInt(k.value)+'</div>'+'<div class="foot">'+esc(k.foot)+'</div></div>'; });byId('ccKpis').innerHTML=kh;renderFunnel(d.funnel||[]);var ah=''; (d.attention||[]).forEach(function(a){ var m=ATT_META[a.key]||{i:'📊',bg:'#eef1f6',fg:'#69748c',c:''};ah+='<div class="att" data-att="'+esc(a.key)+'" data-attt="'+esc(a.title)+'"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+'<div class="ab"><div class="atitle">'+esc(a.title)+'</div><div class="adet">'+esc(a.detail)+'</div></div>'+'<div class="acount '+m.c+'">'+fmtInt(a.count)+'</div></div>'; });byId('ccAttention').innerHTML=ah;buildDashKpis(); buildFix();}function renderFunnel(funnel){var byStage={}; funnel.forEach(function(s){ byStage[s.stage]=s; });var seq=[['ALL','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],['VISITED','Visited'],['ONBOARDED','Onboarded']];var h=''; var prev=null;seq.forEach(function(p,idx){ var s=byStage[p[0]]; if(!s) return; var last=(idx===seq.length-1);var cap; if(idx===0){ cap='<div class="keep">created</div>'; }else if(last){ cap='<div class="keep">'+s.pct+'% conversion</div>'; }else { var drop=prev?prev.count-s.count:0; cap=drop>0?('<div class="drop">−'+fmtInt(drop)+'</div>'):('<div class="keep">'+s.pct+'%</div>'); }h+='<div class="fstep" data-stage="'+p[0]+'" data-label="'+esc(p[1])+'"><div class="fn">'+esc(p[1])+'</div><div class="fv">'+fmtInt(s.count)+'</div>'+'<div class="fbar" style="width:'+Math.max(s.pct,3)+'%"></div>'+cap+(last?'':'<span class="arrow">➤</span>')+'</div>';prev=s; });byId('ccFunnel').innerHTML=h||'<div class="ph">No leads in scope.</div>';}// ---- Lead pipeline: filtered, sorted and paged on the server ------------------------------// PIPE.q is the search term. While it is set the date filter does not apply — a search hunts for// one known retailer, and quietly scoping it to the selected month would report "not found" for a// lead that exists. The caption says which mode is in force so that is never a surprise.var PIPE = { q:'', sort:'created', dir:'desc', page:1, pageSize:50, pages:1, total:0 };function loadPipeline(){byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">Loading…</div></td></tr>';var p='/lms/dashboard/pipeline?page='+PIPE.page+'&pageSize='+PIPE.pageSize+'&sort='+encodeURIComponent(PIPE.sort)+'&dir='+encodeURIComponent(PIPE.dir);if(PIPE.q){ p+='&q='+encodeURIComponent(PIPE.q);if(STATE.regionId) p+='®ionId='+encodeURIComponent(STATE.regionId); }else { p+=qs(); }getJSON(p,function(d){d=d||{}; var rows=d.rows||[];PIPE.total=d.total||0; PIPE.pages=d.pages||1; PIPE.page=d.page||1;renderPipeHead();if(!rows.length){byId('ccPipeline').innerHTML='<tr><td colspan="9"><div class="ph">'+(PIPE.q?('No lead matches “'+esc(PIPE.q)+'”.'):'No leads in scope.')+'</div></td></tr>';} else {var h='';rows.forEach(function(l){var rc=l.slaState==='BREACHED'?'rowbad':(l.slaState==='AT_RISK'?'rowwarn':'');h+='<tr class="clickrow '+rc+'" data-leadid="'+l.id+'">'+'<td class="nowrap"><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+'<td class="wrapok"><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+'<td class="nowrap">'+(l.path==='B'?'<span class="tag b">B · Field</span>':'<span class="tag a">A · BGC</span>')+'</td>'+'<td class="nowrap">'+stagePill(l.stage,l.stageLabel)+'</td>'+// title= so a name clipped by the fixed width is still readable on hover.'<td class="nowrap" title="'+esc(l.owner||'')+'">'+esc(l.owner||'—')+'</td>'+'<td class="nowrap" title="'+esc(l.city||'')+'">'+esc(l.city||'—')+'</td>'+'<td class="nowrap" title="'+esc(l.createdBy||'')+'">'+esc(l.createdBy||'—')+'</td>'+'<td class="nowrap">'+slaCell(l.slaState)+'</td>'+'<td class="num nowrap">'+inrShort(l.value)+'</td></tr>';});byId('ccPipeline').innerHTML=h;}// Say how many there actually are, not how many were drawn.var shown=rows.length, first=(PIPE.page-1)*PIPE.pageSize+(shown?1:0), last=(PIPE.page-1)*PIPE.pageSize+shown;byId('pipeCount').textContent=PIPE.total? ('Showing '+first+'–'+last+' of '+fmtInt(PIPE.total)+(PIPE.q?' matching leads (all dates)':' leads in the selected period')): '';byId('pipePageLbl').textContent='Page '+PIPE.page+' / '+Math.max(PIPE.pages,1);byId('pipePrev').disabled=(PIPE.page<=1);byId('pipeNext').disabled=(PIPE.page>=PIPE.pages);byId('pipeDesc').innerHTML=PIPE.q? 'Searching every lead for “<b>'+esc(PIPE.q)+'</b>” — the date filter is ignored while a search is active.': 'Click any row → full LMS record & trail. Rows shaded by SLA urgency.';byId('pipeClear').classList.toggle('hide', !PIPE.q);});}/*** Mark the active column and its direction.** The label is captured once into data-label and re-read from there. Deriving it from* textContent instead re-read the arrow this function had just appended, so every sort click* stacked another one onto the header.*/function renderPipeHead(){document.querySelectorAll('th.sortable').forEach(function(th){if(!th.hasAttribute('data-label')) th.setAttribute('data-label', th.textContent.trim());var on=(th.getAttribute('data-sort')===PIPE.sort);th.classList.toggle('on',on);th.innerHTML=esc(th.getAttribute('data-label'))+(on?'<span class="arrow '+(PIPE.dir==='asc'?'up':'down')+'"></span>':'');});}function pipeSort(col){// Same column toggles direction; a new column starts descending, which is what people expect// from value and dates and is harmless for the text columns.if(PIPE.sort===col){ PIPE.dir=(PIPE.dir==='asc'?'desc':'asc'); }else { PIPE.sort=col; PIPE.dir='desc'; }PIPE.page=1; loadPipeline();}function pipeSearch(){var v=(byId('pipeQ').value||'').trim();if(v && v.length<2){ toast('Type at least 2 characters'); return; }PIPE.q=v; PIPE.page=1; loadPipeline();}function pipeClearSearch(){ byId('pipeQ').value=''; PIPE.q=''; PIPE.page=1; loadPipeline(); }function pipePage(step){var n=PIPE.page+step;if(n<1||n>PIPE.pages) return;PIPE.page=n; loadPipeline();}function kpiBucket(k){ switch(k){case'new':return['STAGE','NEW'];case'awaiting':return['STAGE','ASSIGNED'];case'sla':return['ATTENTION','BREACHED_NO_CONTACT'];case'contacted':return['STAGE','CONTACTED'];case'qualified':return['STAGE','QUALIFIED'];case'onboarded':return['STAGE','ONBOARDED'];default:return['ALL','']; } }// ---- DASHBOARDSvar DASH_KPI=[ {i:'📥',bg:'#eef1fb',fg:'#3b5bdb'}, {i:'📈',bg:'#e6fcf5',fg:'#0ca678',v:'#0ca678'},{i:'⚖',bg:'#fff4e6',fg:'#f08c00',v:'#f08c00'}, {i:'📞',bg:'#e3fafc',fg:'#0c8599'},{i:'⌛',bg:'#fff0f0',fg:'#e03131',v:'#e03131'}, {i:'💰',bg:'#ebfbee',fg:'#2f9e44'} ];function pctColor(p){ return p>=25?'#2f9e44':(p>=18?'#f08c00':(p>=10?'#e8590c':'#e03131')); }function slaColor(p){ return p>=85?'#2f9e44':(p>=70?'#f08c00':'#e03131'); }function renderState(d){ DATA.state=d; var rows=(d&&d.rows)?d.rows:[];var h=''; rows.forEach(function(r){ var cls=r.convPct<15?'rowbad':(r.slaPct<60?'rowwarn':'');h+='<tr class="clickrow '+cls+'" data-region="'+esc(r.regionCode)+'" data-rn="'+esc(r.regionName)+'">'+'<td><b>'+esc(r.regionName)+'</b> <span class="idpill">'+esc(r.regionCode)+'</span></td>'+'<td class="num">'+fmtInt(r.newCount)+'</td><td class="num">'+fmtInt(r.contacted)+'</td><td class="num">'+fmtInt(r.qualified)+'</td>'+'<td class="num">'+fmtInt(r.visited)+'</td><td class="num">'+fmtInt(r.onboarded)+'</td>'+'<td class="num"><b style="color:'+pctColor(r.convPct)+'">'+r.convPct+'%</b></td>'+'<td class="num"><b style="color:'+slaColor(r.slaPct)+'">'+r.slaPct+'%</b></td></tr>'; });byId('dashState').innerHTML=h||'<tr><td colspan="8"><div class="ph">No regional data.</div></td></tr>';buildDashKpis(); buildFix();}function renderHeat(d){ DATA.heat=d; var weeks=(d&&d.weeks)?d.weeks:[],bms=(d&&d.bms)?d.bms:[];if(!bms.length){ byId('dashHeat').innerHTML='<div class="ph">No SLA data for the selected period.</div>'; return; }function hc(p){ if(p>=90)return'#40c057';if(p>=80)return'#82c91e';if(p>=70)return'#fab005';if(p>=60)return'#fd7e14';if(p>=50)return'#fa5252';return'#e03131'; }var h='<table class="heat"><thead><tr><td style="background:none;border:0"></td>';weeks.forEach(function(w){ h+='<td style="background:none;border:0;color:#97a1b5;font-size:10px;font-weight:700">'+esc(w)+'</td>'; });h+='</tr></thead><tbody>';bms.forEach(function(bm){ h+='<tr><td style="background:none;color:#69748c;font-size:11px;text-align:left;width:auto;border:0">'+esc(bm.bmName)+'</td>';(bm.cells||[]).forEach(function(c){ if(c.pct==null){ h+='<td style="background:#eef1f6;color:#97a1b5">–</td>'; }else { var col=hc(c.pct),tc=(c.pct>=70&&c.pct<80)?'#333':'#fff'; h+='<td style="background:'+col+';color:'+tc+'">'+c.pct+'</td>'; } }); h+='</tr>'; });h+='</tbody></table>'; byId('dashHeat').innerHTML=h;}function renderAgeing(d){ DATA.ageing=d; buildDashKpis(); buildFix(); }function buildDashKpis(){ var s=DATA.summary; if(!s) return;var contacted=0; (s.funnel||[]).forEach(function(f){ if(f.stage==='CONTACTED')contacted=f.count; });var tot=0,acc=0; ((DATA.state&&DATA.state.rows)||[]).forEach(function(r){ tot+=r.newCount; acc+=r.slaPct*r.newCount; });var sla=tot>0?Math.round(acc/tot):0;var vals=[ {v:fmtInt(s.total),f:'in period'}, {v:s.conversionPct+'<small>%</small>',f:'new → onboarded'},{v:sla+'<small>%</small>',f:'first contact ≤ 5h'}, {v:fmtInt(Math.round(contacted/90)),f:'proxy · no telephony'},{v:DATA.ageing?fmtInt(DATA.ageing.total):'—',f:'stuck by stage'}, {v:fmtInr(s.onboardedValue),f:'est. potential'} ];var labels=['LMS created','Conversion','SLA compliance','Calls / day (BGC)','Ageing > 7 days','Onboarded value'];var h=''; vals.forEach(function(x,i){ var m=DASH_KPI[i];h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+m.bg+';color:'+m.fg+'">'+m.i+'</div>'+'<div class="label">'+labels[i]+'</div></div><div class="val"'+(m.v?' style="color:'+m.v+'"':'')+'>'+x.v+'</div><div class="foot">'+x.f+'</div></div>'; });byId('dashKpis').innerHTML=h;}function buildFix(){ var rows=((DATA.state&&DATA.state.rows)||[]).slice();var out='';if(rows.length){ var worst=rows.slice().sort(function(a,b){return a.convPct-b.convPct;})[0];out+='<div class="fix"><div class="sev" style="background:#e03131"></div><div class="fb"><div class="ft">'+esc(worst.regionName)+' is leaking at the top</div>'+'<div class="fd"><b>'+fmtInt(worst.newCount)+' created, only '+worst.convPct+'% onboarded and '+worst.slaPct+'% SLA</b> — the weakest region this period.</div>'+'<div class="act">→ Review its stuck leads; check BM mapping / HOLD queue.</div></div></div>';var lowSla=rows.slice().sort(function(a,b){return a.slaPct-b.slaPct;})[0];if(lowSla && lowSla.regionCode!==worst.regionCode){ out+='<div class="fix"><div class="sev" style="background:#f59f00"></div><div class="fb"><div class="ft">'+esc(lowSla.regionName)+' SLA slipping</div>'+'<div class="fd"><b>'+lowSla.slaPct+'% on-time first contact</b> — first-contact discipline is breaking down.</div>'+'<div class="act">→ Enforce ASM mapping (≤2h) + coaching.</div></div></div>'; } }var age=DATA.ageing?DATA.ageing.total:0;out+='<div class="fix"><div class="sev" style="background:#f59f00"></div><div class="fb"><div class="ft">'+fmtInt(age)+' leads ageing > 7 days</div>'+'<div class="fd"><b>Stuck open leads by stage</b> — qualified leads not being added to beats lose revival odds.</div>'+'<div class="act">→ Nudge ASMs to beat-plan qualified LMS within 48h.</div></div></div>';byId('dashFix').innerHTML=out;}// ---- drill drawerfunction openDrawer(crumb,title,sub,html){ byId('dCrumb').textContent=crumb||''; byId('dTitle').textContent=title||'';byId('dSub').textContent=sub||''; byId('dBody').innerHTML=html||''; byId('ov').classList.add('open'); byId('drawer').classList.add('open'); }function closeDrawer(){ byId('ov').classList.remove('open'); byId('drawer').classList.remove('open'); }document.addEventListener('keydown',function(e){ if(e.key==='Escape')closeDrawer();if(e.key==='/'&&document.activeElement.tagName!=='INPUT'){ e.preventDefault(); byId('lmsSearch').focus(); } });function drawerLeadTable(leads){ if(!leads||!leads.length) return '<div class="ph">No leads in this bucket.</div>';var h='<div class="card"><table><thead><tr><th>LMS ID</th><th>Business</th><th>Stage</th><th>Owner</th><th>SLA</th><th class="num">Value</th></tr></thead><tbody>';leads.forEach(function(l){ h+='<tr class="clickrow" data-leadid="'+l.id+'"><td><span class="idpill">'+esc(l.lmsCode)+'</span></td>'+'<td><b>'+esc(l.name||'—')+'</b>'+(l.outlet?'<br><span style="color:#97a1b5;font-size:11px">'+esc(l.outlet)+'</span>':'')+'</td>'+'<td>'+stagePill(l.stage,l.stageLabel)+'</td><td>'+esc(l.owner||'—')+'</td><td>'+slaCell(l.slaState)+'</td>'+'<td class="num">'+inrShort(l.value)+'</td></tr>'; });return h+'</tbody></table></div>'; }function drill(crumb,title,sub,bt,bk){ openDrawer(crumb,title,'Loading…','<div class="ph">Loading…</div>');var q='?bucketType='+encodeURIComponent(bt)+(bk?'&bucketKey='+encodeURIComponent(bk):'')+qs();getJSON('/lms/dashboard/leads'+q,function(d){ var leads=(d&&d.leads)?d.leads:[];openDrawer(crumb,title,(sub||'')+' · '+leads.length+' lead(s)',drawerLeadTable(leads)); }); }// ---- app-bar search: LMS id / retailer / business / phone / city -> results in the drawervar searchTimer=null, searchSeq=0;function runSearch(){ var q=(byId('lmsSearch').value||'').trim();if(q.length<2){ toast('Type at least 2 characters'); return; }var seq=++searchSeq;openDrawer('Search','\u201c'+q+'\u201d','Searching…','<div class="ph">Searching…</div>');getJSON('/lms/dashboard/search?q='+encodeURIComponent(q),function(d){if(seq!==searchSeq) return; // a newer search already wonvar leads=(d&&d.leads)?d.leads:[];openDrawer('Search','\u201c'+q+'\u201d',leads.length+' match(es)',drawerLeadTable(leads)); },function(){ if(seq!==searchSeq) return;openDrawer('Search','\u201c'+q+'\u201d','Search failed','<div class="ph">Could not run the search.</div>'); }); }byId('pipeQ').addEventListener('keydown',function(e){if(e.key==='Enter'){ e.preventDefault(); pipeSearch(); }else if(e.key==='Escape'){ pipeClearSearch(); } });byId('lmsSearch').addEventListener('keydown',function(e){if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); runSearch(); }else if(e.key==='Escape'){ this.blur(); closeDrawer(); } });byId('lmsSearch').addEventListener('input',function(){ clearTimeout(searchTimer);var v=(this.value||'').trim(); if(v.length<2) return;searchTimer=setTimeout(runSearch,400); });var REPORT_NOTE={ 'State Funnel':'Live from the lead record — grouped by region, BM and ASM.','SLA Compliance':'Live — first_contacted_at vs first_contact_due, owner heat-map.','Ageing':'Live — open leads untouched > 7 days, by stage.','Management Roll-up':'Live — cross-region leads, onboardings and estimated value.','BGC Daily':'Partial — created + dispositions are real; call volume is a PROXY (contacted ÷ days). No telephony bridge yet.','Audit Hygiene':'Partial — missing-disposition & HOLD are shown; DND breaches & recording flags need the call/recording log.','RBM Servicing':'Pending data — repeat-order / complaint / resolution-TAT need order + ticket data outside the lead record.' };// ---- delegated clicksdocument.addEventListener('click',function(e){var th=e.target.closest('th.sortable'); if(th){ pipeSort(th.getAttribute('data-sort')); return; }var kpi=e.target.closest('.kpi[data-kpi]'); if(kpi){ var b=kpiBucket(kpi.getAttribute('data-kpi'));drill('Command Center',kpi.querySelector('.label').textContent,'KPI drill',b[0],b[1]); return; }var fs=e.target.closest('.fstep[data-stage]'); if(fs){ var st=fs.getAttribute('data-stage');drill('Lifecycle funnel',fs.getAttribute('data-label'),'Reached this stage',st==='ALL'?'ALL':'STAGE',st==='ALL'?'':st); return; }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; }var reg=e.target.closest('tr[data-region]'); if(reg){ drill('State funnel',reg.getAttribute('data-rn'),'Region '+reg.getAttribute('data-region'),'REGION',reg.getAttribute('data-region')); return; }var rep=e.target.closest('tr[data-rep]'); if(rep){ var nm=rep.getAttribute('data-rep');openDrawer('Report suite',nm,'SOP §18.2','<div class="card"><div class="dnote">'+esc(REPORT_NOTE[nm]||'')+'</div></div>'); return; }var lead=e.target.closest('[data-leadid]'); if(lead){ var lid=lead.getAttribute('data-leadid');closeDrawer(); LOADED.record=true; byId('recLookup').value=lid; injectRecord(lid);go('record',document.querySelectorAll('.tab')[1]); return; }});// ---- filtersfunction refresh(){ STATE.regionId=byId('fRegion').value||''; STATE.from=byId('fFrom').value||''; STATE.to=byId('fTo').value||'';getJSON('/lms/dashboard/summary?_=1'+qs(),renderSummary); loadPipeline();getJSON('/lms/dashboard/state-funnel?_=1'+qs(),renderState);getJSON('/lms/dashboard/sla-heatmap?_=1'+qs(),renderHeat);getJSON('/lms/dashboard/ageing',renderAgeing);byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||''); }['fRegion','fFrom','fTo'].forEach(function(id){ byId(id).addEventListener('change',refresh); });// ====================== LEAD RECORD (native sts-mockup layout, real data) ======================var STEPS=[['NEW','New'],['ASSIGNED','Assigned'],['CONTACTED','Contacted'],['QUALIFIED','Qualified'],['BEAT_PLANNED','Beat-plan'],['VISITED','Visited'],['ONBOARDED','Onboarded'],['ACTIVE','Active']];function fmtDT(s){ if(!s) return '—'; var d=new Date(s); if(isNaN(d)) return esc(s);var mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];function p(n){ return (n<10?'0':'')+n; }return p(d.getDate())+' '+mo[d.getMonth()]+' '+d.getFullYear()+' · '+p(d.getHours())+':'+p(d.getMinutes()); }function kvRow(k,v){ return '<div class="k">'+k+'</div><div class="v">'+(v==null||v===''?'—':v)+'</div>'; }function trailType(t){ if(t==='TELEPHONIC'||t==='CALL') return 'call'; if(!t) return 'sys'; return ''; }function injectRecord(id){ byId('recordHost').innerHTML='<div class="ph">Loading lead #'+esc(id)+'…</div>';getJSON('/lms/dashboard/record?leadId='+encodeURIComponent(id),function(d){ if(!d||!d.id){ byId('recordHost').innerHTML='<div class="ph">Lead #'+esc(id)+' not found.</div>'; return; } renderRecord(d); }); }function loadRecord(){ var v=(byId('recLookup').value||'').replace(/\D/g,''); if(!v){ toast('Enter a lead #'); return; } injectRecord(v); }function loadRecentRecord(){ getJSON('/lms/dashboard/leads?bucketType=ALL'+qs(),function(d){ var leads=(d&&d.leads)?d.leads:[];if(leads.length){ byId('recLookup').value=leads[0].id; injectRecord(leads[0].id); }else { byId('recordHost').innerHTML='<div class="ph">No leads to show. Create one from the Create Lead tab.</div>'; } }); }/** Trail entries shown before the list starts scrolling. */var TRAIL_VISIBLE=5;/*** Cap the trail at TRAIL_VISIBLE entries and scroll the rest.** Measured from the 6th entry's own offset rather than assumed from a row height — entries vary a* lot (a one-line note, a wrapped remark, one carrying an audio player), so any fixed px guess* would cut an entry in half on most leads.*/function capTrail(){var box=byId('trailScroll'); if(!box) return;var items=box.children;if(items.length<=TRAIL_VISIBLE){ box.style.maxHeight=''; box.style.overflowY=''; box.classList.remove('scrolls'); return; }var cut=items[TRAIL_VISIBLE];box.style.maxHeight=Math.round(cut.offsetTop-box.firstElementChild.offsetTop)+'px';box.style.overflowY='auto';box.classList.add('scrolls');}function renderRecord(d){REC=d; CALL.id=null;// headervar head='<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px">'+'<div><div class="crumb">'+(d.lmsCode?('<span class="idpill">'+esc(d.lmsCode)+'</span>'):('Lead #'+d.id))+'</div>'+'<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>'+'<div style="display:flex;gap:8px;align-items:center">'+stagePill(d.stage,d.stageLabel)+' <span class="stage" style="background:#eef1f6;color:#69748c">SLA: '+esc(d.slaState||'NA')+'</span>'+(d.assignmentStatus==='HOLD'?' <span class="stage st-ni">HOLD</span>':'')+'</div></div>';// steppervar idx=(d.stageIndex==null?-1:d.stageIndex);// A terminal lead has no stepper to draw, and a whole card repeating what the header pill and// the Record card's Current stage already say is just a band of empty space. Omit it.var step='';if(!d.terminal){step='<div class="card" style="padding:16px 22px"><div class="rec-stepper">';STEPS.forEach(function(s,i){ var cls=i<idx?'done':(i===idx?'cur':''); var last=(i===STEPS.length-1);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>'; });step+='</div></div>';}// detailsvar details='<div class="card"><h2 style="display:flex;align-items:center;justify-content:space-between;gap:10px">Record'+'<button class="btn ghost sm" onclick="openEdit()">✎ Edit</button></h2>'+'<p class="desc">ID, dates & auto-assignment are system-set and immutable. Everything else is correctable.</p>'+'<div class="grid g2" style="gap:20px"><div class="kv">'+kvRow('LMS ID', d.lmsCode?('<span class="mono">'+esc(d.lmsCode)+'</span>'):'—')+kvRow('Created', fmtDT(d.created)+(d.createdBy?(' · '+esc(d.createdBy)):''))+kvRow('Creation path', d.path?('<span class="tag '+(d.path==='B'?'b':'a')+'">'+(d.path==='B'?'B · Field':'A · BGC')+'</span>'):'—')+kvRow('Retailer', esc(d.name))+kvRow('Business', esc(d.outlet))+kvRow('Contact', d.contact?('<span class="mono">'+esc(d.contact)+'</span>'):'—')+'</div><div class="kv">'+kvRow('Location', esc(d.location))+kvRow('Region', esc(d.regionCode))+kvRow('Business value', inrShort(d.value))+kvRow('Source', esc(d.source))+kvRow('Last disposition', d.disposition?esc(d.disposition):'—')+kvRow('Owner BM/RSM', esc(d.ownerBm))+'</div></div><div class="divider"></div>'+'<div class="kv" style="grid-template-columns:130px 1fr 130px 1fr">'+kvRow('Working owner', esc(d.owner))+kvRow('Current stage', stagePill(d.stage,d.stageLabel))+kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+kvRow('Unreachable tries', d.unreachableCount==null?'0':d.unreachableCount)+'</div></div>';// SLA cardvar slaClr=d.slaState==='MET'?'#2f9e44':(d.slaState==='BREACHED'?'#e03131':(d.slaState==='AT_RISK'?'#f08c00':'#69748c'));var sla='<div class="card"><h2>First-contact SLA</h2><p class="desc">5-hr commitment from creation (SOP §11).</p>'+'<div style="font-size:26px;font-weight:800;color:'+slaClr+'">'+esc(d.slaState||'NA')+'</div>'+'<div class="kv" style="margin-top:10px">'+kvRow('Due by', d.firstContactDue?fmtDT(d.firstContactDue):'—')+kvRow('First contact', d.firstContactedAt?fmtDT(d.firstContactedAt):'<span style="color:#e8590c">not yet</span>')+'</div></div>';// trailvar trail='<div class="card"><h2>Trail <span style="color:#97a1b5;font-weight:500;font-size:12px">· append-only</span></h2>';if(!d.trail||!d.trail.length){ trail+='<div class="ph">No activity yet.</div>'; }else { trail+='<div class="trail" id="trailScroll">'; d.trail.forEach(function(t){trail+='<div class="tevt '+trailType(t.type)+'"><div class="tdot">●</div>'+'<div class="tt">'+(t.type?esc(t.type):'Update')+'</div>'+'<div class="tm">'+esc(t.actor||'System')+' · '+fmtDT(t.when)+(t.scheduled?(' · scheduled '+fmtDT(t.scheduled)):'')+'</div>'+(t.body?('<div class="td">'+esc(t.body)+'</div>'):'');// The recording of the call this entry came from, so the agent's account of the conversation// and the conversation itself sit together instead of in two separate lists.if(t.callId){trail+='<div class="trec"><audio controls preload="none" src="'+CTX+'/lms/recording/'+t.callId+'"></audio>'+(t.durationSeconds?('<span class="note">'+t.durationSeconds+'s</span>'):'')+(t.archived?'':'<span class="note">archiving…</span>')+'</div>';}trail+='</div>'; }); trail+='</div>'; }trail+='</div>';// ---- Click-to-call (SOP §12). VBC places the call server-side: our own device rings first,// then the retailer. A blocked number never gets a button at all — the refusal belongs in// front of the agent, not in the response to a dial they already started.var call='<div class="card"><h2>Click-to-call</h2>';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>'; }else if(!d.contact){ call+='<p class="desc">No contact number on this lead.</p>'; }else { call+='<p class="desc">Bridged call · disposition required on disconnect.</p>'+'<button class="btn coral" id="callBtn" onclick="startCall()">☎ Call retailer (C2C)</button>'+'<div id="callBar" class="hide" style="margin-top:12px;padding:12px;border:1px solid #e6e9f2;border-radius:10px;background:#f8f9fc">'+'<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">'+'<span id="callState" style="font-weight:700;color:#1f2a5e">Starting…</span>'+'<span id="callTimer" class="mono" style="color:#69748c">00:00</span></div>'+'<div style="margin-top:10px;display:flex;gap:8px">'+'<button class="btn ghost sm" onclick="endCall()">Hang up</button>'+'<button class="btn ghost sm" onclick="openDisp()">Disposition…</button></div>'+'<div id="callErr" class="hide" style="margin-top:8px;color:#e03131;font-size:12px"></div></div>'; }call+='<div class="note" style="margin-top:10px">A call is only worth what its disposition says — log one on every disconnect.</div></div>';// ---- Beat plan. Read-only on purpose: adding a stop belongs to the beat planner, and the// gate that matters (an approved geo-pin) is what this card exists to report.// The card used to state the geo gate and leave the agent to go and find the Leads screen.// The action that clears the gate now lives on it.var mapLink=(d.geoLat!=null&&d.geoLng!=null)? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.google.com/maps?q='+d.geoLat+','+d.geoLng+'">View on map</a>' : '';var photoLink=(d.geoPhotoId>0)? ' <a class="btn ghost sm" target="_blank" rel="noopener" href="'+CTX+'/open-attachment?documentId='+d.geoPhotoId+'">Store photo</a>' : '';var beat='<div class="card"><h2>Beat plan</h2>'+(d.geoApproved? '<p class="desc"><span class="stage st-visited">Geo verified</span> — this lead can be added to a beat.</p>': '<p class="desc" style="color:#a9720a">Geo-pin not yet approved'+(d.geoStatus?(' ('+esc(d.geoStatus.toLowerCase())+')'):'')+'. Verify the location before adding this lead to a beat.</p>')+'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">'+'<button class="btn ghost sm" onclick="openGeo()">'+(d.geoApproved?'Re-verify location':'Verify manually')+'</button>'+mapLink+photoLink+'</div>'+'<div class="note">Check-in at the retailer moves the stage to Visited.</div></div>';// ---- Advance stage. Forward-only and comment-mandatory, same rules the endpoint enforces.var adv='';if(!d.terminal){var opts='<option value="" selected disabled>Move to…</option>';STEPS.forEach(function(st,i){ if(i>idx) opts+='<option value="'+st[0]+'">'+esc(st[1])+'</option>'; });['NOT_INTERESTED','DROPPED'].forEach(function(t){ opts+='<option value="'+t+'">'+t.replace('_',' ')+'</option>'; });adv='<div class="card"><h2>Advance stage</h2><p class="desc">Forward-only. The comment becomes the trail entry.</p>'+'<select id="advStage" class="inp" style="width:100%;margin-bottom:8px">'+opts+'</select>'+'<input id="advComment" class="inp" style="width:100%;margin-bottom:10px" placeholder="Comment (required)">'+'<button class="btn" style="background:#1f2a5e;color:#fff" onclick="advance()">Advance</button></div>';} else {adv='<div class="card"><h2>Advance stage</h2><p class="desc">'+esc(d.stageLabel)+' is terminal — this lead cannot be moved on.</p></div>';}byId('recordHost').innerHTML=head+step+'<div class="grid g3"><div>'+details+trail+'</div><div>'+sla+call+beat+adv+'</div></div>';capTrail();}// ====================== RECORD ACTIONS (edit · call · disposition · stage) =================// Every control here drives the SAME endpoints the in-app record uses, so the two screens cannot// drift into disagreeing about what a disposition or a stage move means.var REC = null; // the record currently rendered, for the action panelsvar CALL = { id:null, poll:null, tick:null, startedAt:0 };/** POST form-encoded (advanceStage / dialer take @RequestParam, not a JSON body). */function postForm(path, params, cb, onErr){var body = Object.keys(params).map(function(k){return encodeURIComponent(k)+'='+encodeURIComponent(params[k]==null?'':params[k]); }).join('&');fetch(CTX+path,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},body:body}).then(function(r){ return r.text(); }).then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}var res=(d&&d.response!==undefined)?d.response:d; cb(res); }).catch(function(e){ console.error('LMS post failed',path,e);if(typeof onErr==='function') onErr(e); else toast('Request failed'); });}/** POST JSON (disposition / updateLead take @RequestBody). */function postJSON(path, payload, cb, onErr){fetch(CTX+path,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify(payload)}).then(function(r){ return r.text(); }).then(function(t){ var d=null; try{ d=JSON.parse(t); }catch(e){}var res=(d&&d.response!==undefined)?d.response:d; cb(res); }).catch(function(e){ console.error('LMS post failed',path,e);if(typeof onErr==='function') onErr(e); else toast('Request failed'); });}/** A refusal comes back as a bare string under `response`; success is an object. */function refusal(res){ return (typeof res==='string' && res) ? res : null; }function reloadRecord(){ if(REC) injectRecord(REC.id); }// ---- stage --------------------------------------------------------------------------------function advance(){var to=byId('advStage').value, c=(byId('advComment').value||'').trim();if(!to){ toast('Choose a stage'); return; }if(!c){ toast('A comment is required'); return; }postForm('/lms/advanceStage',{leadId:REC.id,toStage:to,comment:c},function(res){var why=refusal(res); if(why){ toast(why); return; }toast('Stage advanced to '+to.replace('_',' '),1); reloadRecord(); refresh();});}// ---- edit ---------------------------------------------------------------------------------function openEdit(){if(!REC) return;var regionOpts='<option value="">— Not set —</option>';// Same region list the Create tab renders, so the two forms cannot offer different regions.byId('cRegion').querySelectorAll('option').forEach(function(o){if(!o.value) return;regionOpts+='<option value="'+esc(o.value)+'"'+(String(REC.regionId)===String(o.value)?' selected':'')+'>'+esc(o.textContent)+'</option>';});byId('editBody').innerHTML='<div class="kv" style="margin-bottom:14px">'+kvRow('LMS ID','<span class="mono">'+esc(REC.lmsCode||('#'+REC.id))+'</span> <span class="note" style="display:inline">locked</span>')+kvRow('Created', fmtDT(REC.created)+' <span class="note" style="display:inline">locked</span>')+'</div>'+'<div class="grid g2" style="gap:12px">'+fld('eName','Retailer name (required)',REC.name)+fld('eBusiness','Business name',REC.outlet)+fld('eContact','Contact number (required)',REC.contact)+fld('eValue','Business value (₹ lakh / month)',(REC.value>0?Math.round(toLakh(REC.value)*10)/10:''))+'</div>'+fld('eAddress','Address',REC.address)+'<div class="grid g2" style="gap:12px">'+fld('eCity','City',REC.city)+fld('eState','State',REC.state)+'</div>'+'<label class="lbl">Region</label><select id="eRegion" class="inp" style="width:100%">'+regionOpts+'</select>'+'<div class="note">Changing the region re-runs auto-assignment. A lead already mapped to a named owner keeps that owner.</div>';byId('editModal').classList.remove('hide');}function fld(id,label,val){return '<div><label class="lbl">'+label+'</label><input id="'+id+'" class="inp" style="width:100%" value="'+esc(val==null?'':val)+'"></div>';}function closeEdit(){ byId('editModal').classList.add('hide'); }function saveEdit(){var name=(byId('eName').value||'').trim();if(!name){ toast('Retailer name is required'); return; }var mob=(byId('eContact').value||'').replace(/\D/g,'');if(mob.length<10){ toast('A valid 10-digit contact number is required'); return; }var v=parseFloat(byId('eValue').value), r=byId('eRegion').value;postJSON('/lms/updateLead',{leadId:REC.id,retailerName:name,businessName:byId('eBusiness').value,mobile:mob,address:byId('eAddress').value,city:byId('eCity').value,state:byId('eState').value,regionId:r?parseInt(r,10):null,potential:isNaN(v)?null:v},function(res){var why=refusal(res); if(why){ toast(why); return; }closeEdit(); toast('Record updated · '+(res&&res.changes||0)+' change(s) logged',1); reloadRecord();});}// ---- call ---------------------------------------------------------------------------------function callErr(msg){ var e=byId('callErr'); if(!e) return; e.textContent=msg; e.classList.remove('hide'); }function callState(t){ var e=byId('callState'); if(e) e.textContent=t; }function startCall(){if(!REC || CALL.id) return;byId('callBtn').disabled=true;// Pre-check server-side: DND and the 09:00-21:00 calling window. A check that only lives in// the browser is not a check, which is why this endpoint exists at all.postForm('/lms/dialer/precheck',{leadId:REC.id},function(res){var why=refusal(res);if(why || !res || res.ok!==true){ byId('callBtn').disabled=false; toast(why||(res&&res.reason)||'Cannot place the call'); return; }byId('callBar').classList.remove('hide'); callState('Ringing your device…');postForm('/lms/dialer/dial',{leadId:REC.id},function(r2){var w2=refusal(r2);if(w2 || !r2 || r2.ok!==true){ byId('callBtn').disabled=false; byId('callBar').classList.add('hide');toast(w2||'Could not place the call'); return; }CALL.id=r2.leadCallId; CALL.startedAt=Date.now();CALL.tick=setInterval(function(){ var s=Math.floor((Date.now()-CALL.startedAt)/1000);byId('callTimer').textContent=('0'+Math.floor(s/60)).slice(-2)+':'+('0'+(s%60)).slice(-2); },1000);// click2dial has no event webhook, so the browser has to ask.CALL.poll=setInterval(pollCall,3000);});});}function pollCall(){if(!CALL.id) return;getJSON('/lms/dialer/state?leadCallId='+CALL.id,function(st){if(!st) return;if(st.status) callState(String(st.status).replace(/_/g,' ').toLowerCase());if(st.terminal){ stopCall(); callState('Call ended'); openDisp(); }},function(){ /* a dropped poll is not a dropped call — keep waiting */ });}function stopCall(){ if(CALL.poll) clearInterval(CALL.poll); if(CALL.tick) clearInterval(CALL.tick);CALL.poll=CALL.tick=null; var b=byId('callBtn'); if(b) b.disabled=false; }function endCall(){if(!CALL.id){ stopCall(); return; }postForm('/lms/dialer/hangup',{leadCallId:CALL.id},function(){ stopCall(); callState('Call ended'); openDisp(); });}// ---- disposition --------------------------------------------------------------------------var DISPS=[['INTERESTED','Interested'],['CALLBACK','Callback'],['NOT_REACHABLE','Not reachable'],['WRONG_NUMBER','Wrong number'],['FOLLOW_UP','Follow-up'],['NOT_INTERESTED','Not interested'],['DO_NOT_CALL','Do not call']];var DISP=null;function openDisp(){if(!REC) return;DISP=null;byId('dispGrid').innerHTML=DISPS.map(function(d){return '<button class="btn ghost sm dispopt" data-d="'+d[0]+'" onclick="pickDisp(\''+d[0]+'\')">'+d[1]+'</button>'; }).join('');byId('dispExtra').innerHTML=''; byId('dispNote').value='';byId('dispModal').classList.remove('hide');}function closeDisp(){ byId('dispModal').classList.add('hide'); }function pickDisp(d){DISP=d;document.querySelectorAll('.dispopt').forEach(function(b){b.className='btn '+(b.getAttribute('data-d')===d?'coral':'ghost')+' sm dispopt'; });// Contextual sub-fields, mirroring SOP §12.2 and the in-app modal.var x='';if(d==='INTERESTED') x=fld('dxValue','Business value (₹ lakh / month) — required','');else if(d==='CALLBACK') x='<label class="lbl">Callback date & time — required</label><input id="dxCallback" type="datetime-local" class="inp" style="width:100%">';else if(d==='NOT_REACHABLE')x=fld('dxRetry','Retry schedule (optional)','');else if(d==='WRONG_NUMBER') x=fld('dxCorrected','Corrected number (blank closes the lead)','');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>'+'<label class="lbl">Follow-up date & time — required</label><input id="dxFollowUp" type="datetime-local" class="inp" style="width:100%">';else if(d==='NOT_INTERESTED')x='<label class="lbl">Sub-reason</label><select id="dxSub" class="inp" style="width:100%">'+['Price','Terms','Already partnered','Category mismatch','Other'].map(function(o){return '<option>'+o+'</option>';}).join('')+'</select>';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>';byId('dispExtra').innerHTML=x;}function saveDisp(){if(!DISP){ toast('Pick a disposition'); return; }var p={leadId:REC.id,disposition:DISP,note:byId('dispNote').value,leadCallId:CALL.id||null};if(DISP==='INTERESTED'){ var v=parseFloat(byId('dxValue').value);if(!v||v<=0){ toast('Business value is required'); return; } p.value=v; }else if(DISP==='CALLBACK'){ if(!byId('dxCallback').value){ toast('Callback date/time is required'); return; }p.callbackAt=byId('dxCallback').value; }else if(DISP==='NOT_REACHABLE') p.retrySchedule=byId('dxRetry').value;else if(DISP==='WRONG_NUMBER') p.correctedNumber=byId('dxCorrected').value;else if(DISP==='FOLLOW_UP'){ if(!byId('dxFollowUp').value){ toast('Follow-up date/time is required'); return; }p.followUpType=byId('dxFuType').value; p.followUpAt=byId('dxFollowUp').value; }else if(DISP==='NOT_INTERESTED') p.subReason=byId('dxSub').value;postJSON('/lms/disposition',p,function(res){var why=refusal(res); if(why){ toast(why); return; }closeDisp(); CALL.id=null; toast('Disposition saved: '+DISP.replace(/_/g,' '),1); reloadRecord(); refresh();});}// ---- manual geo verification ---------------------------------------------------------------function geoErr(msg){ var e=byId('geoErr'); e.textContent=msg; e.classList.remove('hide'); }function geoClearErr(){ byId('geoErr').classList.add('hide'); }function openGeo(){if(!REC) return;geoClearErr();// Seed from what the record already knows, so a nudge is an edit rather than a re-entry.byId('geoAddress').value=[REC.address,REC.city,REC.state].filter(function(x){return x;}).join(', ');byId('geoLatLng').value=(REC.geoLat!=null&&REC.geoLng!=null)?(REC.geoLat+','+REC.geoLng):'';byId('geoRemark').value='';byId('geoPhoto').value='';byId('geoPhotoImg').classList.add('hide');byId('geoMapWrap').classList.add('hide');byId('geoModal').classList.remove('hide');if(REC.geoLat!=null&&REC.geoLng!=null) geoShowMap(REC.geoLat,REC.geoLng);}function closeGeo(){ byId('geoModal').classList.add('hide'); }/** Accepts "lat,lng" or a Maps URL: /@lat,lng, ?q=lat,lng, ?ll=, ?destination=. */function parseLatLng(raw){if(!raw) return null;var m=String(raw).match(/(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)/);return m?{lat:parseFloat(m[1]),lng:parseFloat(m[2])}:null;}function geoShowMap(lat,lng){byId('geoMapFrame').src='https://www.google.com/maps?q='+lat+','+lng+'&z=17&output=embed';byId('geoOpenMap').href='https://www.google.com/maps?q='+lat+','+lng;byId('geoMapWrap').classList.remove('hide');}function geoPreview(){var p=parseLatLng(byId('geoLatLng').value);if(!p){ geoErr('Enter latitude,longitude first — like 28.6139,77.2090.'); return; }geoClearErr(); geoShowMap(p.lat,p.lng);}function geoGeocode(){var a=(byId('geoAddress').value||'').trim();if(!a){ geoErr('Enter an address first.'); return; }geoClearErr();var b=byId('geoFindBtn'); b.disabled=true; b.textContent='Finding…';postForm('/lead-geo/geocode-address',{address:a},function(r){b.disabled=false; b.textContent='Find on map';if(!r||!r.success){ geoErr((r&&r.message)||'Could not geocode that address.'); return; }byId('geoLatLng').value=r.latitude+','+r.longitude;geoShowMap(r.latitude,r.longitude);},function(){ b.disabled=false; b.textContent='Find on map'; geoErr('Geocode request failed.'); });}function geoPickPhoto(){var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];if(!f){ byId('geoPhotoImg').classList.add('hide'); return; }var fr=new FileReader();fr.onload=function(ev){ byId('geoPhotoImg').src=ev.target.result; byId('geoPhotoImg').classList.remove('hide'); };fr.readAsDataURL(f);}function geoSave(){var p=parseLatLng(byId('geoLatLng').value);if(!p){ geoErr('Could not read latitude/longitude. Paste like 28.6139,77.2090.'); return; }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; }var f=byId('geoPhoto').files&&byId('geoPhoto').files[0];if(!f){ geoErr('Attach a store-board photo — the server requires one.'); return; }geoClearErr();var b=byId('geoSaveBtn'); b.disabled=true; b.textContent='Uploading photo…';// Upload first: manual-verify needs the document id, and uploading only on save means a// cancelled dialog never orphans a document.var fd=new FormData(); fd.append('file',f,f.name||'store-photo.jpg');fetch(CTX+'/lead-geo/upload-image',{method:'POST',credentials:'same-origin',headers:{'X-Requested-With':'XMLHttpRequest'},body:fd}).then(function(r){ return r.text(); }).then(function(t){var d=null; try{ d=JSON.parse(t); }catch(e){}var r=(d&&d.response!==undefined)?d.response:d;var docId=r&&(r.document_id||r.documentId||r.id);if(!docId){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); return; }b.textContent='Verifying…';postForm('/lead-geo/manual-verify',{leadId:REC.id,latitude:p.lat,longitude:p.lng,remark:byId('geoRemark').value,imageDocumentId:docId},function(res){b.disabled=false; b.textContent='Save & verify';var why=refusal(res);if(why){ geoErr(why); return; }closeGeo(); toast('Location verified — this lead can now go on a beat',1); reloadRecord();},function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Could not save the verification.'); });}).catch(function(){ b.disabled=false; b.textContent='Save & verify'; geoErr('Photo upload failed. Try again.'); });}// ====================== CREATE LEAD (real /lms/resolve-region + /createLead) ======================var CREATE_PATH='A';function setPath(p,btn){ CREATE_PATH=p;byId('pathA').className='btn '+(p==='A'?'coral':'ghost')+' sm'; byId('pathB').className='btn '+(p==='B'?'coral':'ghost')+' sm';byId('createDesc').textContent=(p==='A')?'Path A — captured by a BGC agent during an outbound call, after consent. Owner is the region BM.':'Path B — field encounter. Owner is you, the creator.';}function initials(n){ n=(n||'').trim(); if(!n) return '—'; var p=n.split(/\s+/); return ((p[0]||'')[0]||'').toUpperCase()+((p[1]||'')[0]||'').toUpperCase(); }function resolveBM(regionId){ if(!regionId){ byId('rName').textContent='Pick a region…'; byId('rRegion').textContent='—'; byId('raAv').textContent='—';byId('rEsc').textContent='The owning BM/RSM is resolved from the region (cs.position).'; return; }getJSON('/lms/resolve-region?regionId='+encodeURIComponent(regionId),function(d){ d=d||{};byId('rRegion').textContent=d.regionCode||''; var nm=d.bmName||'';if(d.assignmentStatus==='HOLD' || !nm){ byId('rName').textContent='No active BM/RSM'; byId('raAv').textContent='!';byId('raAv').style.background='#e03131'; byId('rEsc').innerHTML='Region has no active owner → <b style="color:#e03131">HOLD queue</b>, Sales Head notified.'; }else { byId('rName').textContent=nm; byId('raAv').textContent=initials(nm); byId('raAv').style.background='#1f2a5e';byId('rEsc').textContent='Auto-resolved from region · escalation contact copied on the assignment email.'; } }); }// ---- brand-wise business value ------------------------------------------------------------// Agents write "1L", "50k", "1.5 lakh" as readily as digits, and rejecting those would just push// them back to typing a single lump sum. Parsed here so the stored value is always plain rupees.function parseMoney(raw){if(raw===null||raw===undefined) return 0;var t=(''+raw).trim().toLowerCase().replace(/[₹,\s]/g,'');if(!t) return 0;var m=t.match(/^([0-9]*\.?[0-9]+)(k|l|lac|lacs|lakh|lakhs|cr|crore|crores)?$/);if(!m) return 0;var n=parseFloat(m[1]); if(isNaN(n)) return 0;switch(m[2]){case 'k': return Math.round(n*1000);case 'l': case 'lac': case 'lacs': case 'lakh': case 'lakhs': return Math.round(n*100000);case 'cr': case 'crore': case 'crores': return Math.round(n*10000000);default: return Math.round(n);}}function collectBrands(){var out=[];document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){var v=parseMoney(el.value);if(v>0) out.push({brand:el.getAttribute('data-brand'), value:v});});return out;}function brandTotal(){var t=0; collectBrands().forEach(function(b){ t+=b.value; });var el=byId('cValueTotal');if(el) el.textContent='total '+(t>0?fmtMoney(t):'₹0');return t;}function fmtMoney(n){if(n>=10000000) return '₹'+(n/10000000).toFixed(n%10000000?2:0)+'Cr';if(n>=100000) return '₹'+(n/100000).toFixed(n%100000?2:0)+'L';if(n>=1000) return '₹'+(n/1000).toFixed(n%1000?1:0)+'K';return '₹'+n;}function val(id){ var e=byId(id); return e?e.value:''; }function submitLead(){ var fn=val('cRetailer').trim(), biz=val('cBusiness').trim();var phone=(val('cContact')||'').replace(/\D/g,'').slice(-10);var brands=collectBrands(); var value=brandTotal();var region=val('cRegion'); var path=CREATE_PATH;if(!fn){ toast('Retailer name is required'); return; }if(phone.length!==10){ toast('A valid 10-digit contact is required'); return; }if(!region){ toast('Pick a region'); return; }// No address check on either path any more: the capture link is what establishes location,// and it is generated for every lead regardless of path.var payload={ firstName:fn, mobile:phone, outletName:biz||'', source:'LMS Dashboard',regionId:parseInt(region,10), creationPath:path };// potential is the sum, so the funnel's value column keeps working, while the per-brand split// is what actually tells a BM what the counter is worth.if(value && value>0) payload.potential=value;if(brands.length) payload.leadBrands=brands;// /lms/createLead, NOT the shared /createLead: this one honours regionId and creationPath, so// the lead actually gets a stage, an LMS code, an owner and an SLA clock.fetch(CTX+'/lms/createLead',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify(payload)}).then(function(r){ return r.text(); }).then(function(t){var d=null; try{ d=JSON.parse(t); }catch(e){ d=null; }var res=(d&&d.response)?d.response:d;if(!res || res.ok!==true){// A refusal carries the reason as a bare string under `response` (badRequest("...")),// whereas success is an object. Surface the reason, not a generic failure.toast((typeof res==='string' && res) || (res&&res.message) || 'Create failed');return;}var held=(res.assignmentStatus==='HOLD');toast(held ? ('Lead '+res.lmsCode+' created · no BM for that region, parked in HOLD'): ('Lead '+res.lmsCode+' created · assigned to '+(res.ownerName||'the region BM')+' · SLA started'), 1);['cRetailer','cBusiness','cContact'].forEach(function(x){ var e=byId(x); if(e) e.value=''; });document.querySelectorAll('#cBrandGrid .bval').forEach(function(el){ el.value=''; });brandTotal();// Hand the capture link over immediately — the whole point of generating it on create is// that the agent is still on the call with the retailer.if(res.geoLink){if(navigator.clipboard && navigator.clipboard.writeText){navigator.clipboard.writeText(res.geoLink).then(function(){alert('Capture link copied to your clipboard — send it to the retailer now:\n\n'+res.geoLink);}, function(){ prompt('Send this capture link to the retailer:', res.geoLink); });} else {prompt('Send this capture link to the retailer:', res.geoLink);}}byId('recLookup').value=res.leadId; injectRecord(res.leadId);go('record',document.querySelectorAll('.tab')[1]); refresh(); }).catch(function(e){ toast('Create failed'); }); }// ====================== AUTO-ASSIGNMENT (real resolve per region) ======================function buildAssign(){ var opts=byId('cRegion').querySelectorAll('option'); var holdCount=0, assignedCount=0, pending=0;var body=byId('assignMap'); body.innerHTML='';// Rows are created up-front in region order, then filled in as each resolve lands — otherwise// the table would be ordered by whichever request happened to answer first.opts.forEach(function(o){ if(!o.value) return; pending++;var label=o.textContent;var tr=document.createElement('tr');tr.innerHTML='<td><b>'+esc(label)+'</b></td><td colspan="2"><span class="ph">Resolving…</span></td>';body.appendChild(tr);function done(){ if(--pending===0){ renderAssignKpis(assignedCount,holdCount); } }getJSON('/lms/resolve-region?regionId='+encodeURIComponent(o.value),function(d){ d=d||{};var hold=(d.assignmentStatus==='HOLD' || !d.bmName);if(hold) holdCount++; else assignedCount++;tr.innerHTML='<td><b>'+esc(label)+'</b></td>'+'<td>'+(hold?'<span class="warnflag">— vacant —</span>':esc(d.bmName))+'</td>'+'<td>'+(hold?'<span class="stage st-ni">HOLD</span>':'<span class="stage st-active">Active</span>')+'</td>';done();},function(){ tr.innerHTML='<td><b>'+esc(label)+'</b></td><td colspan="2"><span class="warnflag">could not resolve</span></td>';done(); }); });// HOLD queue leadsgetJSON('/lms/dashboard/leads?bucketType=ATTENTION&bucketKey=HOLD',function(d){ var leads=(d&&d.leads)?d.leads:[];if(!leads.length){ byId('holdQueue').innerHTML='<div class="ph">No leads in HOLD. 🎉</div>'; return; }var h=''; leads.slice(0,8).forEach(function(l){ h+='<div style="display:flex;align-items:center;gap:10px;font-size:12.5px;padding:5px 0" class="clickrow" data-leadid="'+l.id+'">'+'<span class="idpill">'+esc(l.lmsCode)+'</span> <span>'+esc(l.name||l.outlet||'—')+'</span></div>'; });byId('holdQueue').innerHTML=h; }); }function renderAssignKpis(assigned,hold){ var k=[{i:'📧',bg:'#eef1fb',fg:'#3b5bdb',l:'Regions mapped',v:assigned,f:'active BM/RSM'},{i:'⏸',bg:'#fff0f0',fg:'#e03131',l:'Regions on HOLD',v:hold,f:'no active BM',vc:'#e03131'},{i:'👤',bg:'#fff4e6',fg:'#e8590c',l:'HOLD leads',v:(DATA.summary&&attn('HOLD'))||0,f:'awaiting a BM',vc:'#e8590c'},{i:'⚡',bg:'#ebfbee',fg:'#2f9e44',l:'Resolve tiers',v:'L4→L5→L3',f:'cs.position'} ];var h=''; k.forEach(function(x){ h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+x.bg+';color:'+x.fg+'">'+x.i+'</div>'+'<div class="label">'+esc(x.l)+'</div></div><div class="val"'+(x.vc?' style="color:'+x.vc+'"':'')+'>'+(typeof x.v==='number'?fmtInt(x.v):x.v)+'</div><div class="foot">'+esc(x.f)+'</div></div>'; });byId('assignKpis').innerHTML=h; }function attn(key){ var s=DATA.summary; if(!s||!s.attention) return 0; var f=0; s.attention.forEach(function(a){ if(a.key===key)f=a.count; }); return f; }// ====================== RECORDINGS & AUDIT (live hygiene from attention) ======================function buildAudit(){// KPIs — missing dispositions is live; recording metrics are pending source.var miss=attn('MISSING_DISP'), br=attn('BREACHED_NO_CONTACT'), hold=attn('HOLD');var kp=[ {i:'🎵',bg:'#fff0ef',fg:'#f0483e',l:'Recordings (24 mo)',v:'—',f:'pending source'},{i:'💬',bg:'#fff4e6',fg:'#e8590c',l:'Missing dispositions',v:miss,f:'live',vc:'#e8590c'},{i:'🚫',bg:'#fff0f0',fg:'#e03131',l:'DND breaches',v:'—',f:'needs call log'},{i:'👁',bg:'#eef1fb',fg:'#3b5bdb',l:'Recording opens',v:'—',f:'pending source'},{i:'🧹',bg:'#e6fcf5',fg:'#0ca678',l:'Onboarded (deep audit)',v:fmtInt(onboardedCount()),f:'5% sampled'} ];var h=''; kp.forEach(function(x){ h+='<div class="kpi"><div class="top"><div class="ic" style="background:'+x.bg+';color:'+x.fg+'">'+x.i+'</div>'+'<div class="label">'+esc(x.l)+'</div></div><div class="val"'+(x.vc?' style="color:'+x.vc+'"':'')+'>'+(typeof x.v==='number'?fmtInt(x.v):x.v)+'</div><div class="foot">'+esc(x.f)+'</div></div>'; });byId('auditKpis').innerHTML=h;// hygiene flags (clickable via data-att)var flags=[ {k:'MISSING_DISP',i:'💬',bg:'#fff4e6',fg:'#e8590c',t:'LMS with missing dispositions',d:'Next action stays locked until dispositioned',c:'warnflag',v:miss},{k:'BREACHED_NO_CONTACT',i:'⚠',bg:'#fff0f0',fg:'#e03131',t:'SLA breaches — first contact',d:'Escalated to BM/RSM per matrix',c:'badflag',v:br},{k:'HOLD',i:'👤',bg:'#fff0f0',fg:'#e03131',t:'Unassigned records (HOLD)',d:'Region awaiting a BM',c:'badflag',v:hold} ];var fh=''; flags.forEach(function(a){ fh+='<div class="att" data-att="'+a.k+'" data-attt="'+esc(a.t)+'"><div class="ic" style="background:'+a.bg+';color:'+a.fg+'">'+a.i+'</div>'+'<div class="ab"><div class="atitle">'+esc(a.t)+'</div><div class="adet">'+esc(a.d)+'</div></div><div class="acount '+a.c+'">'+fmtInt(a.v)+'</div></div>'; });byId('auditHygiene').innerHTML=fh; }function onboardedCount(){ var s=DATA.summary; if(!s||!s.funnel) return 0; var c=0; s.funnel.forEach(function(f){ if(f.stage==='ONBOARDED')c=f.count; }); return c; }// ---- initial paint from seeds, pipeline loads liverenderSummary(SEED.summary); renderState(SEED.state); renderHeat(SEED.heat); renderAgeing(SEED.ageing);byId('dashPeriod').textContent=(STATE.from||'')+' → '+(STATE.to||'');loadPipeline();// ---- deep-link: a URL hash such as dash or record opens that tab(function(){ var TABIDX={cmd:0,record:1,create:2,assign:3,dash:4,audit:5};var h=(location.hash||'').replace('#','').trim();if(h && TABIDX[h]!=null){ go(h, document.querySelectorAll('.tab')[TABIDX[h]]); }window.addEventListener('hashchange',function(){ var k=(location.hash||'').replace('#','').trim();if(k && TABIDX[k]!=null){ go(k, document.querySelectorAll('.tab')[TABIDX[k]]); } }); })();</script></body></html>