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

420 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Production Dashboard — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
<style>
:root{--ink:#080e18;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--border:#1c2e48;--border2:#243650;
--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;
--blue:#1a6fff;--purple:#9f5afd;--teal:#06b6d4;--green:#0dbb8a;--amber:#f5a623;--red:#e53e3e;
--cat1:#3987e5;--cat2:#d95926;--cat3:#199e70;--cat4:#c98500;--cat5:#d55181;--catO:#3d5470;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;
--radius:12px;--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font);transition:background .12s,color .12s}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font);transition:border-color .12s,color .12s}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:18px 16px 44px;max-width:1400px;margin:0 auto}
.hdr{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:16px;position:relative;overflow:hidden}
.hdr::before{content:'';position:absolute;top:-50px;right:-50px;width:220px;height:220px;border-radius:50%;background:rgba(255,255,255,.07)}
.hdr-eyebrow{font-size:10px;font-weight:700;letter-spacing:1.8px;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:4px}
.hdr-title{font-size:24px;font-weight:800;color:#fff!important;margin:0}
.hdr-sub{font-size:12.5px;color:rgba(255,255,255,.75);margin-top:4px}
.hdr-right{font-size:11.5px;color:rgba(255,255,255,.8);text-align:right;z-index:1}
.filters{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;margin-bottom:16px;box-shadow:var(--shadow)}
.fg{display:flex;flex-direction:column;gap:5px;min-width:130px}
.fg label{font-size:9.5px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;font-weight:700}
.fg select,.fg input{background:var(--surface3);border:1px solid var(--border2);border-radius:7px;color:var(--text);font-family:inherit;font-size:12.5px;padding:8px 10px;appearance:none}
.fg select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:26px;cursor:pointer}
.fg select:focus,.fg input:focus{outline:none;border-color:var(--purple)}
.btn{padding:9px 16px;border-radius:7px;border:none;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;background:var(--purple);color:#fff}
.btn:hover{filter:brightness(1.1)}
.kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:16px}
.kpi{position:relative;overflow:hidden;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 16px 14px;box-shadow:var(--shadow)}
.kpi::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--ac)}
.kpi .kl{font-size:9.5px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;font-weight:700;margin-bottom:9px}
.kpi .kv{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:23px;font-weight:700;color:#fff;line-height:1}
.kpi .ku{font-size:10px;color:var(--text3);margin-left:3px;font-weight:600}
.kpi .kd{font-size:10.5px;color:var(--text2);margin-top:8px;font-weight:600}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;box-shadow:var(--shadow)}
.card-hd{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:2px}
.card-hd h3{margin:0;font-size:12px;font-weight:700;color:var(--text);text-transform:uppercase;letter-spacing:.5px}
.card-hd .unit{font-size:10px;color:var(--text3);font-weight:600}
.card-sub{font-size:10.5px;color:var(--text3);margin-bottom:10px}
.row3{display:grid;grid-template-columns:1.6fr 1fr 1fr;gap:12px;margin-bottom:14px}
.row4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:14px}
.legend{display:flex;flex-wrap:wrap;gap:10px 14px;margin-top:10px;font-size:10.5px;color:var(--text2)}
.legend span{display:flex;align-items:center;gap:5px}
.legend i{width:8px;height:8px;border-radius:2px;flex-shrink:0}
.tbl-wrap{border:1px solid var(--border);border-radius:9px;overflow:auto}
table{width:100%;border-collapse:collapse;font-size:11px}
th{text-align:left;background:var(--surface2);color:var(--text3);font-size:9px;text-transform:uppercase;letter-spacing:.4px;padding:7px 9px;border-bottom:1px solid var(--border2);font-weight:700;white-space:nowrap}
td{padding:7px 9px;border-bottom:1px solid var(--border);color:var(--text2);font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
td:first-child,th:first-child{font-family:var(--font);color:var(--text)}
tr:last-child td{border-bottom:none}
tr.tot td{background:var(--surface2);color:var(--text);font-weight:700}
.right{text-align:right}
.chip-ok{display:inline-block;padding:2px 7px;border-radius:999px;font-size:9.5px;font-weight:700;background:rgba(13,187,138,.14);color:var(--green)}
.chip-warn{display:inline-block;padding:2px 7px;border-radius:999px;font-size:9.5px;font-weight:700;background:rgba(245,166,35,.14);color:var(--amber)}
.indent{padding-left:18px;color:var(--text3)!important}
.empty-msg{text-align:center;padding:28px 12px;color:var(--text3);font-size:12px}
.soon{border:1px dashed var(--border2);border-radius:9px;padding:16px;text-align:center;color:var(--text3);font-size:11.5px;line-height:1.6}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
footer{margin-top:6px;text-align:center;font-size:10.5px;color:var(--text3)}
footer a{color:var(--text2)}
@media (max-width:1100px){.kpis{grid-template-columns:repeat(3,1fr)}.row3{grid-template-columns:1fr}.row4{grid-template-columns:repeat(2,1fr)}}
@media (max-width:640px){.kpis{grid-template-columns:repeat(2,1fr)}.row4{grid-template-columns:1fr}.hdr-title{font-size:22px}}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<nav>
<span class="nav-logo">SAP</span>
<div class="nav-links">
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
<a href="/work-order" class="nav-link">Work Order</a><a href="/rejection-register" class="nav-link">Rejection Register</a>
<a href="/rejection-analytics" class="nav-link">Rejection Analytics</a><a href="/production-dashboard" class="nav-link active">Production Dashboard</a>
<a href="/verify-work-order" class="nav-link">Verify Work Order</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"><div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div></main>
<script>
'use strict';
let _tok=null,_user=null;
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
if(!_tok)window.location.href='/';
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';window.location.href='/';};
async function api(m,p){
const h={};if(_tok)h['Authorization']='Bearer '+_tok;
let r,d;try{r=await fetch(BE+p,{method:m,headers:h});d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
const err=new Error(d?.message||'HTTP '+r.status);err.status=r.status;
if(!r.ok||d.success===false)throw err;
return d;
}
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function q(s){return document.querySelector(s);}
function today(){return new Date().toISOString().slice(0,10);}
function daysAgo(n){const d=new Date();d.setDate(d.getDate()-n);return d.toISOString().slice(0,10);}
function fmtN(v){return Math.round(v||0).toLocaleString('en-IN');}
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _from=daysAgo(30),_to=today();
let _loading=false;
let _rejAnalytics=null,_rejEntries=[],_rejStages=[],_rejAllowed=true;
let _wos=[],_woAllowed=true;
// Categorical colors — dataviz-validated dark-mode step order (adjacent-pair
// safe for bars/donuts); "Other" gets a flat neutral outside the sequence.
const CAT=['var(--cat1)','var(--cat2)','var(--cat3)','var(--cat4)','var(--cat5)'];
const CATO='var(--catO)';
async function loadAll(){
_loading=true;render();
const qs=`from=${encodeURIComponent(_from)}&to=${encodeURIComponent(_to)}&company=${encodeURIComponent(_co)}`;
try{const r=await api('GET',`/rejection-register/stages`);_rejStages=r.data||[];}catch(_e){_rejStages=[];}
try{
const [a,e]=await Promise.all([
api('GET',`/rejection-register/analytics?${qs}`),
api('GET',`/rejection-register/entries?${qs}`),
]);
_rejAnalytics=a.data;_rejEntries=e.data||[];_rejAllowed=true;
}catch(e){_rejAllowed=e.status!==403;_rejAnalytics=null;_rejEntries=[];}
try{
const w=await api('GET',`/work-orders?company=${encodeURIComponent(_co)}`);
_wos=(w.data||[]).filter(x=>!x.isDeleted&&x.createdAt&&x.createdAt.slice(0,10)>=_from&&x.createdAt.slice(0,10)<=_to);
_woAllowed=true;
}catch(e){_woAllowed=e.status!==403;_wos=[];}
_loading=false;render();
}
// ── Work Order derived metrics ──────────────────────────────────────────
function woMetrics(){
const approved=_wos.filter(w=>w.status==='APPROVED');
const pending=_wos.filter(w=>w.status==='IN_PROGRESS');
const rejected=_wos.filter(w=>w.status==='REJECTED');
const totalUnits=_wos.reduce((s,w)=>s+(parseFloat(w.totalUnits)||0),0);
const cycles=approved.map(w=>{
const log=Array.isArray(w.workflowLog)?w.workflowLog:[];
if(!log.length||!w.createdAt)return null;
const last=log[log.length-1];
if(!last||!last.at)return null;
return (new Date(last.at)-new Date(w.createdAt))/86400000;
}).filter(v=>v!=null&&v>=0);
const avgCycle=cycles.length?cycles.reduce((s,v)=>s+v,0)/cycles.length:0;
// Top products by planned Total Units (all WOs in range, any status)
const byProduct={};
_wos.forEach(w=>{
const key=w.productCode||'(no code)';
byProduct[key]=(byProduct[key]||0)+(parseFloat(w.totalUnits)||0);
});
const prodList=Object.entries(byProduct).map(([code,v])=>({name:code,v})).sort((a,b)=>b.v-a.v);
const top=prodList.slice(0,5),rest=prodList.slice(5);
const restSum=rest.reduce((s,r)=>s+r.v,0);
const products=top.map((p,i)=>({name:p.name,v:p.v,c:CAT[i]}));
if(restSum>0)products.push({name:'Other',v:restSum,c:CATO});
// Daily approval-cycle trend (days), by the day each WO was fully approved
const cycleByDay={};
approved.forEach(w=>{
const log=Array.isArray(w.workflowLog)?w.workflowLog:[];
const last=log[log.length-1];
if(!last||!last.at||!w.createdAt)return;
const day=last.at.slice(0,10);
const c=(new Date(last.at)-new Date(w.createdAt))/86400000;
if(c<0)return;
if(!cycleByDay[day])cycleByDay[day]={sum:0,n:0};
cycleByDay[day].sum+=c;cycleByDay[day].n++;
});
const cycleTrend=Object.entries(cycleByDay).sort((a,b)=>a[0]<b[0]?-1:1).map(([day,v])=>({day,avg:v.sum/v.n}));
return {approved,pending,rejected,totalUnits,avgCycle,products,cycleTrend};
}
// ── Rejection Register: Stage-wise rollup (reuses each entry's own causes[]) ──
function stageRollup(){
const by={};
_rejEntries.forEach(e=>{
const k=e.stage||'(none)';
if(!by[k])by[k]={stage:k,batches:0,size:0,net:0};
by[k].batches++;by[k].size+=e.batchSize||0;by[k].net+=e.netQty||0;
});
return Object.values(by).sort((a,b)=>b.size-a.size);
}
function stageLabel(v){return (_rejStages.find(s=>s.v===v)||{}).label||v;}
function donut(segs,centerLabel){
const total=segs.reduce((s,x)=>s+x.v,0)||1;
const cx=76,cy=90,r=58,rInner=36;
let ang=-Math.PI/2,paths='';
const polar=(cx,cy,r,a)=>[cx+r*Math.cos(a),cy+r*Math.sin(a)];
segs.forEach(s=>{
const frac=s.v/total,a1=ang,a2=ang+frac*Math.PI*2;
const [x1,y1]=polar(cx,cy,r,a1),[x2,y2]=polar(cx,cy,r,a2);
const [x1i,y1i]=polar(cx,cy,rInner,a1),[x2i,y2i]=polar(cx,cy,rInner,a2);
const large=frac>0.5?1:0;
paths+=`<path d="M${x1i.toFixed(1)},${y1i.toFixed(1)} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} L${x2i.toFixed(1)},${y2i.toFixed(1)} A${rInner},${rInner} 0 ${large} 0 ${x1i.toFixed(1)},${y1i.toFixed(1)} Z" fill="${s.c}" stroke="var(--surface)" stroke-width="2"><title>${esc(s.name)}: ${fmtN(s.v)} (${(frac*100).toFixed(1)}%)</title></path>`;
ang=a2;
});
const legend=segs.map(s=>`<span><i style="background:${s.c}"></i>${esc(s.name)} — ${((s.v/total)*100).toFixed(1)}%</span>`).join('');
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<svg viewBox="0 0 152 180" style="width:150px;height:140px;flex-shrink:0">
${paths}
<text x="${cx}" y="${cy-4}" text-anchor="middle" font-family="var(--mono)" font-size="14" font-weight="700" fill="var(--text)">${fmtN(total)}</text>
<text x="${cx}" y="${cy+12}" text-anchor="middle" font-family="var(--font)" font-size="8.5" fill="var(--text3)">${esc(centerLabel)}</text>
</svg>
</div>
<div class="legend">${legend}</div>`;
}
function lineChart(dates,vals,color,unit,decimals){
if(!dates.length)return '<div class="empty-msg">Not enough data yet.</div>';
const w=340,h=150,padT=14,padB=20,padL=6,pr=6;
const n=vals.length,maxV=Math.max(...vals,0.01)*1.18,minV=Math.min(0,...vals)*(vals.some(v=>v<0)?1.15:1);
const usableH=h-padT-padB;
const xAt=i=>padL+(n<=1?0:i*((w-padL-pr)/(n-1)));
const yAt=v=>h-padB-((v-minV)/(maxV-minV||1))*usableH;
const path=vals.map((v,i)=>`${i===0?'M':'L'}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ');
const area=path+` L${xAt(n-1).toFixed(1)},${h-padB} L${xAt(0).toFixed(1)},${h-padB} Z`;
const dots=vals.map((v,i)=>`<circle cx="${xAt(i).toFixed(1)}" cy="${yAt(v).toFixed(1)}" r="2.4" fill="${color}"><title>${esc(dates[i])}: ${v.toFixed(decimals||1)}${unit}</title></circle>`).join('');
const lastV=vals[n-1];
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:${h}px" preserveAspectRatio="xMinYMid meet">
<path d="${area}" fill="${color}" opacity=".08"/>
<path d="${path}" fill="none" stroke="${color}" stroke-width="2.2"/>
${dots}
<text x="${xAt(n-1).toFixed(1)}" y="${(yAt(lastV)-8).toFixed(1)}" text-anchor="end" font-size="10" font-weight="700" fill="${color}" font-family="var(--mono)">${lastV.toFixed(decimals||1)}${unit}</text>
</svg>`;
}
function hbar(items){
if(!items.length)return '<div class="empty-msg">No cause-level data yet.</div>';
const w=340,rowH=26,padL=128,padR=34,h=items.length*rowH+8;
const maxV=Math.max(...items.map(i=>i.v))*1.15||1;
const rows=items.map((it,i)=>{
const y=i*rowH+4,bw=(w-padL-padR)*(it.v/maxV);
return `<text x="${padL-8}" y="${y+15}" text-anchor="end" font-size="9.5" fill="var(--text2)" font-family="var(--font)">${esc(it.name)}</text>
<rect x="${padL}" y="${y+3}" width="${Math.max(2,bw).toFixed(1)}" height="14" rx="3" fill="${it.c}"><title>${esc(it.name)}: ${fmtN(it.v)}</title></rect>
<text x="${(padL+bw+6).toFixed(1)}" y="${y+14}" font-size="9.5" font-family="var(--mono)" fill="var(--text2)">${fmtN(it.v)}</text>`;
}).join('');
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:${h}px">${rows}</svg>`;
}
function trendSvg(trend){
if(!trend.length)return '<div class="empty-msg">Not enough data yet.</div>';
const w=760,h=170,pad=10,maxV=Math.max(1,...trend.map(p=>Math.max(p.grossPct,p.netPct)))*1.15;
const xAt=i=>pad+i*((w-pad*2)/Math.max(1,trend.length-1));
const yAt=v=>h-pad-(v/maxV)*(h-pad*2);
const grossPath=trend.map((p,i)=>`${i===0?'M':'L'}${xAt(i)},${yAt(p.grossPct)}`).join(' ');
const grossArea=grossPath+` L${xAt(trend.length-1)},${h-pad} L${xAt(0)},${h-pad} Z`;
const netPath=trend.map((p,i)=>`${i===0?'M':'L'}${xAt(i)},${yAt(p.netPct)}`).join(' ');
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:${h}px" preserveAspectRatio="none">
<path d="${grossArea}" fill="rgba(36,54,80,.5)" stroke="none"/>
<path d="${grossPath}" fill="none" stroke="var(--border2)" stroke-width="2"/>
<path d="${netPath}" fill="none" stroke="var(--red)" stroke-width="2.5"/>
${trend.map((p,i)=>`<circle cx="${xAt(i)}" cy="${yAt(p.netPct)}" r="3" fill="var(--red)"><title>${esc(p.date)}: ${p.netPct}% net</title></circle>`).join('')}
</svg>`;
}
function notAuthorized(what){return `<div class="soon">Not assigned to view ${esc(what)} — ask an admin for access.</div>`;}
function render(){
const app=q('#app');
if(_loading){app.innerHTML='<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div>';return;}
const a=_rejAnalytics||{avgPct:0,totalGross:0,totalNet:0,totalSize:0,savedByRework:0,topCauses:[],trend:[],batchCount:0};
const wm=woMetrics();
const stages=stageRollup();
const stageSegs=stages.slice(0,5).map((s,i)=>({name:stageLabel(s.stage),v:s.size,c:CAT[i]}));
const stageRest=stages.slice(5).reduce((s,x)=>s+x.size,0);
if(stageRest>0)stageSegs.push({name:'Other',v:stageRest,c:CATO});
const causeSegs=a.topCauses.slice(0,5).map((c,i)=>({name:c.name,v:Math.max(0,c.qty),c:CAT[i]}));
const nowStr=new Date().toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'});
app.innerHTML=`
<div class="hdr">
<div style="z-index:1">
<div class="hdr-eyebrow">Production</div>
<h1 class="hdr-title">Production Dashboard</h1>
<div class="hdr-sub">Work Orders &amp; Rejection Register — live from the portal</div>
</div>
<div class="hdr-right">As of ${esc(nowStr)}<br/>${esc(_user?.name||_user?.username||'')}</div>
</div>
<div class="filters">
<div class="fg"><label>Company</label><select id="f-co">${coOptions(_co)}</select></div>
<div class="fg"><label>From</label><input type="date" id="f-from" value="${esc(_from)}"/></div>
<div class="fg"><label>To</label><input type="date" id="f-to" value="${esc(_to)}"/></div>
<button class="btn" id="f-apply">Apply</button>
</div>
<div class="kpis">
<div class="kpi" style="--ac:var(--blue)"><div class="kl">Batches Inspected</div><div class="kv">${fmtN(a.batchCount)}</div><div class="kd">Rejection Register entries</div></div>
<div class="kpi" style="--ac:var(--red)"><div class="kl">Net Rejection %</div><div class="kv">${a.avgPct}<span class="ku">%</span></div><div class="kd">${fmtN(a.totalNet)} net of ${fmtN(a.totalGross)} gross</div></div>
<div class="kpi" style="--ac:var(--purple)"><div class="kl">Saved by Rework</div><div class="kv">${a.totalGross?Math.round(a.savedByRework/a.totalGross*100):0}<span class="ku">%</span></div><div class="kd">${fmtN(a.savedByRework)} bags recovered</div></div>
<div class="kpi" style="--ac:var(--green)"><div class="kl">Work Orders Approved</div><div class="kv">${fmtN(wm.approved.length)}</div><div class="kd">of ${fmtN(_wos.length)} in range</div></div>
<div class="kpi" style="--ac:var(--amber)"><div class="kl">Work Orders Pending</div><div class="kv">${fmtN(wm.pending.length)}</div><div class="kd">${fmtN(wm.rejected.length)} rejected in range</div></div>
<div class="kpi" style="--ac:var(--teal)"><div class="kl">Avg. WO Approval</div><div class="kv">${wm.avgCycle.toFixed(1)}<span class="ku">days</span></div><div class="kd">Prepared QA → Approved QA</div></div>
</div>
<div class="row3">
<div class="card">
<div class="card-hd"><h3>Net Rejection Trend</h3><span class="unit">Daily %</span></div>
<div class="card-sub">Pale band = gross before rework · red line = net after rework</div>
${_rejAllowed?trendSvg(a.trend):notAuthorized('Rejection Analytics')}
</div>
<div class="card">
<div class="card-hd"><h3>Volume by Stage</h3></div>
<div class="card-sub">Batch size inspected, per Stage</div>
${_rejAllowed?(stageSegs.length?donut(stageSegs,'bags'):'<div class="empty-msg">No entries in this filter.</div>'):notAuthorized('Rejection Analytics')}
</div>
<div class="card">
<div class="card-hd"><h3>Top Products</h3></div>
<div class="card-sub">By planned Total Units — Work Orders</div>
${_woAllowed?(wm.products.length?donut(wm.products,'units'):'<div class="empty-msg">No Work Orders in this filter.</div>'):notAuthorized('Work Orders')}
</div>
</div>
<div class="row4">
<div class="card">
<div class="card-hd"><h3>Top Rejection Causes</h3><span class="unit">Bags</span></div>
<div class="card-sub">Net qty, current filter</div>
${_rejAllowed?hbar(causeSegs):notAuthorized('Rejection Analytics')}
</div>
<div class="card">
<div class="card-hd"><h3>WO Status Mix</h3></div>
<div class="card-sub">Created in range</div>
${_woAllowed?(_wos.length?donut([{name:'Approved',v:wm.approved.length,c:'var(--green)'},{name:'Pending',v:wm.pending.length,c:'var(--amber)'},{name:'Rejected',v:wm.rejected.length,c:'var(--red)'}],'WOs'):'<div class="empty-msg">No Work Orders in this filter.</div>'):notAuthorized('Work Orders')}
</div>
<div class="card">
<div class="card-hd"><h3>WO Approval Cycle</h3><span class="unit">Days</span></div>
<div class="card-sub">Daily average, WOs approved that day</div>
${_woAllowed?lineChart(wm.cycleTrend.map(c=>c.day),wm.cycleTrend.map(c=>c.avg),'var(--teal)','d',1):notAuthorized('Work Orders')}
</div>
<div class="card">
<div class="card-hd"><h3>OEE &amp; Manpower</h3></div>
<div class="card-sub">Plant-wide utilization</div>
<div class="soon">Coming soon — OEE and Man Power don't have a plant-wide analytics rollup yet (today they're per-form entries). Ask to have this wired once that aggregation exists.</div>
</div>
</div>
<div class="row4">
<div class="card">
<div class="card-hd"><h3>Rejection Summary</h3></div>
<div class="card-sub">Rolled up over the filter</div>
${_rejAllowed?`<div class="tbl-wrap"><table>
<thead><tr><th>Particulars</th><th class="right">Bags</th><th class="right">%</th></tr></thead>
<tbody>
<tr><td>Total Batch Charge</td><td class="right">${fmtN(a.totalSize)}</td><td class="right">100.00</td></tr>
<tr><td>Gross Rejection</td><td class="right">${fmtN(a.totalGross)}</td><td class="right">—</td></tr>
<tr><td>Net Rejection</td><td class="right">${fmtN(a.totalNet)}</td><td class="right">${a.avgPct}</td></tr>
${a.topCauses.slice(0,5).map(c=>`<tr><td class="indent">· ${esc(c.name)}</td><td class="right">${fmtN(c.qty)}</td><td class="right">—</td></tr>`).join('')}
</tbody>
</table></div>`:notAuthorized('Rejection Analytics')}
</div>
<div class="card">
<div class="card-hd"><h3>Stage-wise Performance</h3></div>
<div class="card-sub">Batches captured &amp; net rejection</div>
${_rejAllowed?`<div class="tbl-wrap"><table>
<thead><tr><th>Stage</th><th class="right">Batches</th><th class="right">Bags</th><th class="right">Net %</th></tr></thead>
<tbody>
${stages.map(s=>`<tr><td>${esc(stageLabel(s.stage))}</td><td class="right">${fmtN(s.batches)}</td><td class="right">${fmtN(s.size)}</td><td class="right">${s.size?(s.net/s.size*100).toFixed(1):'0.0'}</td></tr>`).join('')||'<tr><td colspan="4" style="color:var(--text3);font-family:var(--font)">No entries in this filter.</td></tr>'}
</tbody>
</table></div>`:notAuthorized('Rejection Analytics')}
</div>
<div class="card">
<div class="card-hd"><h3>Work Order Summary</h3></div>
<div class="card-sub">Created in range</div>
${_woAllowed?`<div class="tbl-wrap"><table>
<thead><tr><th>Particulars</th><th class="right">Count</th></tr></thead>
<tbody>
<tr><td>Total Work Orders</td><td class="right">${fmtN(_wos.length)}</td></tr>
<tr><td>Approved</td><td class="right">${fmtN(wm.approved.length)}</td></tr>
<tr><td>Pending (in progress)</td><td class="right">${fmtN(wm.pending.length)}</td></tr>
<tr><td>Rejected</td><td class="right">${fmtN(wm.rejected.length)}</td></tr>
<tr><td>Total Planned Units</td><td class="right">${fmtN(wm.totalUnits)}</td></tr>
<tr class="tot"><td>Avg. Approval Cycle</td><td class="right">${wm.avgCycle.toFixed(1)} d</td></tr>
</tbody>
</table></div>`:notAuthorized('Work Orders')}
</div>
<div class="card">
<div class="card-hd"><h3>Quality Summary</h3></div>
<div class="card-sub">Actual vs. target, current filter</div>
${_rejAllowed||_woAllowed?`<div class="tbl-wrap"><table>
<thead><tr><th>Parameter</th><th class="right">Actual</th><th class="right">Target</th><th class="right">Status</th></tr></thead>
<tbody>
${_rejAllowed?`<tr><td>Net Rejection %</td><td class="right">${a.avgPct}</td><td class="right">≤ 3.00</td><td class="right">${a.avgPct<=3?'<span class="chip-ok">On Target</span>':'<span class="chip-warn">Watch</span>'}</td></tr>
<tr><td>Rework Recovery %</td><td class="right">${a.totalGross?Math.round(a.savedByRework/a.totalGross*100):0}</td><td class="right">≥ 55.0</td><td class="right">${(a.totalGross?a.savedByRework/a.totalGross*100:0)>=55?'<span class="chip-ok">On Target</span>':'<span class="chip-warn">Watch</span>'}</td></tr>`:''}
${_woAllowed?`<tr><td>WO Approval Cycle</td><td class="right">${wm.avgCycle.toFixed(1)} d</td><td class="right">≤ 2.0 d</td><td class="right">${wm.avgCycle<=2?'<span class="chip-ok">On Target</span>':'<span class="chip-warn">Watch</span>'}</td></tr>`:''}
</tbody>
</table></div>`:notAuthorized('this data')}
</div>
</div>
<footer>Live from <a href="/work-order">Work Orders</a> and <a href="/rejection-analytics">Rejection Register</a>.</footer>
`;
wire();
}
function wire(){
if(q('#f-co'))q('#f-co').onchange=function(){_co=this.value;};
if(q('#f-from'))q('#f-from').onchange=function(){_from=this.value;};
if(q('#f-to'))q('#f-to').onchange=function(){_to=this.value;};
if(q('#f-apply'))q('#f-apply').onclick=loadAll;
}
(async function init(){
try{if(window.fetchCompanyList){_companies=await window.fetchCompanyList();if(!_companies.some(c=>c.value===_co)&&_companies.length)_co=_companies[0].value;}}catch(_e){}
loadAll();
})();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>