1137 lines
73 KiB
HTML
1137 lines
73 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
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>
|