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

1137 lines
73 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Rejection Analytics — 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;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:12px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5);--shadow-sm:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.22)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);background-image:radial-gradient(circle at 12% 0%,rgba(159,90,253,.06),transparent 45%),radial-gradient(circle at 88% 8%,rgba(26,111,255,.05),transparent 40%);background-attachment:fixed;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 40px;max-width:1360px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.page-icon{width:38px;height:38px;flex-shrink:0;border-radius:10px;background:linear-gradient(135deg,var(--purple),var(--blue));display:flex;align-items:center;justify-content:center;font-family:var(--display);font-size:16px;letter-spacing:.5px;color:#fff;box-shadow:0 4px 14px rgba(159,90,253,.35)}
h2{font-size:20px;font-weight:700;letter-spacing:-.2px;color:var(--text);margin-bottom:1px}
.sub{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px}
.tabs{display:flex;gap:6px;margin-bottom:16px;background:var(--surface3);padding:4px;border-radius:10px;border:1px solid var(--border);width:fit-content}
.tab{padding:7px 16px;border-radius:7px;border:none;background:transparent;color:var(--text3);font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;transition:background .12s,color .12s}
.tab:hover{color:var(--text2)}
.tab.on{background:rgba(159,90,253,.16);color:var(--purple)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;margin-bottom:16px;box-shadow:var(--shadow-sm)}
.card h3{font-size:14.5px;color:var(--text);margin-bottom:4px;font-weight:700}
.section-hd{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:4px}
.section-hd h3{margin-bottom:0}
.eyebrow{font-size:9.5px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-weight:700}
.hint{font-size:11.5px;color:var(--text2);margin-bottom:14px;line-height:1.55}
.row{display:flex;gap:14px;flex-wrap:wrap}
.fg{flex:1;min-width:140px;margin-bottom:12px}
.fg label{display:block;font-size:10.5px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-bottom:5px;font-weight:700}
.fi{width:100%;padding:9px 10px;background:var(--surface3);border:1px solid var(--border2);border-radius:7px;color:var(--text);font-size:13px;font-family:inherit;transition:border-color .12s}
.fi:focus{outline:none;border-color:var(--purple)}
select.fi{cursor:pointer;appearance:none;background:var(--surface3) 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") no-repeat right 10px center;padding-right:26px}
.btn{padding:9px 18px;border-radius:7px;border:none;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;transition:filter .12s,transform .12s}
.btn:hover:not(:disabled){filter:brightness(1.1)}
.btn:active:not(:disabled){transform:translateY(1px)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--purple);color:#fff;box-shadow:0 2px 10px rgba(159,90,253,.3)}
.b-ghost{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.3)}
table{width:100%;border-collapse:separate;border-spacing:0;font-size:12.5px}
th{text-align:left;padding:9px 12px;color:var(--text3);font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;border-bottom:1px solid var(--border2);background:var(--surface2);white-space:nowrap}
td{padding:10px 12px;border-bottom:1px solid var(--border);color:var(--text);vertical-align:middle;white-space:nowrap}
tr:last-child td{border-bottom:none}
tbody tr{transition:background .1s}
tbody tr:hover td{background:rgba(255,255,255,.025)}
.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:10px}
.table-wrap table{min-width:100%}
.table-wrap thead th:first-child{border-top-left-radius:9px}
.table-wrap thead th:last-child{border-top-right-radius:9px}
.right{text-align:right}
.mono{font-family:var(--mono)}
.select-sm{padding:6px 26px 6px 10px;font-size:11.5px}
.toolbar{display:flex;flex-direction:column;gap:12px}
.toolbar-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.toolbar-label{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;font-weight:700;align-self:center;margin-right:2px}
.filter-row{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
.chip{padding:6px 13px;border-radius:999px;font-size:11px;font-weight:700;background:var(--surface2);color:var(--text3);border:1px solid var(--border2);cursor:pointer;transition:background .12s,color .12s,border-color .12s}
.chip:hover{color:var(--text2);border-color:var(--border2)}
.chip.on{background:rgba(159,90,253,.15);color:var(--purple);border-color:rgba(159,90,253,.4)}
.kpi-row{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px}
.kpi{position:relative;overflow:hidden;flex:1;min-width:170px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px 16px 22px;box-shadow:var(--shadow-sm);transition:transform .12s,border-color .12s}
.kpi:hover{transform:translateY(-2px);border-color:var(--border2)}
.kpi::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--accent,var(--purple))}
.kpi .kl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-bottom:7px;font-weight:700}
.kpi .kv{font-size:27px;font-weight:800;color:var(--text);font-family:var(--mono);line-height:1}
.kpi .kd{font-size:11px;margin-top:6px;font-weight:600;color:var(--text2)}
.legend{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-top:8px;flex-wrap:wrap}
.legend span{display:flex;align-items:center;gap:5px}
.dot{width:9px;height:9px;border-radius:2px;flex-shrink:0}
.badge{display:inline-block;font-size:9px;font-weight:700;padding:3px 8px;border-radius:999px;background:rgba(159,90,253,.14);color:var(--purple);margin-right:6px;letter-spacing:.2px}
.badge-blue{background:rgba(26,111,255,.14);color:var(--blue)}
.badge-purple{background:rgba(159,90,253,.14);color:var(--purple)}
.badge-amber{background:rgba(245,166,35,.15);color:var(--amber)}
.badge-green{background:rgba(13,187,138,.14);color:var(--green)}
.badge-grey{background:rgba(61,84,112,.25);color:var(--text2)}
.chart-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:16px}
.chart-grid>.card{margin-bottom:0}
@media (max-width:980px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:640px){.chart-grid{grid-template-columns:1fr}}
.empty-msg{text-align:center;padding:28px 12px;color:var(--text3);font-size:12px}
.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)}}
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);max-width:320px}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:20px;overflow-y:auto}
.modal-bg.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:900px;margin:auto;box-shadow:var(--shadow)}
.modal-hd{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-hd h3{margin:0}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);cursor:pointer;font-size:14px;transition:background .12s,border-color .12s}
.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:16px;max-height:72vh;overflow-y:auto}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<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="/rejection-register" class="nav-link">Rejection Register</a><a href="/rejection-analytics" class="nav-link active">Rejection Analytics</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></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';localStorage.removeItem('token');localStorage.removeItem('portal_user');window.location.href='/';};
function hasStepPerm(fullKey,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
for(const s of steps){
if(typeof s==='string'){ if(s===fullKey) return true; }
else if(s&&s.step===fullKey){ return Array.isArray(s.perms)&&s.perms.includes(perm); }
}
return false;
}
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
// Analytics/Batch Log are a QA-side view — holding only the Entry step
// (submit-only) does NOT grant this; that's a completely separate page
// (rejection-register.html). Confirmed access model: Entry-only users must
// be confined to submitting entries, never see plant-wide analytics or
// other operators' data.
const CAN_VIEW=hasStepAssigned('rejection_register:qa_edit')||hasStepAssigned('rejection_register:causes_setup');
const CAN_SETUP_VIEW=hasStepAssigned('rejection_register:causes_setup');
const CAN_SETUP_ADD=hasStepPerm('rejection_register:causes_setup','add');
const CAN_SETUP_EDIT=hasStepPerm('rejection_register:causes_setup','edit');
const CAN_SETUP_DELETE=hasStepPerm('rejection_register:causes_setup','delete');
const CAN_QA_EDIT=hasStepPerm('rejection_register:qa_edit','approve');
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok||d.success===false)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
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 statusBadge(status){
const cls=status==='QA_EDITED'?'badge-purple':status==='SUBMITTED'?'badge-blue':'badge-grey';
const label=status==='QA_EDITED'?'QA Edited':status==='SUBMITTED'?'Submitted':(status||'-');
return `<span class="badge ${cls}">${esc(label)}</span>`;
}
// Stage master — loaded fresh in init(); no built-in sample stages, an
// admin adds their own real ones below (loadStages()/addStage()/deleteStage()).
let STAGES=[];
let _stagesLoading=false;
let _newStage={v:'',label:''};
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 _tab=CAN_VIEW?'analytics':'setup';
let _analyticsStage='';
let _analyticsFrom='',_analyticsTo='';
let _analytics=null,_analyticsLoading=false;
let _batchLog=[],_batchLoading=false;
// Deep-dive charts — pure client-side aggregation over the already-filtered
// _batchLog (same Company/Stage/Date filters as the rest of the tab), no
// extra fetch needed since listEntries() already returns each entry's full
// causes[] array.
let _chartGran='month'; // 'batch'|'day'|'week'|'month'|'year' — shared time bucket for all 3 charts
let _sfgBucketKey=''; // '' = all periods combined, else one bucket key (SFG chart's own drill-down)
let _causes=[],_causesLoading=false,_causesLoaded=false;
let _newCause={stage:'',name:'',rootCause:''};
let _causesPage=0;
const CAUSES_PAGE_SIZE=15;
let _pending=[],_pendingLoading=false,_pendingLoaded=false; // Stages with free-text entries awaiting QA follow-up
// QA-edit modal — re-code one entry's causes (fixes exactly what the
// "Needs Cause Review" list points at: who recorded it, and lets QA
// actually correct it instead of just flagging it).
let _editEntry=null; // the entry object currently open in the modal, or null
let _editCauses=null; // working copy of its causes being edited
let _editRemarks='';
let _editStageCauses=[]; // that entry's Stage's active causes, for the picker
let _editLoading=false;
let _editSaving=false;
function render(){
const app=q('#app');
if(!CAN_VIEW&&!CAN_SETUP_VIEW){
app.innerHTML='<div class="card"><div class="empty-msg">You\'re not assigned to view Rejection Analytics or Root Cause Setup. Holding only the "Entry" step lets you submit entries via the Rejection Register page, but not see analytics here — ask an admin to grant "QA Edit After Submit" or "Root Cause Setup" in User Management if you believe this is incorrect.</div></div>';
return;
}
const tabs=[...(CAN_VIEW?[{k:'analytics',l:'Analytics'}]:[]),...(CAN_SETUP_VIEW?[{k:'setup',l:'Root Cause Setup'}]:[])];
app.innerHTML=`<div class="page-head"><div class="page-icon">RA</div><div><h2>Rejection ${_tab==='setup'?'Root Cause Setup':'Analytics'}</h2><div class="sub">Net rejection after rework, plant-wide</div></div></div>
<div class="tabs">${tabs.map(t=>`<button class="tab ${_tab===t.k?'on':''}" data-tab="${t.k}">${t.l}</button>`).join('')}</div>
<div id="tab-body">${_tab==='setup'?renderSetup():renderAnalytics()}</div>
${renderEditModal()}`;
wire();
if(_tab==='analytics'&&!_analytics&&!_analyticsLoading)loadAnalytics();
if(_tab==='setup'&&!_causesLoaded&&!_causesLoading)loadCauses();
if(_tab==='setup'&&!_pendingLoaded&&!_pendingLoading)loadPending();
}
function renderAnalytics(){
if(_analyticsLoading)return `<div class="card"><div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div></div>`;
if(!_analytics)return `<div class="card"><div class="empty-msg">No data yet.</div></div>`;
const a=_analytics;
return `
<div class="card">
<div class="toolbar">
<div class="toolbar-row">
<div class="fg" style="max-width:200px;margin-bottom:0"><label>Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div class="fg" style="max-width:150px;margin-bottom:0"><label>From</label><input class="fi" id="f-from" type="date" value="${esc(_analyticsFrom)}"/></div>
<div class="fg" style="max-width:150px;margin-bottom:0"><label>To</label><input class="fi" id="f-to" type="date" value="${esc(_analyticsTo)}"/></div>
<button class="btn b-ghost" id="btn-refresh">Refresh</button>
<button class="btn b-primary" id="btn-export" style="margin-left:auto">⬇ Export Excel</button>
</div>
<div class="toolbar-row">
<span class="toolbar-label">Stage</span>
${['',...STAGES.filter(s=>s.active!==false).map(s=>s.v)].map(s=>`<button class="chip ${_analyticsStage===s?'on':''}" data-stage="${s}">${s?STAGES.find(x=>x.v===s)?.label||s:'All Stages'}</button>`).join('')}
</div>
</div>
</div>
<div class="kpi-row">
<div class="kpi" style="--accent:var(--purple)"><div class="kl">Net rejection %</div><div class="kv">${a.avgPct}%</div><div class="kd">${a.batchCount} batch${a.batchCount!==1?'es':''} in range</div></div>
<div class="kpi" style="--accent:var(--red)"><div class="kl">Total rejected (units)</div><div class="kv">${a.totalGross}</div><div class="kd">Net after rework: ${a.totalNet}</div></div>
<div class="kpi" style="--accent:var(--green)"><div class="kl">Saved by rework</div><div class="kv">${a.savedByRework}</div><div class="kd">${a.totalGross?Math.round(a.savedByRework/a.totalGross*100):0}% of gross recovered</div></div>
</div>
<div class="card">
<div class="section-hd"><h3>Deep-dive charts</h3></div>
<div class="hint">Trend, cause and SFG breakdowns, bucketed at the granularity below — these follow the Company/Stage/Date filters above.</div>
<div class="filter-row" style="margin-bottom:0">
<span class="toolbar-label">View by</span>
${['batch','day','week','month','year'].map(g=>`<button class="chip ${_chartGran===g?'on':''}" data-gran="${g}">${g[0].toUpperCase()+g.slice(1)}</button>`).join('')}
</div>
</div>
<div class="chart-grid">
<div class="card">
<div class="section-hd"><h3>Net rejection trend</h3><span class="eyebrow">Daily</span></div>
<div class="hint">Daily net rejection as a share of batch quantity inspected. The pale band is gross rejection before rework — the gap between the two lines is what rework saved.</div>
${a.trend.length?trendSvg(a.trend):'<div class="empty-msg">Not enough data yet to plot a trend.</div>'}
<div class="legend"><span><i class="dot" style="background:var(--purple)"></i> Net (after rework)</span><span><i class="dot" style="background:var(--border2)"></i> Gross (before rework)</span></div>
</div>
<div class="card">
<div class="section-hd"><h3>Top causes</h3></div>
<div class="hint">Net quantity rejected, biggest first, across every batch in the current filter.</div>
${a.topCauses.length?`<div class="table-wrap"><table><thead><tr><th>Cause</th><th class="right">Net Qty</th></tr></thead><tbody>
${a.topCauses.map(c=>`<tr><td>${esc(c.name)}</td><td class="right mono">${c.qty}</td></tr>`).join('')}
</tbody></table></div>`:'<div class="empty-msg">No cause-level data yet.</div>'}
</div>
${renderCauseBreakdownCharts()}
${renderProducedTrendChart()}
${renderSfgBreakdownChart()}
</div>
<div class="card">
<div class="section-hd"><h3>Batch log</h3><span class="eyebrow">${_batchLog.length} entr${_batchLog.length!==1?'ies':'y'}</span></div>
${_batchLoading?'<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div>':!_batchLog.length?'<div class="empty-msg">No entries in this filter.</div>':`<div class="table-wrap"><table><thead><tr><th>Production Date</th><th>Entry Date</th><th>Batch</th><th>Product</th><th>Stage</th><th>Shift</th><th>Recorded By</th><th class="right">Batch size</th><th class="right">Rejected</th><th class="right">Net</th><th class="right">Net %</th><th>Status</th><th></th></tr></thead><tbody>
${_batchLog.map(b=>`<tr${b.needsCauseSetup?' style="background:rgba(245,166,35,.07)"':''}><td>${esc(b.prodDate)}</td><td>${b.createdAt?esc(new Date(b.createdAt).toLocaleString()):'-'}</td><td class="mono">${esc(b.batchNo)}</td><td class="mono">${esc(b.productCode||'-')}</td><td>${esc(STAGES.find(s=>s.v===b.stage)?.label||b.stage)}</td><td>${esc((b.shift||'').split(' ')[0])}</td><td>${esc(b.createdByName||b.createdBy||'-')}</td><td class="right mono">${b.batchSize}</td><td class="right mono">${b.grossQty}</td><td class="right mono" style="font-weight:700">${b.netQty}</td><td class="right mono">${b.netPct}%</td><td>${statusBadge(b.status)}${b.needsCauseSetup?'<span class="badge badge-amber">Needs Cause</span>':''}</td><td>${CAN_QA_EDIT?`<button class="btn b-ghost" data-edit-entry="${b.id}" style="padding:5px 10px;font-size:11px">Edit</button>`:''}</td></tr>`).join('')}
</tbody></table></div>`}
</div>`;
}
function trendSvg(trend){
const w=900,h=140,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:150px" 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(--purple)" stroke-width="2.5"/>
${trend.map((p,i)=>`<circle cx="${xAt(i)}" cy="${yAt(p.netPct)}" r="3" fill="var(--purple)"/>`).join('')}
</svg>`;
}
// ── Deep-dive charts ────────────────────────────────────────────────────
// Categorical order per the dataviz skill's validated dark-mode steps
// (blue, orange, aqua, yellow, magenta, green) — passes the adjacent-pair
// CVD/contrast gates for stacked bars. "Other" gets a flat neutral grey,
// outside the sequence, never a 7th hue.
const CHART_HUES=['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300'];
const CHART_OTHER='#3d5470';
// Cause -> color, assigned in first-seen order and kept for the rest of the
// session — color must follow the cause's identity, not its rank in
// whichever filtered/bucketed view happens to be on screen, or the same
// cause would repaint every time the Stage/Period filter changes.
let _causeColorMap={};
function colorForCause(name){
if(name==='Other')return CHART_OTHER;
if(!_causeColorMap[name]){
const used=Object.keys(_causeColorMap).length;
_causeColorMap[name]=used<CHART_HUES.length?CHART_HUES[used]:CHART_OTHER;
}
return _causeColorMap[name];
}
function isoWeekLabel(d){
const dt=new Date(d+'T00:00:00');
const target=new Date(dt);target.setDate(dt.getDate()+4-(dt.getDay()||7));
const yearStart=new Date(target.getFullYear(),0,1);
const week=Math.ceil((((target-yearStart)/86400000)+1)/7);
return `${target.getFullYear()}-W${String(week).padStart(2,'0')}`;
}
function bucketKeyOf(e,gran){
const d=e.prodDate||(e.createdAt||'').slice(0,10);
if(gran==='batch')return e.batchNo||'(no batch)';
if(!d)return '(no date)';
if(gran==='day')return d;
if(gran==='week')return isoWeekLabel(d);
if(gran==='month')return d.slice(0,7);
return d.slice(0,4);
}
function bucketLabelOf(key,gran){
if(gran==='month'&&/^\d{4}-\d{2}$/.test(key)){const[y,m]=key.split('-');return new Date(+y,+m-1,1).toLocaleDateString('en-GB',{month:'short',year:'2-digit'});}
if(gran==='day'&&/^\d{4}-\d{2}-\d{2}$/.test(key)){const dt=new Date(key+'T00:00:00');return dt.toLocaleDateString('en-GB',{day:'2-digit',month:'short'});}
return key;
}
function buildBuckets(entries,gran){
const map={};
entries.forEach(e=>{
const k=bucketKeyOf(e,gran);
const d=e.prodDate||(e.createdAt||'').slice(0,10)||'';
if(!map[k])map[k]={key:k,sortKey:gran==='batch'?(d||'9999')+'_'+k:k,label:bucketLabelOf(k,gran)};
});
return Object.values(map).sort((a,b)=>a.sortKey<b.sortKey?-1:a.sortKey>b.sortKey?1:0);
}
// Generic stacked-bar SVG — used for both the time-bucketed cause charts
// and the SFG-by-cause chart (same shape: named x-categories, named
// color-stable series). A 2px surface gap between stacked segments (inset
// 1px top/bottom on each rect) keeps adjacent same-column fills visually
// separated even when colors are close.
function stackedBarSvg(buckets,series,opts={}){
if(!buckets.length||!series.length)return '<div class="empty-msg">Not enough data yet to chart.</div>';
const barW=opts.barW||42,gapW=opts.gapW||14,h=opts.h||180,padT=10,padB=24,padL=4,padR=4;
const n=buckets.length;
const w=padL+padR+n*barW+(n-1)*gapW;
const totals=buckets.map((_,i)=>series.reduce((s,sr)=>s+(sr.values[i]||0),0));
const maxV=Math.max(1,...totals)*1.12;
const usableH=h-padT-padB;
let bars='';
buckets.forEach((b,i)=>{
const x=padL+i*(barW+gapW);
let yCum=0;
series.forEach(sr=>{
const v=sr.values[i]||0;if(v<=0)return;
const segH=usableH*(v/maxV);
const y=h-padB-yCum-segH;
bars+=`<rect x="${x}" y="${(y+1).toFixed(1)}" width="${barW}" height="${Math.max(0,segH-2).toFixed(1)}" rx="2" fill="${sr.color}"><title>${esc(b.label)} — ${esc(sr.name)}: ${v}</title></rect>`;
yCum+=segH;
});
bars+=`<text x="${x+barW/2}" y="${h-6}" text-anchor="middle" font-size="9" fill="var(--text3)">${esc(b.label)}</text>`;
});
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;min-width:${w}px;height:${h}px" preserveAspectRatio="xMinYMid meet">${bars}</svg>`;
}
function barSvg(buckets,values,opts={}){
const barW=opts.barW||42,gapW=opts.gapW||14,h=opts.h||110,padT=10,padB=24,padL=4,padR=4,color=opts.color||'var(--blue)';
const n=buckets.length;if(!n)return '<div class="empty-msg">Not enough data yet to chart.</div>';
const w=padL+padR+n*barW+(n-1)*gapW;
const maxV=Math.max(1,...values)*1.12;
const usableH=h-padT-padB;
let bars='';
buckets.forEach((b,i)=>{
const x=padL+i*(barW+gapW),v=values[i]||0,bh=usableH*(v/maxV);
bars+=`<rect x="${x}" y="${(h-padB-bh).toFixed(1)}" width="${barW}" height="${Math.max(0,bh).toFixed(1)}" rx="3" fill="${color}"><title>${esc(b.label)}: ${v}</title></rect>`;
bars+=`<text x="${x+barW/2}" y="${h-6}" text-anchor="middle" font-size="9" fill="var(--text3)">${esc(b.label)}</text>`;
});
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;min-width:${w}px;height:${h}px" preserveAspectRatio="xMinYMid meet">${bars}</svg>`;
}
function lineSvg(buckets,values,opts={}){
const barW=opts.barW||42,gapW=opts.gapW||14,h=opts.h||110,padT=14,padB=24,padL=4,padR=4,color=opts.color||'var(--purple)';
const n=buckets.length;if(!n)return '<div class="empty-msg">Not enough data yet to chart.</div>';
const w=padL+padR+n*barW+(n-1)*gapW;
const maxV=Math.max(1,...values)*1.15;
const usableH=h-padT-padB;
const xAt=i=>padL+i*(barW+gapW)+barW/2;
const yAt=v=>h-padB-usableH*(v/maxV);
const path=values.map((v,i)=>`${i===0?'M':'L'}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ');
const pts=values.map((v,i)=>`<circle cx="${xAt(i).toFixed(1)}" cy="${yAt(v).toFixed(1)}" r="3" fill="${color}"><title>${esc(buckets[i].label)}: ${v}%</title></circle>`).join('');
const labels=buckets.map((b,i)=>`<text x="${xAt(i).toFixed(1)}" y="${h-6}" text-anchor="middle" font-size="9" fill="var(--text3)">${esc(b.label)}</text>`).join('');
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;min-width:${w}px;height:${h}px" preserveAspectRatio="xMinYMid meet"><path d="${path}" fill="none" stroke="${color}" stroke-width="2.5"/>${pts}${labels}</svg>`;
}
function chartLegend(series){
return `<div class="legend" style="flex-wrap:wrap">${series.map(s=>`<span><i class="dot" style="background:${s.color}"></i>${esc(s.name)}</span>`).join('')}</div>`;
}
// Chart 1 — cause-wise Net / Sent-for-rework, bucketed by the shared Period
// granularity. Net per cause matches computeTotals()'s batch-level formula:
// rejected + (rework − ok). Top 5 causes by total, rest folded into "Other".
function aggregateCauseBreakdown(entries,gran,metric){
const buckets=buildBuckets(entries,gran);
const idx={};buckets.forEach((b,i)=>idx[b.key]=i);
const perCause={};
entries.forEach(e=>{
const bi=idx[bucketKeyOf(e,gran)];
(e.causes||[]).forEach(c=>{
if(!c.name)return;
const rejected=parseFloat(c.rejected)||0,rework=parseFloat(c.rework)||0,ok=parseFloat(c.ok)||0;
const val=metric==='net'?(rejected+(rework-ok)):rework;
if(!val)return;
if(!perCause[c.name])perCause[c.name]=new Array(buckets.length).fill(0);
perCause[c.name][bi]+=val;
});
});
const totals=Object.entries(perCause).map(([name,arr])=>({name,total:arr.reduce((s,v)=>s+v,0),arr})).sort((a,b)=>b.total-a.total);
const top=totals.slice(0,5),rest=totals.slice(5);
const series=top.map(t=>({name:t.name,color:colorForCause(t.name),values:t.arr}));
if(rest.length){
const otherArr=new Array(buckets.length).fill(0);
rest.forEach(r=>r.arr.forEach((v,i)=>otherArr[i]+=v));
series.push({name:'Other',color:CHART_OTHER,values:otherArr});
}
return {buckets,series};
}
function renderCauseBreakdownCharts(){
if(!_batchLog.length)return '';
const net=aggregateCauseBreakdown(_batchLog,_chartGran,'net');
const rw=aggregateCauseBreakdown(_batchLog,_chartGran,'rework');
return `<div class="card">
<div class="section-hd"><h3>Cause-wise Net rejection</h3><span class="eyebrow">By ${_chartGran}</span></div>
<div class="hint">Top 5 causes by net quantity, remainder folded into "Other".</div>
<div style="overflow-x:auto">${stackedBarSvg(net.buckets,net.series)}</div>
${chartLegend(net.series)}
</div>
<div class="card">
<div class="section-hd"><h3>Cause-wise Sent-for-rework</h3><span class="eyebrow">By ${_chartGran}</span></div>
<div class="hint">Quantity routed to rework, by cause, over the same periods.</div>
<div style="overflow-x:auto">${stackedBarSvg(rw.buckets,rw.series)}</div>
${chartLegend(rw.series)}
</div>`;
}
// Chart 2 — produced quantity (sum of Batch Size) alongside Net rejection %,
// same Period buckets, two stacked panels sharing one x scale (never a
// dual-axis combo on one plot).
function aggregateProducedTrend(entries,gran){
const buckets=buildBuckets(entries,gran);
const idx={};buckets.forEach((b,i)=>idx[b.key]=i);
const produced=new Array(buckets.length).fill(0);
const net=new Array(buckets.length).fill(0);
const size=new Array(buckets.length).fill(0);
entries.forEach(e=>{
const i=idx[bucketKeyOf(e,gran)];
produced[i]+=e.batchSize||0;
net[i]+=e.netQty||0;
size[i]+=e.batchSize||0;
});
const netPct=buckets.map((_,i)=>size[i]?Math.round((net[i]/size[i])*1000)/10:0);
return {buckets,produced,netPct};
}
function renderProducedTrendChart(){
if(!_batchLog.length)return '';
const t=aggregateProducedTrend(_batchLog,_chartGran);
return `<div class="card">
<div class="section-hd"><h3>Produced qty vs Net rejection %</h3><span class="eyebrow">By ${_chartGran}</span></div>
<div class="hint">Top: total units produced (batch size) per period. Bottom: net rejection as a % of that quantity — read together to see whether a rejection spike tracks volume or breaks away from it.</div>
<div style="overflow-x:auto">${barSvg(t.buckets,t.produced,{color:'var(--blue)'})}</div>
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Produced qty (units)</span></div>
<div style="overflow-x:auto;margin-top:10px">${lineSvg(t.buckets,t.netPct,{color:'var(--purple)'})}</div>
<div class="legend"><span><i class="dot" style="background:var(--purple)"></i>Net rejection %</span></div>
</div>`;
}
// Chart 3 — cause-wise rejection by SFG (product code), for either a single
// Period bucket (drill-down picker) or all periods combined. Top 6 SFGs by
// Net qty, top 5 causes overall within that scope; everything else folds
// into "Other" on both axes so the chart never grows unbounded.
function aggregateSfgCauseBreakdown(entries,gran,bucketKey){
const filtered=bucketKey?entries.filter(e=>bucketKeyOf(e,gran)===bucketKey):entries;
const perSfg={};
filtered.forEach(e=>{
const code=e.productCode||'(no code)';
if(!perSfg[code])perSfg[code]={code,causeTotals:{},total:0};
(e.causes||[]).forEach(c=>{
if(!c.name)return;
const rejected=parseFloat(c.rejected)||0,rework=parseFloat(c.rework)||0,ok=parseFloat(c.ok)||0;
const net=rejected+(rework-ok);
if(!net)return;
perSfg[code].causeTotals[c.name]=(perSfg[code].causeTotals[c.name]||0)+net;
perSfg[code].total+=net;
});
});
const sfgList=Object.values(perSfg).sort((a,b)=>b.total-a.total);
const top=sfgList.slice(0,6),rest=sfgList.slice(6);
const cats=top.map(s=>({key:s.code,label:s.code}));
if(rest.length)cats.push({key:'__other__',label:'Other'});
const causeTotalsAll={};
sfgList.forEach(s=>Object.entries(s.causeTotals).forEach(([name,v])=>{causeTotalsAll[name]=(causeTotalsAll[name]||0)+v;}));
const topCauseNames=Object.entries(causeTotalsAll).sort((a,b)=>b[1]-a[1]).slice(0,5).map(([name])=>name);
const series=topCauseNames.map(name=>({name,color:colorForCause(name),values:cats.map(cat=>{
if(cat.key==='__other__')return rest.reduce((s,r)=>s+(r.causeTotals[name]||0),0);
const s=perSfg[cat.key];return s?(s.causeTotals[name]||0):0;
})}));
const otherVals=cats.map(cat=>{
if(cat.key==='__other__'){
let sum=0;rest.forEach(r=>Object.entries(r.causeTotals).forEach(([n,v])=>{if(!topCauseNames.includes(n))sum+=v;}));
return sum;
}
const s=perSfg[cat.key];if(!s)return 0;
return Object.entries(s.causeTotals).reduce((sum,[n,v])=>sum+(topCauseNames.includes(n)?0:v),0);
});
if(otherVals.some(v=>v>0))series.push({name:'Other',color:CHART_OTHER,values:otherVals});
return {buckets:cats,series};
}
function renderSfgBreakdownChart(){
if(!_batchLog.length)return '';
const allBuckets=buildBuckets(_batchLog,_chartGran);
const bd=aggregateSfgCauseBreakdown(_batchLog,_chartGran,_sfgBucketKey);
return `<div class="card">
<div class="section-hd"><h3>Cause-wise rejection by SFG</h3></div>
<div class="hint">Net rejection by cause for each semi-finished good — top 6 SFGs by volume, remainder folded into "Other".</div>
<div class="fg" style="max-width:220px;margin-bottom:12px"><label>Period</label><select class="fi select-sm" id="sel-sfg-bucket">
<option value="" ${_sfgBucketKey?'':'selected'}>All periods</option>
${allBuckets.map(b=>`<option value="${esc(b.key)}" ${_sfgBucketKey===b.key?'selected':''}>${esc(b.label)}</option>`).join('')}
</select></div>
${bd.buckets.length?`<div style="overflow-x:auto">${stackedBarSvg(bd.buckets,bd.series)}</div>${chartLegend(bd.series)}`:'<div class="empty-msg">No cause-level data for this period.</div>'}
</div>`;
}
// Root cause assignment table, paginated client-side (CAUSES_PAGE_SIZE per
// page) — this list only grows over time as more causes are added, so it
// needs a pager rather than dumping every row on one screen.
function renderCausesTable(){
if(_causesLoading)return '<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div>';
if(!_causes.length)return '<div class="empty-msg">No causes configured yet — add one above.</div>';
const totalPages=Math.max(1,Math.ceil(_causes.length/CAUSES_PAGE_SIZE));
if(_causesPage>=totalPages)_causesPage=totalPages-1;
if(_causesPage<0)_causesPage=0;
const pageCauses=_causes.slice(_causesPage*CAUSES_PAGE_SIZE,(_causesPage+1)*CAUSES_PAGE_SIZE);
const pager=totalPages>1?`<div class="filter-row" style="margin-top:10px;margin-bottom:0">
<button class="btn b-ghost" id="causes-prev" ${_causesPage===0?'disabled':''} style="padding:6px 12px;font-size:11px">← Prev</button>
<span style="font-size:11.5px;color:var(--text2)">Page ${_causesPage+1} of ${totalPages} (${_causes.length} causes)</span>
<button class="btn b-ghost" id="causes-next" ${_causesPage>=totalPages-1?'disabled':''} style="padding:6px 12px;font-size:11px">Next →</button>
</div>`:`<div style="font-size:11px;color:var(--text3);margin-top:8px">${_causes.length} cause${_causes.length!==1?'s':''}</div>`;
return `<div class="table-wrap"><table><thead><tr><th>Stage</th><th>Rejection cause</th><th>Root cause</th><th>Active</th><th></th></tr></thead><tbody>
${pageCauses.map(c=>`<tr>
<td>${esc(STAGES.find(s=>s.v===c.stage)?.label||c.stage)}</td>
<td><input class="fi select-sm" data-name-for="${c.id}" value="${esc(c.name)}" ${CAN_SETUP_EDIT?'':'disabled'}/></td>
<td><input class="fi select-sm" data-root-for="${c.id}" value="${esc(c.rootCause)}" ${CAN_SETUP_EDIT?'':'disabled'}/></td>
<td><input type="checkbox" data-active-for="${c.id}" ${c.active?'checked':''} ${CAN_SETUP_EDIT?'':'disabled'}/></td>
<td>${c.active?`<button class="btn b-danger" data-del-cause="${c.id}" ${CAN_SETUP_DELETE?'':'disabled'} style="padding:5px 10px;font-size:11px">Delete</button>`:'<span class="badge badge-grey">Deleted</span>'}</td>
</tr>`).join('')}
</tbody></table></div>${pager}`;
}
function renderSetup(){
const byStage={};
_causes.forEach(c=>{(byStage[c.stage]=byStage[c.stage]||[]).push(c);});
const activeStages=STAGES.filter(s=>s.active!==false);
const stagesWithNone=activeStages.filter(s=>!(byStage[s.v]||[]).filter(c=>c.active).length);
return `
<div class="card">
<div class="section-hd"><h3>Stage Master</h3></div>
<div class="hint">The manufacturing stages the Rejection Register can count against. Add one here before adding causes for it below. The Key is a stable ID and can't be changed once created — only the Label. "Delete" is a soft delete: the stage stops appearing as a pickable option everywhere, but existing causes/entries under it keep their data and correct label; uncheck/recheck Active to restore it.</div>
${_stagesLoading?'<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div>':`<div class="table-wrap"><table><thead><tr><th>Key</th><th>Label</th><th>Active</th><th></th></tr></thead><tbody>
${STAGES.map(s=>`<tr>
<td class="mono">${esc(s.v)}</td>
<td><input class="fi select-sm" data-label-for="${esc(s.v)}" value="${esc(s.label)}" ${CAN_SETUP_EDIT?'':'disabled'}/></td>
<td><input type="checkbox" data-active-stage="${esc(s.v)}" ${s.active!==false?'checked':''} ${CAN_SETUP_EDIT?'':'disabled'}/></td>
<td>${s.active!==false?`<button class="btn b-danger" data-del-stage="${esc(s.v)}" ${CAN_SETUP_EDIT?'':'disabled'} style="padding:5px 10px;font-size:11px">Delete</button>`:'<span class="badge badge-grey">Deleted</span>'}</td>
</tr>`).join('')}
</tbody></table></div>`}
<div class="row" style="margin-top:12px">
<div class="fg" style="max-width:160px"><label>Stage key</label><input class="fi" id="ns-key" value="${esc(_newStage.v)}" placeholder="e.g. needle"/></div>
<div class="fg"><label>Stage label</label><input class="fi" id="ns-label" value="${esc(_newStage.label)}" placeholder="e.g. Needle Assembly"/></div>
<div class="fg" style="max-width:120px;align-self:flex-end"><button class="btn b-primary" id="btn-add-stage" ${CAN_SETUP_EDIT?'':'disabled'} style="width:100%">+ Add Stage</button></div>
</div>
</div>
<div class="card">
<div class="section-hd"><h3>Add a rejection cause</h3></div>
<div class="hint">Once added here, it appears in the Entry wizard's Count step for that stage.</div>
<div class="row">
<div class="fg" style="max-width:200px"><label>Stage</label><select class="fi" id="nc-stage">
<option value="" ${_newCause.stage?'':'selected'}>— Select Stage —</option>
${activeStages.map(s=>`<option value="${s.v}" ${_newCause.stage===s.v?'selected':''}>${esc(s.label)}</option>`).join('')}
</select></div>
<div class="fg"><label>Cause name</label><input class="fi" id="nc-name" value="${esc(_newCause.name)}" placeholder="e.g. Leak — seal weld"/></div>
<div class="fg"><label>Root cause</label><input class="fi" id="nc-root" value="${esc(_newCause.rootCause)}" placeholder="e.g. Sealing station #2 — heater drift"/></div>
<div class="fg" style="max-width:120px;align-self:flex-end"><button class="btn b-primary" id="btn-add-cause" ${CAN_SETUP_ADD?'':'disabled'} style="width:100%">+ Add</button></div>
</div>
</div>
<div class="card">
<div class="section-hd"><h3>Root cause assignment</h3><span class="eyebrow">${_causes.length} cause${_causes.length!==1?'s':''}</span></div>
<div class="hint">Every rejection cause carries one root cause. Change an assignment here and the analytics follow immediately — line operators never see this screen. "Delete" is a soft delete (sets Active off): existing entries already recorded against this cause keep their data either way; uncheck/recheck Active to restore it.</div>
${renderCausesTable()}
</div>
<div class="card">
<div class="section-hd"><h3>Needs Cause Review — Pending QA Follow-up</h3>${_pending.length?`<span class="eyebrow" style="color:var(--amber)">${_pending.reduce((s,p)=>s+p.entries.length,0)} pending</span>`:''}</div>
<div class="hint">Entries awaiting a cause: either the free-text fallback (no causes were set up yet), or an operator used "Other / Unknown" because they didn't know which listed cause it was. There's no deadline/reminder on these — this list is the only tracking, so it stays until someone clicks Edit and re-codes it. Add/fix causes for the stage above first if needed.</div>
${_pendingLoading?'<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div>'
:!_pending.length?'<div class="empty-msg">Nothing pending — every submitted entry had causes to pick from.</div>'
:`<div class="table-wrap"><table><thead><tr><th>Stage</th><th>Batch No.</th><th>Recorded By</th><th>When</th><th></th></tr></thead><tbody>
${_pending.map(p=>p.entries.map(e=>`<tr><td>${esc(STAGES.find(s=>s.v===p.stage)?.label||p.stage)}</td><td class="mono">${esc(e.batchNo)}</td><td>${esc(e.createdByName||e.createdBy||'-')}</td><td>${e.createdAt?esc(new Date(e.createdAt).toLocaleString()):'-'}</td><td>${CAN_QA_EDIT?`<button class="btn b-primary" data-edit-entry="${e.id}" style="padding:5px 10px;font-size:11px">Edit</button>`:''}</td></tr>`).join('')).join('')}
</tbody></table></div>`}
</div>
<div class="card">
<div class="section-hd"><h3>Stages without causes set up</h3></div>
<div class="hint">These stages fall back to the free-text screen in the Entry wizard until at least one active cause is added above.</div>
${stagesWithNone.length?stagesWithNone.map(s=>`<span class="badge badge-amber">${esc(s.label)}</span>`).join(''):'<div style="color:var(--text3)">All active stages have causes configured.</div>'}
</div>`;
}
async function loadAnalytics(){
_analyticsLoading=true;_batchLoading=true;render();
const qs=`stage=${encodeURIComponent(_analyticsStage)}&from=${encodeURIComponent(_analyticsFrom)}&to=${encodeURIComponent(_analyticsTo)}&company=${encodeURIComponent(_co)}`;
try{
const [ar,br]=await Promise.all([
api('GET',`/rejection-register/analytics?${qs}`),
api('GET',`/rejection-register/entries?${qs}`),
]);
_analytics=ar.data;_batchLog=br.data||[];
}catch(e){toast(e.message,'err');_analytics={avgPct:0,totalGross:0,totalNet:0,savedByRework:0,topCauses:[],trend:[],batchCount:0};_batchLog=[];}
_analyticsLoading=false;_batchLoading=false;render();
}
// Styled .xlsx export via ExcelJS (SheetJS — used elsewhere in this app,
// e.g. salary.html — has no cell-styling support in its free build; ExcelJS
// does, which is the whole point of "professional view with border &
// color"). Exports the CURRENT filtered view (Stage/From/To/Company/Period
// granularity exactly as shown on screen), not the whole database, across
// three sheets: "Rejection Analytics" (title/meta block, Batch Log with a
// bordered/colored header, zebra-striped rows, an amber highlight on rows
// still awaiting cause review, and a Top Causes table), "Monthly Summary"
// (the ranked S.No/Type/Qty report), and "Deep-Dive Charts" (the same
// period-bucketed cause/rework/produced-qty/SFG breakdowns as the on-screen
// charts, as plain tables).
const XLSX_PURPLE='FF9F5AFD', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFF3F0FF', XLSX_AMBER='FFFDF0DA', XLSX_BORDER='FFB9AEEA';
function xlsxBorder(color){
const b={style:'thin',color:{argb:color||XLSX_BORDER}};
return {top:b,left:b,bottom:b,right:b};
}
// "JULY" / "AUGUST 2026" etc. — derived from the From-date filter when it's
// set (uppercased month name, + year only if different from the current
// year), otherwise a generic fallback since the filter may span more than
// one month.
function monthTitleLabel(){
if(_analyticsFrom){
const d=new Date(_analyticsFrom);
if(!isNaN(d)){
const now=new Date();
return d.toLocaleString('en-US',{month:'long'}).toUpperCase()+(d.getFullYear()!==now.getFullYear()?' '+d.getFullYear():'');
}
}
return 'ALL PERIODS';
}
// Second sheet — S.No / Rejection Type / Qty ranked list, styled to match
// a reference report: merged month-title bar, header row, then each data
// row alternating a lavender and a green fill on the Rejection Type cell,
// with the Qty cell always solid yellow — borders throughout.
function buildMonthlySummarySheet(wb,topCauses){
const LAVENDER='FFB4A7D6', GREEN='FFA9D18E', YELLOW='FFFFFF00', HDR_BG='FFD9D9D9';
const ws=wb.addWorksheet('Monthly Summary');
ws.mergeCells('A1:C1');
const title=ws.getCell('A1');
title.value=monthTitleLabel();
title.font={bold:true,size:14,color:{argb:'FF1F4E78'}};
title.alignment={vertical:'middle',horizontal:'center'};
title.border=xlsxBorder('FF808080');
ws.getRow(1).height=22;
const hdrRow=ws.getRow(2);
['S.NO','REJECTION TYPE','QTY'].forEach((h,i)=>{
const cell=hdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:'FF000000'}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:HDR_BG}};
cell.alignment={vertical:'middle',horizontal:i===1?'left':'center'};
cell.border=xlsxBorder('FF808080');
});
hdrRow.height=18;
topCauses.forEach((c,i)=>{
const row=ws.getRow(3+i);
const typeFill=i%2===0?LAVENDER:GREEN;
row.getCell(1).value=i+1;
row.getCell(1).alignment={vertical:'middle',horizontal:'right'};
row.getCell(1).border=xlsxBorder('FF808080');
row.getCell(2).value=c.name;
row.getCell(2).fill={type:'pattern',pattern:'solid',fgColor:{argb:typeFill}};
row.getCell(2).alignment={vertical:'middle',horizontal:'left',wrapText:true};
row.getCell(2).border=xlsxBorder('FF808080');
row.getCell(3).value=c.qty;
row.getCell(3).fill={type:'pattern',pattern:'solid',fgColor:{argb:YELLOW}};
row.getCell(3).alignment={vertical:'middle',horizontal:'right'};
row.getCell(3).border=xlsxBorder('FF808080');
});
ws.columns=[{width:8},{width:34},{width:10}];
}
// Generic bordered/banded table block — title bar, colored header row,
// zebra-striped data rows — used to lay out each Deep-dive chart's
// underlying numbers on the export sheet (same visual language as the
// Batch Log / Top Causes blocks above). Returns the next free row so
// callers can stack blocks with a blank-row gap between them.
function xlsxTableBlock(ws,startRow,title,headers,rows,opts={}){
const span=Math.max(1,headers.length);
ws.mergeCells(startRow,1,startRow,span);
const t=ws.getCell(startRow,1);
t.value=title;t.font={bold:true,size:12,color:{argb:XLSX_INK}};
const hdrRow=ws.getRow(startRow+1);
headers.forEach((h,i)=>{
const cell=hdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:opts.hdrColor||XLSX_PURPLE}};
cell.alignment={vertical:'middle',horizontal:i===0?'left':'center'};
cell.border=xlsxBorder('FF7C3AED');
});
hdrRow.height=20;
if(!rows.length){
const emptyRow=ws.getRow(startRow+2);
ws.mergeCells(startRow+2,1,startRow+2,span);
const cell=emptyRow.getCell(1);
cell.value='No data in this filter';cell.font={italic:true,color:{argb:'FF94A3B8'}};
cell.alignment={horizontal:'center'};cell.border=xlsxBorder();
return startRow+4;
}
rows.forEach((r,ri)=>{
const dataRow=ws.getRow(startRow+2+ri);
r.forEach((v,ci)=>{
const cell=dataRow.getCell(ci+1);
cell.value=v;
cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:ri%2?XLSX_ZEBRA:XLSX_WHITE}};
if(ci>0)cell.alignment={horizontal:'right'};
if((opts.boldCols||[]).includes(ci))cell.font={bold:true};
if((opts.pctCols||[]).includes(ci))cell.numFmt='0.0%';
});
});
return startRow+2+rows.length+2;
}
// A cause name -> Excel column list, "Other" always pinned last so every
// block reads the same way as the on-screen legend order.
function causeColumnOrder(names){
return [...names].sort((a,b)=>(a==='Other'?1:0)-(b==='Other'?1:0));
}
// Deep-dive sheet — the same three period-bucketed charts shown on screen
// (Cause-wise Net/Rework, Produced Qty vs Net%, Cause-wise by SFG), laid
// out as plain tables so the underlying numbers travel with the export,
// not just the Batch Log / Top Causes rollups the sheet already had.
function buildDeepDiveSheet(wb){
const ws=wb.addWorksheet('Deep-Dive Charts',{views:[{state:'frozen',ySplit:1}]});
ws.mergeCells('A1:G1');
const title=ws.getCell('A1');
title.value=`Deep-Dive Charts — bucketed by ${_chartGran}`;
title.font={bold:true,size:14,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_PURPLE}};
title.alignment={vertical:'middle',horizontal:'left'};
ws.getRow(1).height=24;
let row=3;
const net=aggregateCauseBreakdown(_batchLog,_chartGran,'net');
const rw=aggregateCauseBreakdown(_batchLog,_chartGran,'rework');
const causeNames=causeColumnOrder([...new Set([...net.series.map(s=>s.name),...rw.series.map(s=>s.name)])]);
function causeTableRows(agg){
return agg.buckets.map((b,i)=>{
const vals=causeNames.map(name=>{
const s=agg.series.find(sr=>sr.name===name);
return s?(s.values[i]||0):0;
});
return [b.label,...vals,vals.reduce((s,v)=>s+v,0)];
});
}
const causeHeaders=['Period',...causeNames,'Total'];
row=xlsxTableBlock(ws,row,`Cause-wise Net Rejection — by ${_chartGran}`,causeHeaders,causeTableRows(net),{boldCols:[causeHeaders.length-1]});
row=xlsxTableBlock(ws,row,`Cause-wise Sent-for-Rework — by ${_chartGran}`,causeHeaders,causeTableRows(rw),{boldCols:[causeHeaders.length-1]});
const t=aggregateProducedTrend(_batchLog,_chartGran);
const prodRows=t.buckets.map((b,i)=>[b.label,t.produced[i],(t.netPct[i]||0)/100]);
row=xlsxTableBlock(ws,row,`Produced Qty vs Net Rejection % — by ${_chartGran}`,['Period','Produced Qty','Net Rejection %'],prodRows,{pctCols:[2]});
const sfg=aggregateSfgCauseBreakdown(_batchLog,_chartGran,_sfgBucketKey);
const sfgCauseNames=causeColumnOrder([...new Set(sfg.series.map(s=>s.name))]);
const sfgRows=sfg.buckets.map((b,i)=>{
const vals=sfgCauseNames.map(name=>{
const s=sfg.series.find(sr=>sr.name===name);
return s?(s.values[i]||0):0;
});
return [b.label,...vals,vals.reduce((s,v)=>s+v,0)];
});
const sfgHeaders=['SFG',...sfgCauseNames,'Total'];
const periodLabel=_sfgBucketKey?(buildBuckets(_batchLog,_chartGran).find(b=>b.key===_sfgBucketKey)?.label||_sfgBucketKey):'All periods';
xlsxTableBlock(ws,row,`Cause-wise Rejection by SFG — ${periodLabel}`,sfgHeaders,sfgRows,{boldCols:[sfgHeaders.length-1]});
const widestCols=Math.max(causeHeaders.length,sfgHeaders.length,3);
ws.columns=[{width:18},...Array(widestCols-1).fill({width:13})];
}
async function exportAnalyticsCsv(){
if(!_batchLog.length){toast('Nothing to export in this filter','err');return;}
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry','err');return;}
const a=_analytics||{};
const wb=new ExcelJS.Workbook();
wb.creator='SAP ERP Portal';wb.created=new Date();
const ws=wb.addWorksheet('Rejection Analytics',{views:[{state:'frozen',ySplit:8}]});
// ── Title block ──
ws.mergeCells('A1:M1');
const title=ws.getCell('A1');
title.value='Rejection Analytics — Export';
title.font={bold:true,size:16,color:{argb:XLSX_WHITE}};
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_PURPLE}};
title.alignment={vertical:'middle',horizontal:'left'};
ws.getRow(1).height=28;
ws.mergeCells('A2:M2');
const subtitle=ws.getCell('A2');
subtitle.value=`Generated ${new Date().toLocaleString()} · Deep-dive charts bucketed by ${_chartGran}`;
subtitle.font={italic:true,size:9.5,color:{argb:'FF94A3B8'}};
subtitle.alignment={vertical:'middle',horizontal:'left'};
const stageLabel=_analyticsStage?(STAGES.find(s=>s.v===_analyticsStage)?.label||_analyticsStage):'All Stages';
const metaRows=[
['Company:',_co,'Stage:',stageLabel],
['From:',_analyticsFrom||'(all)','To:',_analyticsTo||'(all)'],
['Net rejection %:',a.avgPct+'%','Total rejected:',a.totalGross],
['Total net:',a.totalNet,'Saved by rework:',a.savedByRework],
];
metaRows.forEach((r,i)=>{
const row=ws.getRow(3+i);
row.getCell(1).value=r[0];row.getCell(1).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:'FF0F172A'}};
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:'FF64748B'}};
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:'FF0F172A'}};
});
// ── Batch Log ──
const logHdrRowNum=8;
ws.mergeCells(`A${logHdrRowNum-1}:M${logHdrRowNum-1}`);
const logTitle=ws.getCell(`A${logHdrRowNum-1}`);
logTitle.value='Batch Log';logTitle.font={bold:true,size:12,color:{argb:XLSX_INK}};
const logCols=['Production Date','Entry Date','Batch No.','Product','Stage','Shift','Recorded By','Batch Size','Rejected','Net','Net %','Status','Needs Cause Review'];
const logHdrRow=ws.getRow(logHdrRowNum);
logCols.forEach((h,i)=>{
const cell=logHdrRow.getCell(i+1);
cell.value=h;
cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
cell.alignment={vertical:'middle',horizontal:'center'};
cell.border=xlsxBorder('FF0F172A');
});
logHdrRow.height=20;
_batchLog.forEach((b,i)=>{
const row=ws.getRow(logHdrRowNum+1+i);
const vals=[b.prodDate,b.createdAt?new Date(b.createdAt).toLocaleString():'',b.batchNo,b.productCode||'',STAGES.find(s=>s.v===b.stage)?.label||b.stage,(b.shift||'').split(' ')[0],
b.createdByName||b.createdBy||'',b.batchSize,b.grossQty,b.netQty,b.netPct/100,b.status,b.needsCauseSetup?'Yes':'No'];
vals.forEach((v,ci)=>{
const cell=row.getCell(ci+1);
cell.value=v;
cell.border=xlsxBorder();
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:b.needsCauseSetup?XLSX_AMBER:(i%2?XLSX_ZEBRA:XLSX_WHITE)}};
if([7,8,9,10].includes(ci))cell.alignment={horizontal:'right'};
if(ci===10)cell.numFmt='0.0%';
if(ci===11)cell.font={bold:true,color:{argb:b.status==='QA_EDITED'?'FF9F5AFD':'FF1A6FFF'}};
});
});
// ── Top Causes ──
if((a.topCauses||[]).length){
const tcHdrRowNum=logHdrRowNum+_batchLog.length+3;
ws.mergeCells(`A${tcHdrRowNum-1}:B${tcHdrRowNum-1}`);
const tcTitle=ws.getCell(`A${tcHdrRowNum-1}`);
tcTitle.value='Top Causes';tcTitle.font={bold:true,size:12,color:{argb:XLSX_INK}};
const tcHdrRow=ws.getRow(tcHdrRowNum);
['Cause','Net Qty'].forEach((h,i)=>{
const cell=tcHdrRow.getCell(i+1);
cell.value=h;cell.font={bold:true,color:{argb:XLSX_WHITE}};
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_PURPLE}};
cell.alignment={vertical:'middle',horizontal:'center'};
cell.border=xlsxBorder('FF7C3AED');
});
a.topCauses.forEach((c,i)=>{
const row=ws.getRow(tcHdrRowNum+1+i);
row.getCell(1).value=c.name;row.getCell(1).border=xlsxBorder();
row.getCell(1).fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
row.getCell(2).value=c.qty;row.getCell(2).border=xlsxBorder();row.getCell(2).alignment={horizontal:'right'};
row.getCell(2).fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
});
}
ws.columns=[{width:13},{width:17},{width:14},{width:14},{width:16},{width:9},{width:16},{width:11},{width:9},{width:9},{width:9},{width:12},{width:16}];
// ── Second sheet: "S.No / Rejection Type / Qty" ranked list, same visual
// style as the reference (month title bar, alternating lavender/green
// rows on the Rejection Type column, a solid yellow Qty column, borders
// on every cell) — reuses the same Top Causes data as the summary above,
// just laid out and colored to match that reference exactly.
if((a.topCauses||[]).length)buildMonthlySummarySheet(wb,a.topCauses);
// ── Third sheet: the same period-bucketed Deep-dive charts shown on
// screen, as plain tables (Cause-wise Net/Rework, Produced Qty vs Net%,
// Cause-wise by SFG) — so the export carries the same numbers, not just
// the flat Batch Log/Top Causes rollups.
buildDeepDiveSheet(wb);
const buf=await wb.xlsx.writeBuffer();
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const link=document.createElement('a');
link.href=URL.createObjectURL(blob);
link.download=`Rejection_Analytics_${_analyticsFrom||'all'}_to_${_analyticsTo||'all'}.xlsx`;
link.click();
URL.revokeObjectURL(link.href);
}
async function loadCauses(){
_causesLoading=true;render();
try{const r=await api('GET',`/rejection-register/causes?includeInactive=1&company=${encodeURIComponent(_co)}`);_causes=r.data||[];}
catch(e){toast(e.message,'err');_causes=[];}
_causesLoading=false;_causesLoaded=true;render();
}
async function loadPending(){
_pendingLoading=true;render();
try{const r=await api('GET',`/rejection-register/pending-cause-setup?company=${encodeURIComponent(_co)}`);_pending=r.data||[];}
catch(e){_pending=[];}
_pendingLoading=false;_pendingLoaded=true;render();
}
// ── QA-edit modal — re-code one entry's causes ─────────────────────────
async function openEditEntry(id){
_editEntry=null;_editCauses=null;_editStageCauses=[];_editLoading=true;render();
try{
const [entries]=await Promise.all([api('GET',`/rejection-register/entries?company=${encodeURIComponent(_co)}`)]);
const entry=(entries.data||[]).find(e=>e.id==id);
if(!entry){toast('Entry not found','err');_editLoading=false;render();return;}
_editEntry=entry;
_editCauses=(entry.causes||[]).map(c=>({...c}));
_editRemarks=entry.remarks||'';
try{const r=await api('GET',`/rejection-register/causes?stage=${encodeURIComponent(entry.stage)}&company=${encodeURIComponent(_co)}`);_editStageCauses=r.data||[];}catch(_e){_editStageCauses=[];}
}catch(e){toast(e.message,'err');}
_editLoading=false;render();
}
function closeEditEntry(){_editEntry=null;_editCauses=null;_editStageCauses=[];render();}
function editCauseRow(name){
let row=_editCauses.find(c=>c.name===name);
if(!row){row={name,rejected:0,rework:0,ok:0};_editCauses.push(row);}
return row;
}
function renderEditModal(){
if(!_editEntry&&!_editLoading)return '';
if(_editLoading)return `<div class="modal-bg show"><div class="modal"><div class="modal-bd" style="text-align:center;padding:40px"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading…</div></div></div>`;
const e=_editEntry;
// Union of this Stage's currently-active causes PLUS whatever's already
// on the entry (an "Other / Unknown" bucket, or a since-soft-deleted
// cause name) — QA needs to see and zero out the latter while moving the
// qty into a real cause, not have it silently disappear from the editor.
const names=[...new Set([..._editStageCauses.map(c=>c.name),...(_editCauses||[]).map(c=>c.name)])];
const rows=names.map(name=>{
const row=editCauseRow(name);
const isOther=name==='Other / Unknown';
return `<tr${isOther?' style="background:rgba(245,166,35,.08)"':''}>
<td>${esc(name)}</td>
<td class="right"><input class="fi select-sm" style="width:110px;text-align:right" data-erow="rejected" data-ename="${esc(name)}" type="number" min="0" value="${row.rejected||0}"/></td>
<td class="right"><input class="fi select-sm" style="width:110px;text-align:right" data-erow="rework" data-ename="${esc(name)}" type="number" min="0" value="${row.rework||0}"/></td>
<td class="right"><input class="fi select-sm" style="width:110px;text-align:right" data-erow="ok" data-ename="${esc(name)}" type="number" min="0" max="${row.rework||0}" value="${row.ok||0}"/></td>
<td class="right mono" style="font-weight:700">${(row.rejected||0)+((row.rework||0)-(row.ok||0))}</td>
</tr>`;
}).join('');
const gross=_editCauses.reduce((s,c)=>s+(parseFloat(c.rejected)||0),0);
const reworkTot=_editCauses.reduce((s,c)=>s+(parseFloat(c.rework)||0),0);
const ok=_editCauses.reduce((s,c)=>s+(parseFloat(c.ok)||0),0);
const net=gross+(reworkTot-ok);
return `<div class="modal-bg show"><div class="modal">
<div class="modal-hd"><h3>Re-code — Batch ${esc(e.batchNo)}</h3><button class="modal-close" id="edit-close">✕</button></div>
<div class="modal-bd">
<div class="hint">Stage: <b>${esc(STAGES.find(s=>s.v===e.stage)?.label||e.stage)}</b> · Recorded by <b>${esc(e.createdByName||e.createdBy||'-')}</b>${e.createdAt?' on '+esc(new Date(e.createdAt).toLocaleString()):''}. Move any "Other / Unknown" qty into the correct cause below, then Save.</div>
<div class="table-wrap"><table><thead><tr><th>Cause</th><th class="right">Rejected</th><th class="right">Sent for rework</th><th class="right">Okay after rework</th><th class="right">Net</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="fg" style="margin-top:12px"><label>Remarks</label><input class="fi" id="edit-remarks" value="${esc(_editRemarks)}"/></div>
<div class="kpi-row" style="margin-top:14px;margin-bottom:0">
<div class="kpi" style="--accent:var(--text3);padding:12px 14px 12px 18px"><div class="kl">Gross</div><div class="kv" style="font-size:19px">${gross}</div></div>
<div class="kpi" style="--accent:var(--green);padding:12px 14px 12px 18px"><div class="kl">OK</div><div class="kv" style="font-size:19px">${ok}</div></div>
<div class="kpi" style="--accent:var(--purple);padding:12px 14px 12px 18px"><div class="kl">Net</div><div class="kv" style="font-size:19px">${net}</div></div>
</div>
<div class="btn-bar" style="margin-top:14px;display:flex;gap:8px;justify-content:flex-end"><button class="btn b-ghost" id="edit-cancel">Cancel</button><button class="btn b-primary" id="edit-save" ${_editSaving?'disabled':''}>${_editSaving?'<span class="sp"></span> Saving…':'Save'}</button></div>
</div>
</div></div>`;
}
async function saveEditEntry(){
_editSaving=true;render();
try{
const r=await api('PUT',`/rejection-register/entries/${_editEntry.id}`,{causes:_editCauses,remarks:_editRemarks});
// The server only clears "needs review" once the Other/Unknown bucket
// actually has no qty left — saving without fixing that is still valid
// (e.g. QA is just correcting a typo elsewhere), but it stays on the
// pending list, so say so instead of implying it's fully resolved.
toast(r.data&&r.data.needsCauseSetup?'Saved — still needs a cause for "Other / Unknown" qty':'Entry re-coded');
_editSaving=false;
closeEditEntry();
loadPending();
if(_tab==='analytics')loadAnalytics();
}catch(e){toast(e.message,'err');_editSaving=false;render();}
}
function wire(){
document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{_tab=b.dataset.tab;render();});
if(_tab==='analytics'){
if(q('#sel-co'))q('#sel-co').onchange=function(){_co=this.value;_analytics=null;render();};
document.querySelectorAll('[data-stage]').forEach(b=>b.onclick=function(){_analyticsStage=this.dataset.stage;loadAnalytics();});
if(q('#f-from'))q('#f-from').onchange=function(){_analyticsFrom=this.value;};
if(q('#f-to'))q('#f-to').onchange=function(){_analyticsTo=this.value;};
if(q('#btn-refresh'))q('#btn-refresh').onclick=loadAnalytics;
if(q('#btn-export'))q('#btn-export').onclick=exportAnalyticsCsv;
document.querySelectorAll('[data-gran]').forEach(b=>b.onclick=function(){_chartGran=this.dataset.gran;_sfgBucketKey='';render();});
if(q('#sel-sfg-bucket'))q('#sel-sfg-bucket').onchange=function(){_sfgBucketKey=this.value;render();};
}
if(_tab==='setup'){
if(q('#nc-stage'))q('#nc-stage').onchange=function(){_newCause.stage=this.value;};
if(q('#nc-name'))q('#nc-name').oninput=function(){_newCause.name=this.value;};
if(q('#nc-root'))q('#nc-root').oninput=function(){_newCause.rootCause=this.value;};
if(q('#btn-add-cause'))q('#btn-add-cause').onclick=async()=>{
if(!_newCause.stage){toast('Pick a Stage first','err');return;}
if(!_newCause.name.trim()){toast('Cause name required','err');return;}
try{
await api('POST','/rejection-register/causes',{stage:_newCause.stage,name:_newCause.name.trim(),rootCause:_newCause.rootCause.trim(),company:_co});
toast('Cause added');_newCause.name='';_newCause.rootCause='';
_causesPage=Number.MAX_SAFE_INTEGER; // jump to the last page so the new row is visible
loadCauses();
}catch(e){toast(e.message,'err');}
};
if(q('#causes-prev'))q('#causes-prev').onclick=()=>{_causesPage--;render();};
if(q('#causes-next'))q('#causes-next').onclick=()=>{_causesPage++;render();};
document.querySelectorAll('[data-name-for]').forEach(inp=>inp.onchange=async function(){
if(!this.value.trim()){toast('Cause name cannot be blank','err');this.value=_causes.find(c=>c.id==this.dataset.nameFor)?.name||'';return;}
try{await api('PUT',`/rejection-register/causes/${this.dataset.nameFor}`,{name:this.value.trim()});toast('Updated');loadCauses();}
catch(e){toast(e.message,'err');}
});
document.querySelectorAll('[data-root-for]').forEach(inp=>inp.onchange=async function(){
try{await api('PUT',`/rejection-register/causes/${this.dataset.rootFor}`,{rootCause:this.value});toast('Updated');}
catch(e){toast(e.message,'err');}
});
document.querySelectorAll('[data-active-for]').forEach(cb=>cb.onchange=async function(){
try{await api('PUT',`/rejection-register/causes/${this.dataset.activeFor}`,{active:this.checked});toast('Updated');loadCauses();}
catch(e){toast(e.message,'err');loadCauses();}
});
// "Delete" is a soft delete (Active:false, same as unchecking the box
// above) — never a real DELETE. Existing entries already recorded
// against this cause keep their data either way; re-checking Active
// restores it as a pickable option.
document.querySelectorAll('[data-del-cause]').forEach(b=>b.onclick=async function(){
if(!confirm('Delete this cause? This only hides it from new entries (soft delete) — existing entries keep their recorded data, and you can restore it later via its Active checkbox.'))return;
try{await api('PUT',`/rejection-register/causes/${this.dataset.delCause}`,{active:false});toast('Deleted (soft)');loadCauses();}
catch(e){toast(e.message,'err');}
});
if(q('#ns-key'))q('#ns-key').oninput=function(){_newStage.v=this.value;};
if(q('#ns-label'))q('#ns-label').oninput=function(){_newStage.label=this.value;};
if(q('#btn-add-stage'))q('#btn-add-stage').onclick=async()=>{
const v=_newStage.v.trim(),label=_newStage.label.trim();
if(!v||!label){toast('Stage key and label are both required','err');return;}
if(STAGES.some(s=>s.v.toLowerCase()===v.toLowerCase())){toast('That Stage key already exists','err');return;}
try{
await api('PUT','/rejection-register/stages',{stages:[...STAGES,{v,label,active:true}]});
toast('Stage added');_newStage={v:'',label:''};
loadStages();
}catch(e){toast(e.message,'err');}
};
document.querySelectorAll('[data-label-for]').forEach(inp=>inp.onchange=async function(){
const v=this.dataset.labelFor;
if(!this.value.trim()){toast('Stage label cannot be blank','err');this.value=STAGES.find(s=>s.v===v)?.label||'';return;}
try{
await api('PUT','/rejection-register/stages',{stages:STAGES.map(s=>s.v===v?{...s,label:this.value.trim()}:s)});
toast('Updated');loadStages();
}catch(e){toast(e.message,'err');}
});
document.querySelectorAll('[data-active-stage]').forEach(cb=>cb.onchange=async function(){
const v=this.dataset.activeStage;
try{
await api('PUT','/rejection-register/stages',{stages:STAGES.map(s=>s.v===v?{...s,active:this.checked}:s)});
toast('Updated');loadStages();
}catch(e){toast(e.message,'err');loadStages();}
});
// "Delete" is a soft delete (Active:false) — the key/label stay in the
// stored list so existing causes/entries under it keep resolving to the
// correct label; it just stops being offered as a pickable option.
document.querySelectorAll('[data-del-stage]').forEach(b=>b.onclick=async function(){
const v=this.dataset.delStage;
const inUse=_causes.some(c=>c.stage===v);
if(!confirm(`Delete this Stage? This only hides it from new entries (soft delete) — existing causes/entries under it keep their data and label, and you can restore it later via its Active checkbox.${inUse?' Note: it still has rejection causes configured under it.':''}`))return;
try{
await api('PUT','/rejection-register/stages',{stages:STAGES.map(s=>s.v===v?{...s,active:false}:s)});
toast('Deleted (soft)');
loadStages();
}catch(e){toast(e.message,'err');}
});
}
// Edit-entry buttons/modal — appear in both tabs (Batch Log in Analytics,
// "Needs Cause Review" in Setup), so wired unconditionally.
document.querySelectorAll('[data-edit-entry]').forEach(b=>b.onclick=()=>openEditEntry(b.dataset.editEntry));
if(_editEntry){
if(q('#edit-close'))q('#edit-close').onclick=closeEditEntry;
if(q('#edit-cancel'))q('#edit-cancel').onclick=closeEditEntry;
if(q('#edit-save'))q('#edit-save').onclick=saveEditEntry;
if(q('#edit-remarks'))q('#edit-remarks').oninput=function(){_editRemarks=this.value;};
// Rejected and Sent for rework are two INDEPENDENT piles (both entered
// at Count/Step 2 in the Entry wizard) — together, not each alone, they
// can never exceed the Batch Size. Okay after rework IS a subset of
// Sent for rework specifically. Net = Rejected + (Sent for rework −
// Okay after rework) — see services/rejectionRegisterStore.js's
// computeTotals()/validateCauses().
document.querySelectorAll('[data-erow]').forEach(inp=>inp.onchange=function(){
const name=this.dataset.ename,field=this.dataset.erow;
const row=editCauseRow(name);
const v=Math.max(0,parseFloat(this.value)||0);
const size=parseFloat(_editEntry.batchSize)||0;
if(field==='rejected'||field==='rework'){
const otherField=field==='rejected'?'rework':'rejected';
const othersSum=_editCauses.filter(c=>c!==row).reduce((s,c)=>s+(parseFloat(c.rejected)||0)+(parseFloat(c.rework)||0),0);
const cap=Math.max(0,size-othersSum-(parseFloat(row[otherField])||0));
if(v>cap)toast(`Rejected + Sent for rework across all causes can't exceed Batch Size (${size}) — max here is ${cap}`,'err');
row[field]=Math.min(cap,v);
if(field==='rework'&&(row.ok||0)>row.rework)row.ok=row.rework;
}else{
if(v>(row.rework||0)){toast(`Okay after rework can't exceed Sent for rework (${row.rework||0})`,'err');row.ok=row.rework||0;}
else row.ok=v;
}
render();
});
}
}
async function loadStages(){
_stagesLoading=true;render();
try{const r=await api('GET','/rejection-register/stages');if(Array.isArray(r.data))STAGES=r.data;}
catch(e){toast(e.message,'err');}
_stagesLoading=false;render();
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const r=await api('GET','/rejection-register/stages');if(Array.isArray(r.data))STAGES=r.data;}catch(_e){}
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>