420 lines
28 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
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 & 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 & 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 & 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=25"></script>
|
|
</body>
|
|
</html>
|