297 lines
17 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
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 & 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> ${fmtN(r.qty)}</b><br/>Receipts <b> ${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>
|