Coaching Radar: live downline triage in admin + member dashboards

- chain.js getCoachingScan(rootId): classifies everyone below a root into
  atRisk (POL forming they can't catch - corrected bought-level rule),
  rollForward (qualified-at-Scintilla with entry rewards covering Ascensus),
  and oneAway (1/2 directs)
- Admin: GET /api/admin/coaching?root= (name-decorated) + "Coaching Radar"
  panel with tiered who/what-to-say/POL-at-stake rows, auto-loaded
- Member dashboards: memberPublic now returns .coach scoped to the member's
  own leg; new "Coach your team" card shows the same triage so every member
  coaches their own team - computed live, no snapshots or cron needed
- Chatbot canned answer + AI system prompt updated to describe the panel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-19 05:15:45 -05:00
parent 9966dc1728
commit a835291eab
7 changed files with 99 additions and 3 deletions
+24
View File
@@ -40,6 +40,7 @@ function render(){
if(!incomeAutoLoaded&&incEl&&incEl.value){incomeAutoLoaded=true;loadIncome();}
const orgEl=document.getElementById('orgRoot');if(orgEl&&!orgEl.value)orgEl.value=state.config.orgRootId||'21';
if(!orgShareAutoLoaded&&orgEl&&orgEl.value){orgShareAutoLoaded=true;loadOrgShare();}
if(!coachAutoLoaded){coachAutoLoaded=true;loadCoaching();}
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
}
@@ -87,6 +88,29 @@ function genBreakdownHtml(gc,rootId){
return `<div style="margin-top:16px"><small style="text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-size:11px">Depth — members per generation below #${rootId}</small><div style="margin-top:8px">${rows}</div></div>`;
}
document.getElementById('orgShareForm').addEventListener('submit',e=>{e.preventDefault();loadOrgShare()});
async function loadCoaching(){
const out=document.getElementById('coachOut');if(!out)return;
out.innerHTML='<div class="empty">Scanning the org…</div>';
try{
const root=(document.getElementById('orgRoot')&&document.getElementById('orgRoot').value.trim())||(state&&state.config&&state.config.orgRootId)||'21';
const d=await api('/api/admin/coaching?root='+encodeURIComponent(root));
if(!d.ready){out.innerHTML='<div class="empty">Chain snapshot not ready — try again in a minute.</div>';return}
const nm=r=>r.name?`${esc(r.name)} (#${r.id})`:`#${r.id}`;
const link=r=>`<a href="/my/${r.id}" target="_blank" rel="noopener" style="color:var(--teal)">${nm(r)}</a>`;
const t1=d.rollForward.map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">💬</div><div class="pp-body">${link(r)} — qualified, still Scintilla, sitting on <strong>${fmt(r.earnedPol)} POL</strong> (Ascensus costs ${fmt(r.ascensusCost)}). One message: “your entry rewards already cover the upgrade that catches your team's first payments.”</div></div>`).join('')||'<div class="empty">Nobody stuck at qualified-Scintilla. 🎉</div>';
const t2=d.atRisk.filter(r=>r.qualified&&r.level>1).map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">⚠️</div><div class="pp-body">${link(r)} — ${esc(r.levelName)}, needs <strong>${esc(r.neededLevelName)}</strong> to catch <strong style="color:var(--gold)">${fmt(r.atRiskPol)} POL</strong> forming under them (from ${r.fromIds.map(f=>'#'+f).join(', ')}).</div></div>`).join('')||'<div class="empty">Everyone leveled is ahead of their wave. 🎉</div>';
const t3=d.oneAway.map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">🎯</div><div class="pp-body">${link(r)} — 1/2 directs, one placement from qualifying.</div></div>`).join('')||'<div class="empty">Nobody stranded at 1/2.</div>';
const unq=d.atRisk.filter(r=>!r.qualified).map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">⏰</div><div class="pp-body">${link(r)} — NOT qualified (${r.directCount}/2) with <strong style="color:var(--gold)">${fmt(r.atRiskPol)} POL</strong> already forming below. Qualification is urgent for them.</div></div>`).join('');
out.innerHTML=
`<div class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px"><div class="fact"><small>Members scanned</small><strong>${d.scanned}</strong></div><div class="fact"><small>POL at risk</small><strong style="color:var(--gold)">${fmt(d.totals.atRiskPol)}</strong></div><div class="fact"><small>Easy wins</small><strong>${d.totals.rollForwardCount}</strong></div><div class="fact"><small>One direct away</small><strong>${d.totals.oneAwayCount}</strong></div></div>`+
`<h3 style="margin:12px 0 6px;font-size:14px">Tier 1 · Easy wins — one message each</h3>${t1}`+
`<h3 style="margin:16px 0 6px;font-size:14px">Tier 2 · Money at risk — coach the upgrade</h3>${t2}`+
(unq?`<h3 style="margin:16px 0 6px;font-size:14px">Tier 2b · Money forming, not yet qualified</h3>${unq}`:'')+
`<h3 style="margin:16px 0 6px;font-size:14px">Tier 3 · One placement from qualified</h3>${t3}`;
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
}
const cr=document.getElementById('coachRefresh');if(cr)cr.addEventListener('click',loadCoaching);
let coachAutoLoaded=false;
let incomeAutoLoaded=false;
async function loadIncome(){
const ids=(document.getElementById('incomeIds').value||'').trim();