Files
sap-erp/public/production-dashboard-legacy.html
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

297 lines
17 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>Production Dashboard (Legacy) — 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;
--series-2:#eb6834;
--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,#0f3d2e 0%,#0d9488 55%,#2563eb 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(13,148,136,.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}
.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}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
.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}
.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)}
.hbar-row{display:grid;grid-template-columns:minmax(120px,260px) 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}
/* workflow funnel */
.stage-row{display:grid;grid-template-columns:minmax(150px,240px) 1fr 60px;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid #f3f4f6}
.stage-row:last-child{border-bottom:none}
.stage-name{font-size:11.5px;color:var(--text2);font-weight:600}
.stage-fill{height:14px;border-radius:0 4px 4px 0;background:var(--series-1);min-width:2px}
.stage-val{font-family:var(--mono);font-size:12px;color:var(--text);text-align:right}
.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}
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 production analytics…</div></main>
<script>
const _tok=sessionStorage.getItem('portal_token');
let _user=null;try{_user=JSON.parse(sessionStorage.getItem('portal_user')||'null');}catch(_e){}
// Superseded by the redesigned /production-dashboard (Work Order + Rejection
// Register + OEE, matching the plant's own process) — kept here as an
// admin-only reference/fallback rather than deleted, since it still reads
// real SAP output/order data (OWOR/IGN1) that the new page doesn't cover yet.
if(!_user||_user.role!=='admin'){
document.getElementById('main').innerHTML='<div class="err-note">This legacy dashboard is admin-only. See <a href="/production-dashboard" style="color:var(--series-1)">Production Dashboard</a> instead.</div>';
throw new Error('admin-only page');
}
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;');}
function fmtN(v){return (Number(v)||0).toLocaleString('en-IN');}
function fmtQty(v){
const n=Number(v)||0,a=Math.abs(n);
if(a>=1e7)return (n/1e7).toFixed(2)+' Cr';
if(a>=1e5)return (n/1e5).toFixed(2)+' L';
return n.toLocaleString('en-IN');
}
let _from='',_to='',_qItem='',_qT=null,_refocus=null,_lastD=null;
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};
}
async function load(from,to){
try{
const iq=_qItem?`&itemQ=${encodeURIComponent(_qItem)}`:'';
const r=await fetch(`/api/production-dashboard/summary?from=${from}&to=${to}${iq}`,{headers:{Authorization:'Bearer '+_tok}});
const j=await r.json();if(!j.success)throw new Error(j.message||'Failed');
_lastD=j.data;_from=from;_to=to;
render(j.data);
}catch(e){document.getElementById('main').innerHTML='<div class="err-note">Could not load analytics: '+esc(e.message)+'</div>';}
}
function render(d){
const k=d.kpi,period=`${d.from} → ${d.to}`;
const now=new Date();
const kpis=[
{l:'Production Output — Period',v:fmtQty(k.outputQty),s:'units received from production'},
{l:'Receipt Documents',v:fmtN(k.receiptDocs),s:'receipts posted in period'},
{l:'Items Produced',v:fmtN(k.itemsProduced),s:'distinct products'},
{l:'Orders Created — Period',v:fmtN(k.ordersCreated),s:fmtQty(k.plannedQtyCreated)+' units planned'},
{l:'Orders Closed — Period',v:fmtN(k.ordersClosed),s:'of those created in period'},
{l:'Open Orders — Now',v:fmtN(k.openPlanned+k.openReleased),s:`${fmtN(k.openPlanned)} planned · ${fmtN(k.openReleased)} released`},
];
document.getElementById('main').innerHTML=`
<div class="hero">
<div style="z-index:1">
<div class="hero-eyebrow">Production Module</div>
<div class="hero-title">Production Dashboard</div>
<div class="hero-sub">Output, orders &amp; portal workflow — 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">${kpis.map(x=>`<div class="kpi"><div class="kpi-l">${x.l}</div><div class="kpi-v">${x.v}</div><div class="kpi-s">${x.s||''}</div></div>`).join('')}</div>
<div class="fbar">
<span class="fbar-l">Date range</span>
<button class="fchip" data-preset="1">1M</button>
<button class="fchip" data-preset="3">3M</button>
<button class="fchip on" data-preset="6">6M</button>
<button class="fchip" data-preset="12">12M</button>
<button class="fchip" data-preset="ytd">YTD</button>
<span class="sep"></span>
<input type="date" id="f-from" value="${d.from}"/> <span style="color:var(--text3);font-size:11px">to</span> <input type="date" id="f-to" value="${d.to}"/>
<button class="fapply" id="f-apply">Apply</button>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Monthly Production Output</div><div class="card-sub">Units received from production · ${period}</div></div></div>
<div class="card-bd"><div class="chart-wrap" id="line-wrap"></div></div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Month</th><th>Output Qty</th><th>Receipt Docs</th></tr></thead><tbody>
${d.monthly.map(r=>`<tr><td>${MONTHS[r.m-1]} ${r.y}</td><td>${fmtN(r.qty)}</td><td>${fmtN(r.docs)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="grid2">
<div class="card">
<div class="card-hd">
<div><div class="card-title">Top Produced Items</div><div class="card-sub">By quantity · ${period}</div></div>
<input class="csearch" id="q-item" placeholder="Search item…" value="${esc(_qItem)}" autocomplete="off"/>
</div>
<div class="card-bd">${d.topItems.length?hbars(d.topItems):'<div class="loading">No production in this period.</div>'}</div>
<details class="tbl"><summary>View data table</summary><table><thead><tr><th>Item</th><th>Qty</th></tr></thead><tbody>
${d.topItems.map(i=>`<tr><td>${esc(i.code)} — ${esc(i.name)}</td><td>${fmtN(i.qty)}</td></tr>`).join('')}
</tbody></table></details>
</div>
<div class="card">
<div class="card-hd"><div><div class="card-title">Portal Workflow — Pending by Stage</div><div class="card-sub">Portal-tracked production orders · as of now</div></div></div>
<div class="card-bd">
${stageRows(d.stages)}
${d.rejected?`<div class="stage-row"><div class="stage-name" style="color:var(--bad)">Rejected — awaiting Close</div><div><div class="stage-fill" style="background:var(--bad);width:${Math.min(100,d.rejected*12)}%"></div></div><div class="stage-val" style="color:var(--bad)">${fmtN(d.rejected)}</div></div>`:''}
</div>
</div>
</div>`;
drawLine(d.monthly);
wireFilters();
}
function stageRows(stages){
const max=Math.max(...stages.map(s=>s.count),1);
return stages.map(s=>`<div class="stage-row">
<div class="stage-name">${esc(s.label)}</div>
<div><div class="stage-fill" style="width:${Math.max(2,s.count/max*100)}%${s.count?'':';background:#e5e7eb'}"></div></div>
<div class="stage-val">${fmtN(s.count)}</div>
</div>`).join('');
}
function hbars(rows){
const max=Math.max(...rows.map(r=>r.qty),1);
return rows.map(r=>`<div class="hbar-row" title="${esc(r.code)} — ${esc(r.name)}: ${fmtN(r.qty)}">
<div class="hbar-name">${esc(r.name||r.code)}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${Math.max(1,r.qty/max*100)}%"></div></div>
<div class="hbar-val">${fmtQty(r.qty)}</div>
</div>`).join('');
}
function wireFilters(){
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);
load(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'));
load(from,to);
};
const q=document.getElementById('q-item');
if(q)q.oninput=function(){
_qItem=this.value;
const pos=this.selectionStart;
clearTimeout(_qT);
_qT=setTimeout(()=>{_refocus={pos};load(_from,_to);},450);
};
if(_refocus){
const el=document.getElementById('q-item');
if(el){el.focus();try{el.setSelectionRange(_refocus.pos,_refocus.pos);}catch(_e){}}
_refocus=null;
}
}
// single-series line chart (title names the series — no legend needed)
function drawLine(monthly){
const wrap=document.getElementById('line-wrap');if(!wrap)return;
if(!monthly.length){wrap.innerHTML='<div class="loading">No production receipts in this period.</div>';return;}
const W=1080,H=280,PL=70,PR=16,PT=14,PB=30;
const iw=W-PL-PR,ih=H-PT-PB;
const maxV=Math.max(...monthly.map(r=>r.qty),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);
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">${fmtQty(gv)}</text>`;
}
let xl='';
monthly.forEach((r,i)=>{ if(monthly.length<=8||i%2===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>`;});
const pts=monthly.map((r,i)=>`${x(i).toFixed(1)},${y(r.qty).toFixed(1)}`).join(' ');
wrap.innerHTML=`
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Monthly production output">
${grid}${xl}
<polyline points="${pts}" 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" r="4" fill="var(--series-1)" 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'),dot=svg.querySelector('#dot');
svg.addEventListener('mousemove',ev=>{
const rect=svg.getBoundingClientRect();
const sx=(ev.clientX-rect.left)*(W/rect.width);
let idx=monthly.length===1?0: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');
dot.setAttribute('cx',cx);dot.setAttribute('cy',y(r.qty));dot.setAttribute('opacity','1');
tip.innerHTML=`<div style="font-weight:700;margin-bottom:3px">${MONTHS[r.m-1]} ${r.y}</div>Output <b>&nbsp;${fmtN(r.qty)}</b><br/>Receipts <b>&nbsp;${fmtN(r.docs)}</b>`;
tip.style.left=(cx/W*100)+'%';
tip.style.top=(y(r.qty)/H*100)+'%';
tip.style.opacity='1';
});
svg.addEventListener('mouseleave',()=>{tip.style.opacity='0';xh.setAttribute('opacity','0');dot.setAttribute('opacity','0');});
}
const _r0=rangeDates('6');
load(_r0.from,_r0.to);
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>