Files
sap-erp/public/board-dashboard.html
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

504 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Board Dashboard — MITRA ERP</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
:root{
--bg:#f9fafb;--surface:#ffffff;--border:#e5e7eb;
--text:#111827;--text2:#374151;--text3:#6b7280;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;
/* Validated data-viz palette (light surface #ffffff) */
--series-1:#2a78d6; /* Net Sales */
--series-2:#eb6834; /* Purchases */
--good:#008300;--bad:#c81e1e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:26px 30px 50px;max-width:1240px;margin:0 auto}
.hero{background:linear-gradient(135deg,#1e3a8a 0%,#2563eb 55%,#4f46e5 100%);border-radius:16px;padding:24px 28px;color:#fff;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;box-shadow:0 4px 24px rgba(37,99,235,.22);margin-bottom:22px;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-50px;right:-50px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.07)}
.hero-eyebrow{font-size:10px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:4px}
.hero-title{font-size:24px;font-weight:800}
.hero-sub{font-size:12.5px;color:rgba(255,255,255,.75);margin-top:4px}
.hero-right{font-size:11.5px;color:rgba(255,255,255,.8);text-align:right;z-index:1}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-bottom:22px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.kpi-l{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.7px;color:var(--text3);margin-bottom:8px}
.kpi-v{font-family:var(--mono);font-size:21px;font-weight:600;color:var(--text);line-height:1.1}
.kpi-s{font-size:11px;color:var(--text3);margin-top:6px}
.kpi-delta{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;border-radius:6px;padding:2px 8px;margin-top:8px}
.kpi-delta.up{color:var(--good);background:rgba(0,131,0,.08)}
.kpi-delta.down{color:var(--bad);background:rgba(200,30,30,.08)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;margin-bottom:18px;overflow:hidden}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.card-title{font-size:13.5px;font-weight:700}
.card-sub{font-size:11px;color:var(--text3);margin-top:2px}
.card-bd{padding:16px 18px}
.legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.leg{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--text2);font-weight:600}
.leg .sw{width:10px;height:10px;border-radius:3px}
.leg.ch{cursor:pointer;user-select:none;padding:3px 9px;border-radius:999px;border:1px solid var(--border);transition:all .12s}
.leg.ch:hover{border-color:#c7cdd6}
.leg.ch.off{opacity:.38}
.leg.ch.off .sw{background:#cbd5e1!important}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
/* breakdown filter bar — one row above the charts it controls */
.fbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px 14px;margin-bottom:16px}
.fbar-l{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-right:4px}
.fchip{padding:5px 13px;border-radius:999px;font-size:11.5px;font-weight:700;cursor:pointer;border:1px solid var(--border);background:#fff;color:var(--text2);font-family:var(--font);transition:all .12s}
.fchip:hover{border-color:#c7cdd6}
.fchip.on{background:var(--series-1);color:#fff;border-color:transparent}
.fbar input[type=date]{padding:5px 8px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text2)}
.fbar .sep{width:1px;height:22px;background:var(--border);margin:0 4px}
.fapply{padding:6px 16px;border-radius:8px;border:none;background:var(--series-1);color:#fff;font-size:11.5px;font-weight:700;font-family:var(--font);cursor:pointer}
.fapply:hover{opacity:.9}
/* product-group multi-select */
.gsel{position:relative}
.gsel-btn{padding:6px 12px;border-radius:8px;border:1px solid var(--border);background:#fff;color:var(--text2);font-family:var(--font);font-size:11.5px;font-weight:600;cursor:pointer}
.gsel-btn b{color:var(--series-1)}
.gsel-pop{display:none;position:absolute;top:calc(100% + 6px);right:0;z-index:50;background:#fff;border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(15,23,42,.16);padding:10px;width:270px}
.gsel-pop.show{display:block}
.gsel-search{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:12px;color:var(--text);margin-bottom:8px;outline:none}
.gsel-search:focus{border-color:var(--series-1);box-shadow:0 0 0 3px rgba(42,120,214,.12)}
.gsel-tools{display:flex;gap:6px;margin-bottom:8px}
.gsel-tools button{flex:1;padding:4px;border-radius:6px;border:1px solid var(--border);background:#fff;font-family:var(--font);font-size:10.5px;font-weight:700;color:var(--text3);cursor:pointer}
.gsel-tools button:hover{color:var(--text2);border-color:#c7cdd6}
.gsel-list{max-height:220px;overflow:auto;display:flex;flex-direction:column;gap:2px}
.gsel-list label{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:5px 6px;border-radius:6px;cursor:pointer}
.gsel-list label:hover{background:#f8fafc}
.gsel-list input{accent-color:var(--series-1)}
/* per-card search boxes */
.csearch{padding:6px 10px;border:1px solid var(--border);border-radius:8px;font-family:var(--font);font-size:11.5px;color:var(--text);width:180px;outline:none}
.csearch:focus{border-color:var(--series-1);box-shadow:0 0 0 3px rgba(42,120,214,.12)}
/* horizontal bars (HTML) */
.hbar-row{display:grid;grid-template-columns:minmax(120px,220px) 1fr 92px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid #f3f4f6}
.hbar-row:last-child{border-bottom:none}
.hbar-name{font-size:11.5px;color:var(--text2);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hbar-track{height:14px;position:relative}
.hbar-fill{height:100%;border-radius:0 4px 4px 0;background:var(--series-1);min-width:2px;transition:opacity .12s}
.hbar-row:hover .hbar-fill{opacity:.8}
.hbar-val{font-family:var(--mono);font-size:11px;color:var(--text);text-align:right}
/* chart */
.chart-wrap{position:relative}
.chart-wrap svg{display:block;width:100%;height:auto}
.tip{position:absolute;pointer-events:none;background:#111827;color:#fff;font-size:11px;line-height:1.5;padding:7px 10px;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.25);opacity:0;transition:opacity .08s;white-space:nowrap;z-index:20;transform:translate(-50%,-110%)}
.tip b{font-family:var(--mono);font-weight:600}
.tip .t-row{display:flex;align-items:center;gap:6px}
.tip .sw{width:8px;height:8px;border-radius:2px;display:inline-block}
details.tbl{border-top:1px solid var(--border)}
details.tbl summary{padding:9px 18px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;user-select:none}
details.tbl summary:hover{color:var(--text2)}
details.tbl table{width:100%;border-collapse:collapse;font-size:11.5px}
details.tbl th{text-align:left;padding:6px 18px;color:var(--text3);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:1px solid var(--border)}
details.tbl td{padding:5px 18px;border-top:1px solid #f3f4f6;font-family:var(--mono);color:var(--text2)}
details.tbl td:first-child{font-family:var(--font)}
.loading{padding:60px;text-align:center;color:var(--text3);font-size:13px}
.err-note{padding:40px;text-align:center;color:var(--bad);font-size:13px}
</style>
</head>
<body>
<main id="main"><div class="loading">Loading company analytics…</div></main>
<script>
const _tok=sessionStorage.getItem('portal_token');
let _user=null;try{_user=JSON.parse(sessionStorage.getItem('portal_user')||'null');}catch(_e){}
const MONTHS=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
// Indian-style currency: ₹ in Crores / Lakhs for board-level reading.
function inr(v){
const n=Number(v)||0,a=Math.abs(n),sign=n<0?'-':'';
if(a>=1e7)return sign+'₹'+(a/1e7).toFixed(2)+' Cr';
if(a>=1e5)return sign+'₹'+(a/1e5).toFixed(2)+' L';
return sign+'₹'+Math.round(a).toLocaleString('en-IN');
}
function inrFull(v){return '₹'+Math.round(Number(v)||0).toLocaleString('en-IN');}
let _allGroups=[]; // [{code,name}] from /api/board/groups — already admin-restricted if configured
let _groupsTotal=0; // how many groups the company actually has (may be > _allGroups.length if admin-restricted)
let _selGroups=new Set(); // selected ItmsGrpCod values — empty = All (of whatever _allGroups allows)
// "All" is ambiguous when an admin restriction is active — it means "every
// group you're ALLOWED to see", not "every group the company has". Spell
// that out so it's never mistaken for a genuinely unrestricted view.
function allLabel(){ return (_groupsTotal>_allGroups.length) ? `All (${_allGroups.length} of ${_groupsTotal})` : 'All'; }
async function load(){
const main=document.getElementById('main');
let d;
try{
const [r,gr]=await Promise.all([
fetch('/api/board/summary',{headers:{Authorization:'Bearer '+_tok}}),
fetch('/api/board/groups',{headers:{Authorization:'Bearer '+_tok}}),
]);
const j=await r.json();
if(r.status===403){main.innerHTML='<div class="err-note">This dashboard is for Board Members. Your role does not have access.</div>';return;}
if(!j.success)throw new Error(j.message||'Failed');
d=j.data;
try{const gj=await gr.json();if(gj.success){_allGroups=gj.data||[];_groupsTotal=gj.total||_allGroups.length;}}catch(_e){}
}catch(e){main.innerHTML='<div class="err-note">Could not load analytics: '+esc(e.message)+'</div>';return;}
render(d);
}
let _staticKpi=null; // as-of-today balances from /summary (AR/AP) — not range-dependent
function render(d){
_staticKpi=d.kpi;
const now=new Date();
document.getElementById('main').innerHTML=`
<div class="hero">
<div style="z-index:1">
<div class="hero-eyebrow">Board of Directors</div>
<div class="hero-title">Company Growth Dashboard</div>
<div class="hero-sub">Net sales, purchases &amp; customer performance — live from SAP Business One</div>
</div>
<div class="hero-right">As of ${now.toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'})}<br/>${esc(_user?.name||_user?.username||'')}</div>
</div>
<div class="kpis" id="kpis"><div class="loading" style="padding:20px;grid-column:1/-1">Loading…</div></div>
<div class="fbar">
<span class="fbar-l">Date range</span>
<button class="fchip" data-preset="3">3M</button>
<button class="fchip" data-preset="6">6M</button>
<button class="fchip on" data-preset="12">12M</button>
<button class="fchip" data-preset="24">24M</button>
<button class="fchip" data-preset="ytd">YTD</button>
<span class="sep"></span>
<input type="date" id="f-from"/> <span style="color:var(--text3);font-size:11px">to</span> <input type="date" id="f-to"/>
<button class="fapply" id="f-apply">Apply</button>
<span class="sep"></span>
<div class="gsel">
<button class="gsel-btn" id="g-btn">Product Groups: <b id="g-lbl">${allLabel()}</b> <span style="font-size:9px">▾</span></button>
<div class="gsel-pop" id="g-pop">
<input class="gsel-search" id="g-search" placeholder="Search product groups…" autocomplete="off"/>
<div class="gsel-tools"><button type="button" id="g-all">Select all</button><button type="button" id="g-none">Clear</button></div>
<div class="gsel-list" id="g-list"></div>
<div style="font-size:10px;color:var(--text3);padding:6px 2px 0;text-align:center">Ticking a group filters the data immediately</div>
</div>
</div>
</div>
<div class="card">
<div class="card-hd">
<div><div class="card-title">Net Sales vs Purchases</div><div class="card-sub" id="trend-sub">Monthly · net sales = invoices − credit memos · excl. GST</div></div>
<div class="legend">
<span class="leg"><span class="sw" style="background:var(--series-1)"></span>Net Sales</span>
<span class="leg"><span class="sw" style="background:var(--series-2)"></span>Purchases</span>
</div>
</div>
<div class="card-bd"><div class="chart-wrap" id="line-wrap"><div class="loading">Loading trend…</div></div></div>
<details class="tbl"><summary>View data table</summary><div style="max-height:260px;overflow:auto"><table><thead><tr><th>Month</th><th>Net Sales</th><th>Purchases</th></tr></thead><tbody id="trend-tbody">
</tbody></table></div></details>
</div>
<div id="breakdown"><div class="loading">Loading breakdown…</div></div>`;
wireFilters(); // draws the trend + breakdown for the default range
}
// ── Breakdown filters (customers / products / product groups) ────────────
function rangeDates(preset){
const now=new Date(),to=now.toISOString().slice(0,10);
let from;
if(preset==='ytd')from=now.getFullYear()+'-01-01';
else{const dd=new Date(now);dd.setMonth(dd.getMonth()-parseInt(preset));from=dd.toISOString().slice(0,10);}
return {from,to};
}
// One date range drives the WHOLE dashboard body: trend chart + breakdown.
function applyRange(from,to){
loadMonthly(from,to);
loadBreakdown(from,to);
}
function wireFilters(){
const setRange=(from,to)=>{document.getElementById('f-from').value=from;document.getElementById('f-to').value=to;};
document.querySelectorAll('.fchip').forEach(c=>c.onclick=()=>{
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on'));
c.classList.add('on');
const r=rangeDates(c.dataset.preset);
setRange(r.from,r.to);
applyRange(r.from,r.to);
});
document.getElementById('f-apply').onclick=()=>{
const from=document.getElementById('f-from').value,to=document.getElementById('f-to').value;
if(!from||!to){alert('Pick both From and To dates');return;}
if(from>to){alert('From date must be before To date');return;}
document.querySelectorAll('.fchip').forEach(x=>x.classList.remove('on')); // custom range — no preset active
applyRange(from,to);
};
// ── Product Group multi-select popover ──
const pop=document.getElementById('g-pop');
document.getElementById('g-btn').onclick=e=>{e.stopPropagation();pop.classList.toggle('show');};
pop.onclick=e=>e.stopPropagation();
document.addEventListener('click',()=>pop.classList.remove('show'));
document.getElementById('g-list').innerHTML=_allGroups.length
? _allGroups.map(g=>`<label><input type="checkbox" class="g-cb" value="${g.code}" ${_selGroups.has(g.code)?'checked':''}/> ${esc(g.name)}</label>`).join('')
: '<div style="font-size:11px;color:var(--text3);padding:6px">No item groups found.</div>';
// The product-group filter is its OWN independent filter: ticking a group
// (or Select all / Clear) applies to the data IMMEDIATELY — no Apply button.
// The search box narrows the checklist so a group is easy to find & tick.
let _gT=null;
function applyGroups(){
_selGroups=new Set(Array.from(document.querySelectorAll('.g-cb:checked')).map(c=>c.value)); // string codes (incl. virtual '103bb'/'103capd')
if(_selGroups.size===_allGroups.length)_selGroups=new Set(); // everything ticked = no filter
document.getElementById('g-lbl').textContent=_selGroups.size?`${_selGroups.size} selected`:allLabel();
clearTimeout(_gT); // debounce rapid ticking — one reload after 350ms
_gT=setTimeout(()=>applyRange(document.getElementById('f-from').value,document.getElementById('f-to').value),350); // refreshes BOTH the trend chart and the breakdown/KPI cards
}
document.getElementById('g-list').addEventListener('change',applyGroups);
document.getElementById('g-search').addEventListener('input',function(){
const q=this.value.trim().toLowerCase();
document.querySelectorAll('#g-list label').forEach(l=>{
l.style.display=(!q||l.textContent.toLowerCase().includes(q))?'flex':'none';
});
});
const visibleCbs=()=>Array.from(document.querySelectorAll('#g-list label')).filter(l=>l.style.display!=='none').map(l=>l.querySelector('.g-cb'));
document.getElementById('g-all').onclick=()=>{visibleCbs().forEach(c=>c.checked=true);applyGroups();};
document.getElementById('g-none').onclick=()=>{visibleCbs().forEach(c=>c.checked=false);applyGroups();};
const r=rangeDates('12');setRange(r.from,r.to);
applyRange(r.from,r.to);
}
async function loadMonthly(from,to){
const wrap=document.getElementById('line-wrap');
wrap.innerHTML='<div class="loading">Loading trend…</div>';
try{
const gq=_selGroups.size?`&groups=${[..._selGroups].join(',')}`:'';
const r=await fetch(`/api/board/monthly?from=${from}&to=${to}${gq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
const rows=j.data||[];
const gnote=_selGroups.size?` · ${_selGroups.size} product group${_selGroups.size>1?'s':''}`:'';
document.getElementById('trend-sub').textContent=`Monthly · ${from} → ${to}${gnote} · net sales = invoices − credit memos · excl. GST`;
document.getElementById('trend-tbody').innerHTML=rows.map(x=>`<tr><td>${MONTHS[x.m-1]} ${x.y}</td><td>${inrFull(x.sales)}</td><td>${inrFull(x.purchases)}</td></tr>`).join('');
if(!rows.length){wrap.innerHTML='<div class="loading">No transactions in this period.</div>';return;}
drawLineChart(rows);
}catch(e){wrap.innerHTML='<div class="err-note">Could not load trend: '+esc(e.message)+'</div>';}
}
let _lastB=null,_lastFrom='',_lastTo=''; // cached so channel toggles re-render without refetch
let _qCust='',_qItem='',_qGrp='',_qT=null,_refocus=null; // per-card search (server-side, ranked by sales)
let _chanD=true,_chanE=true; // Domestic / Export channel toggles (group card)
async function loadBreakdown(from,to){
const el=document.getElementById('breakdown');
el.innerHTML='<div class="loading">Loading breakdown…</div>';
try{
const gq=(_selGroups.size?`&groups=${[..._selGroups].join(',')}`:'')+(_qCust?`&custQ=${encodeURIComponent(_qCust)}`:'')+(_qItem?`&itemQ=${encodeURIComponent(_qItem)}`:'')+(_qGrp?`&grpQ=${encodeURIComponent(_qGrp)}`:'');
const r=await fetch(`/api/board/breakdown?from=${from}&to=${to}${gq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
_lastB=j.data;_lastFrom=from;_lastTo=to;
renderBreakdown(j.data,from,to);
}catch(e){el.innerHTML='<div class="err-note">Could not load breakdown: '+esc(e.message)+'</div>';}
}
// KPI tiles follow the selected date range — including AR/AP outstanding,
// which show the still-unpaid amount of invoices DATED within the range.
function renderKpis(b){
const k=b.kpi||{},dv=b.divisions||{};
const growth=k.prevNetSales>0?((k.netSales-k.prevNetSales)/k.prevNetSales*100):null;
const gUp=growth!=null&&growth>=0;
// Every KPI tile AND Division Summary (dv.tm/pd/ed/ei) now recompute per the
// selected Product Groups (routes/board.js) — but they're still two
// DIFFERENT formulas for "export sales" (dv.ed/ei = G/L-account-exclusion
// based, matching the Sales Report to the paisa; k.exportDirect/Indirect =
// simple U_CustomerCategory based), so they can legitimately show different
// numbers even both correctly filtered to the same groups — prefer the
// Sales-Report-matching Division Summary figure when it loaded successfully.
const grpActive=_selGroups.size>0;
const expDirect=dv.ed!=null?dv.ed:k.exportDirect;
const expIndirect=dv.ei!=null?dv.ei:k.exportIndirect;
const gsuffix=grpActive?` · ${_selGroups.size} group${_selGroups.size>1?'s':''} selected`:'';
const tiles=[
{l:'Net Sales — Selected Period',v:inr(k.netSales),
delta:growth==null?'':`<span class="kpi-delta ${gUp?'up':'down'}">${gUp?'▲':'▼'} ${Math.abs(growth).toFixed(1)}% vs previous period</span>`,
s:inrFull(k.netSales)+gsuffix},
{l:'Purchases — Selected Period',v:inr(k.purchases),s:inrFull(k.purchases)+gsuffix},
{l:'Invoices — Selected Period',v:(k.invoices||0).toLocaleString('en-IN'),s:(grpActive?'AR invoices with a selected-group line':'AR invoices raised')+gsuffix},
{l:'Active Customers — Period',v:(k.activeCustomers||0).toLocaleString('en-IN'),s:(grpActive?'customers billed on selected groups':'customers billed')+gsuffix},
{l:'Export Sales — Direct',v:inr(expDirect),s:grpActive?('selected groups'+gsuffix):'as per sales report'},
{l:'Export Sales — Indirect',v:inr(expIndirect),s:grpActive?('selected groups'+gsuffix):'as per sales report'},
{l:'Receivables Outstanding',v:inr(k.openAR),s:'unpaid AR invoices dated in period — whole company, not group-filtered'},
{l:'Payables Outstanding',v:inr(k.openAP),s:'unpaid AP invoices dated in period — whole company, not group-filtered'},
];
document.getElementById('kpis').innerHTML=tiles.map(x=>`<div class="kpi"><div class="kpi-l">${x.l}</div><div class="kpi-v">${x.v}</div>${x.delta||`<div class="kpi-s">${x.s||''}</div>`}</div>`).join('');
}
function renderBreakdown(b,from,to){
const gnote=_selGroups.size?` · ${_selGroups.size} product group${_selGroups.size>1?'s':''}`:'';
const period=`${from} → ${to}${gnote}`;
renderKpis(b);
// Division Summary — the exact management-report figures (same SQL as
// /reports sales, verified to the paisa against the books) — now ALSO
// scoped to the selected Product Groups, same as every other card.
const dv=b.divisions;
const divCard=dv?`
<div class="card">
<div class="card-hd"><div><div class="card-title">Division Summary</div><div class="card-sub">As per Sales Report · ${from} → ${to}${gnote}</div></div></div>
<div class="card-bd"><div class="kpis" style="margin-bottom:0">
<div class="kpi"><div class="kpi-l">Blood Bag (TM)</div><div class="kpi-v">${inr(dv.tm)}</div><div class="kpi-s">${inrFull(dv.tm)}</div></div>
<div class="kpi"><div class="kpi-l">CAPD (PD)</div><div class="kpi-v">${inr(dv.pd)}</div><div class="kpi-s">${inrFull(dv.pd)}</div></div>
<div class="kpi"><div class="kpi-l">Export Direct</div><div class="kpi-v">${inr(dv.ed)}</div><div class="kpi-s">${inrFull(dv.ed)}</div></div>
<div class="kpi"><div class="kpi-l">Export Indirect</div><div class="kpi-v">${inr(dv.ei)}</div><div class="kpi-s">${inrFull(dv.ei)}</div></div>
<div class="kpi" style="border-color:var(--series-1);background:#f5f9ff"><div class="kpi-l" style="color:var(--series-1)">Total Sales</div><div class="kpi-v">${inr(dv.total)}</div><div class="kpi-s">${inrFull(dv.total)}</div></div>
</div></div>
</div>`:'';
document.getElementById('breakdown').innerHTML=divCard+`
<div class="card">
<div class="card-hd">
<div><div class="card-title">Sales by Product Group</div><div class="card-sub">Net of credit notes · ${period}${(!_chanD||!_chanE)?` · ${_chanD?'Domestic only':'Export only'}`:''}</div></div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<input class="csearch" id="q-grp" placeholder="Search product group…" value="${esc(_qGrp)}" autocomplete="off"/>
<div class="legend">
<span class="leg ch${_chanD?'':' off'}" id="ch-dom" title="Click to toggle Domestic"><span class="sw" style="background:var(--series-1)"></span>Domestic</span>
<span class="leg ch${_chanE?'':' off'}" id="ch-exp" title="Click to toggle Export"><span class="sw" style="background:var(--series-2)"></span>Export</span>
</div>
</div>
</div>
<div class="card-bd">${b.topGroups.length?hbarsSplit(b.topGroups):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Product Group</th><th>Domestic</th><th>Export</th><th>Total</th></tr></thead><tbody>
${b.topGroups.map(g=>`<tr><td>${esc(g.name)}</td><td>${inrFull(g.domestic)}</td><td>${inrFull(g.export)}</td><td>${inrFull(g.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="grid2">
<div class="card">
<div class="card-hd"><div><div class="card-title">Top Customers</div><div class="card-sub">By sales value · ${period}</div></div><input class="csearch" id="q-cust" placeholder="Search customer…" value="${esc(_qCust)}" autocomplete="off"/></div>
<div class="card-bd">${b.topCustomers.length?hbars(b.topCustomers.map(c=>({name:c.name,total:c.total}))):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Customer</th><th>Sales</th></tr></thead><tbody>
${b.topCustomers.map(c=>`<tr><td>${esc(c.name)}</td><td>${inrFull(c.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Top Products</div><div class="card-sub">By sales value · ${period}</div></div><input class="csearch" id="q-item" placeholder="Search product…" value="${esc(_qItem)}" autocomplete="off"/></div>
<div class="card-bd">${b.topItems.length?hbars(b.topItems.map(i=>({name:i.name||i.code,total:i.total}))):'<div class="loading">No sales in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Product</th><th>Sales</th></tr></thead><tbody>
${b.topItems.map(i=>`<tr><td>${esc(i.code)} — ${esc(i.name)}</td><td>${inrFull(i.total)}</td></tr>`).join('')}
</tbody></table></details>
</div>
</div>`;
// Channel toggle chips — clicking the ONLY active one is ignored (at least
// one channel must stay selected). Re-renders from cached data, no refetch.
const cd=document.getElementById('ch-dom'),ce=document.getElementById('ch-exp');
if(cd)cd.onclick=()=>{if(_chanD&&!_chanE)return;_chanD=!_chanD;renderBreakdown(_lastB||b,_lastFrom||from,_lastTo||to);};
if(ce)ce.onclick=()=>{if(_chanE&&!_chanD)return;_chanE=!_chanE;renderBreakdown(_lastB||b,_lastFrom||from,_lastTo||to);};
// Per-card search — server-side (finds ANY row in the period, ranked by
// sales), debounced; the input that triggered the reload keeps its focus.
const wireQ=(id,set)=>{
const el=document.getElementById(id);if(!el)return;
el.oninput=function(){
set(this.value);
const pos=this.selectionStart;
clearTimeout(_qT);
_qT=setTimeout(()=>{_refocus={id,pos};loadBreakdown(_lastFrom||from,_lastTo||to);},450);
};
};
wireQ('q-cust',v=>_qCust=v);
wireQ('q-item',v=>_qItem=v);
wireQ('q-grp',v=>_qGrp=v);
if(_refocus){
const el=document.getElementById(_refocus.id);
if(el){el.focus();try{el.setSelectionRange(_refocus.pos,_refocus.pos);}catch(_e){}}
_refocus=null;
}
}
// ── Stacked Domestic/Export bars per product group — a 2px surface gap
// separates the two segments; hover shows the split via the title. ──
function hbarsSplit(rows){
// Values, widths and ordering follow the ACTIVE channel toggles (legend
// chips) — both on = total, one on = that channel only.
const sel=r=>(_chanD?Math.max(0,r.domestic):0)+(_chanE?Math.max(0,r.export):0);
const list=[...rows].sort((a,b)=>sel(b)-sel(a));
const max=Math.max(...list.map(sel),1);
return list.map(r=>{
const dw=_chanD?Math.max(0,r.domestic)/max*100:0, ew=_chanE?Math.max(0,r.export)/max*100:0;
return `<div class="hbar-row" title="${esc(r.name)} — Domestic: ${inrFull(r.domestic)} · Export: ${inrFull(r.export)} · Total: ${inrFull(r.total)}">
<div class="hbar-name">${esc(r.name)}</div>
<div class="hbar-track" style="display:flex;gap:2px">
${dw>0?`<div class="hbar-fill" style="width:${dw}%;border-radius:${ew>0?'0':'0 4px 4px 0'}"></div>`:''}
${ew>0?`<div class="hbar-fill" style="width:${ew}%;background:var(--series-2)"></div>`:''}
</div>
<div class="hbar-val">${inr(sel(r))}</div>
</div>`;
}).join('');
}
// ── Horizontal bars — single series (title carries identity, no legend) ──
function hbars(rows){
const max=Math.max(...rows.map(r=>r.total),1);
return rows.map(r=>`<div class="hbar-row" title="${esc(r.name)}: ${inrFull(r.total)}">
<div class="hbar-name">${esc(r.name)}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${Math.max(1,r.total/max*100)}%"></div></div>
<div class="hbar-val">${inr(r.total)}</div>
</div>`).join('');
}
// ── 2-series line chart with crosshair hover + tooltip ───────────────────
function drawLineChart(monthly){
const wrap=document.getElementById('line-wrap');if(!wrap||!monthly.length)return;
const W=1080,H=300,PL=64,PR=16,PT=14,PB=30;
const iw=W-PL-PR,ih=H-PT-PB;
const maxV=Math.max(...monthly.map(r=>Math.max(r.sales,r.purchases)),1)*1.08;
const x=i=>PL+(monthly.length===1?iw/2:i*(iw/(monthly.length-1)));
const y=v=>PT+ih-(Math.max(v,0)/maxV*ih);
const pts=key=>monthly.map((r,i)=>`${x(i).toFixed(1)},${y(r[key]).toFixed(1)}`).join(' ');
// recessive grid: 4 horizontal lines + axis labels in Cr
let grid='';
for(let g=0;g<=4;g++){
const gv=maxV*g/4, gy=y(gv);
grid+=`<line x1="${PL}" y1="${gy}" x2="${W-PR}" y2="${gy}" stroke="#eef0f3" stroke-width="1"/>
<text x="${PL-8}" y="${gy+3.5}" text-anchor="end" font-size="10" fill="#9ca3af" font-family="JetBrains Mono,monospace">${(gv/1e7).toFixed(gv>=1e7?0:1)} Cr</text>`;
}
// x labels — every 3rd month to stay uncluttered
let xl='';
monthly.forEach((r,i)=>{ if(i%3===0||i===monthly.length-1)
xl+=`<text x="${x(i)}" y="${H-8}" text-anchor="middle" font-size="10" fill="#9ca3af" font-family="Space Grotesk,sans-serif">${MONTHS[r.m-1]} ${String(r.y).slice(2)}</text>`;});
wrap.innerHTML=`
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Monthly net sales and purchases, last 24 months">
${grid}${xl}
<polyline points="${pts('purchases')}" fill="none" stroke="var(--series-2)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${pts('sales')}" fill="none" stroke="var(--series-1)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<line id="xh" x1="0" y1="${PT}" x2="0" y2="${PT+ih}" stroke="#c7cdd6" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
<circle id="dot-s" r="4" fill="var(--series-1)" stroke="#fff" stroke-width="2" opacity="0"/>
<circle id="dot-p" r="4" fill="var(--series-2)" stroke="#fff" stroke-width="2" opacity="0"/>
</svg>
<div class="tip" id="line-tip"></div>`;
const svg=wrap.querySelector('svg'),tip=document.getElementById('line-tip');
const xh=svg.querySelector('#xh'),ds=svg.querySelector('#dot-s'),dp=svg.querySelector('#dot-p');
svg.addEventListener('mousemove',ev=>{
const rect=svg.getBoundingClientRect();
const sx=(ev.clientX-rect.left)*(W/rect.width);
let idx=Math.round((sx-PL)/(iw/(monthly.length-1)));
idx=Math.max(0,Math.min(monthly.length-1,idx));
const r=monthly[idx],cx=x(idx);
xh.setAttribute('x1',cx);xh.setAttribute('x2',cx);xh.setAttribute('opacity','1');
ds.setAttribute('cx',cx);ds.setAttribute('cy',y(r.sales));ds.setAttribute('opacity','1');
dp.setAttribute('cx',cx);dp.setAttribute('cy',y(r.purchases));dp.setAttribute('opacity','1');
tip.innerHTML=`<div style="font-weight:700;margin-bottom:3px">${MONTHS[r.m-1]} ${r.y}</div>
<div class="t-row"><span class="sw" style="background:var(--series-1)"></span>Net Sales <b>&nbsp;${inr(r.sales)}</b></div>
<div class="t-row"><span class="sw" style="background:var(--series-2)"></span>Purchases <b>&nbsp;${inr(r.purchases)}</b></div>`;
tip.style.left=(cx/W*100)+'%';
tip.style.top=(Math.min(y(r.sales),y(r.purchases))/H*100)+'%';
tip.style.opacity='1';
});
svg.addEventListener('mouseleave',()=>{tip.style.opacity='0';xh.setAttribute('opacity','0');ds.setAttribute('opacity','0');dp.setAttribute('opacity','0');});
}
load();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>