504 lines
32 KiB
HTML
504 lines
32 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
|
||
// 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 & 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> ${inr(r.sales)}</b></div>
|
||
<div class="t-row"><span class="sw" style="background:var(--series-2)"></span>Purchases <b> ${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=31"></script>
|
||
</body>
|
||
</html>
|