Files
sap-erp/public/rejection-register.html
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

576 lines
39 KiB
HTML
Raw 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>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>Rejection Register — 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:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.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)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
main{padding:16px;max-width:860px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:14px;padding:16px 18px}
.card h3{font-size:14.5px;color:var(--text);margin-bottom:4px}
.hint{font-size:11.5px;color:var(--text2);margin-bottom:14px;line-height:1.5}
.row{display:flex;gap:14px;flex-wrap:wrap}
.fg{flex:1;min-width:160px;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}
.fi:focus{outline:none;border-color:var(--purple)}
.fi[readonly]{color:var(--text2);background:var(--ink3)}
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}
.radio-row{display:flex;gap:14px;flex-wrap:wrap;padding-top:4px}
.radio-row label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--text);cursor:pointer;font-weight:400;text-transform:none}
.btn{padding:9px 18px;border-radius:7px;border:none;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}
.btn:disabled{opacity:.4;cursor:not-allowed}
.b-primary{background:var(--purple);color:#fff}
.b-ghost{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.btn-bar{display:flex;justify-content:space-between;gap:8px;margin-top:6px}
.lookup-msg{font-size:11px;margin:-6px 0 12px;min-height:14px}
.lookup-msg.found{color:var(--green)}
.lookup-msg.miss{color:var(--amber)}
.prod-box{background:var(--ink3);border:1px solid var(--border2);border-radius:8px;padding:12px 14px;min-width:220px}
.prod-box .lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px}
.prod-box .name{font-size:14px;font-weight:700;color:var(--text)}
.prod-box .code{font-family:var(--mono);color:var(--purple);font-size:11.5px;margin-top:2px}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;padding:8px 10px;color:var(--text3);font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;border-bottom:1px solid var(--border2)}
td{padding:9px 10px;border-bottom:1px solid var(--border);color:var(--text)}
tr:last-child td{border-bottom:none}
.right{text-align:right}
.mono{font-family:var(--mono)}
.num-cell{display:flex;align-items:center;gap:6px;justify-content:flex-end}
.stepper{width:26px;height:26px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);font-size:14px;cursor:pointer;flex-shrink:0}
.stepper:hover{border-color:var(--purple)}
.num-inp{width:64px;padding:6px;text-align:right;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:var(--mono);font-size:12.5px}
.review-net{text-align:center;padding:18px 0}
.review-net .pct{font-size:44px;font-weight:800;color:var(--text);font-family:var(--mono)}
.review-net .pct.warn{color:var(--amber)}
.review-net .pct.bad{color:var(--red)}
.review-net .pct.good{color:var(--green)}
.review-net .words{font-size:12px;color:var(--text2);margin-top:4px}
.saved-box{text-align:center;padding:30px 0}
.saved-box .tick{width:52px;height:52px;border-radius:50%;background:rgba(13,187,138,.15);color:var(--green);display:flex;align-items:center;justify-content:center;font-size:26px;margin:0 auto 14px}
.saved-box h3{font-size:18px;color:var(--text);margin-bottom:6px}
.saved-box .sum{font-size:12.5px;color:var(--text2);margin-bottom:20px}
.step-track{display:flex;gap:4px;margin-bottom:4px}
.step-track div{flex:1;height:4px;border-radius:2px;background:var(--border2)}
.step-track div.done{background:var(--purple)}
.empty-msg{text-align:center;padding:24px;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}
</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 active">Rejection Register</a><a href="/rejection-analytics" class="nav-link">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));}
const CAN_ENTRY=hasStepPerm('rejection_register:entry','add');
// Optional per-user Stage allow-list on the rejection_register:entry step
// itself (set in User Management) — e.g. a line operator who only counts
// rejections at "EBB" shouldn't see every other Stage in the picker. Empty/
// absent = unrestricted (every active Stage), same convention as
// allowedCompanies/allowedDepartments elsewhere. The server enforces this
// independently on submit (routes/rejectionRegister.js's entryStageAllowed)
// — this is just so the picker itself doesn't offer stages that would 403.
const ENTRY_STAGE_ALLOWLIST=(()=>{
if(_user&&_user.role==='admin')return [];
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
const entry=steps.find(s=>s&&typeof s==='object'&&s.step==='rejection_register:entry');
return entry&&Array.isArray(entry.stages)?entry.stages:[];
})();
function entryStages(){
const active=STAGES.filter(s=>s.active!==false);
return ENTRY_STAGE_ALLOWLIST.length?active.filter(s=>ENTRY_STAGE_ALLOWLIST.includes(s.v)):active;
}
// Analytics/Batch Log are a QA-side view — holding only the Entry step
// (this page) does NOT grant it, so the nav link/"See analytics" button
// must not dangle into a page that just 403s. Mirrors rejection-analytics.html's
// own CAN_VIEW.
const CAN_VIEW_ANALYTICS=hasStepAssigned('rejection_register:qa_edit')||hasStepAssigned('rejection_register:causes_setup');
if(!CAN_VIEW_ANALYTICS){const navA=document.querySelector('a[href="/rejection-analytics"]');if(navA)navA.remove();}
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 today(){return new Date().toISOString().slice(0,10);}
// Stage master (Rejection Analytics → Root Cause Setup → "Stage Master") and
// Shift master (Admin → System Settings → "Rejection Register") — both
// loaded fresh in init(); no built-in sample stages, an admin adds their
// own real ones via Setup.
let STAGES=[];
let SHIFTS=['A (06:00–14:00)','B (14:00–22:00)','C (22:00–06:00)'];
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 _step=1; // 1 lookup, 2 count, 3 rework, 4 review, 'saved'
let _entry=blankEntry();
let _causes=[]; // this stage's admin-configured causes
let _causesLoading=false;
let _lookupCandidates=[]; // every distinct item this batch no. resolved to
let _lookupHit=null; // the one the user has picked/it auto-picked, or null
let _lookupLoading=false; // batch lookup request in flight
let _submitting=false;
let _savedBatchNo='';
function blankEntry(){return{stage:'',shift:'',batchNo:'',date:today(),batchSize:'',causes:[],remarks:''};}
function render(){
const app=q('#app');
if(!CAN_ENTRY){
app.innerHTML='<div class="card"><div class="empty-msg">You\'re not assigned "add" on the Rejection Register — Entry approval step, so you can\'t count rejections. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
return;
}
app.innerHTML=(_step==='saved')?renderSaved():renderWizard();
wire();
}
function renderWizard(){
const stageLabel=STAGES.find(s=>s.v===_entry.stage)?.label||'';
const track=[1,2,3,4].map(n=>`<div class="${n<=_step?'done':''}"></div>`).join('');
let body='';
if(_step===1)body=stepLookup(stageLabel);
else if(_step===2)body=stepCount();
else if(_step===3)body=stepRework();
else body=stepReview();
return `
<div class="card" style="padding-bottom:10px">
<div class="row" style="align-items:flex-end">
<div class="fg" style="max-width:180px"><label>Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
<div class="fg" style="max-width:220px"><label>Stage</label>
<select class="fi" id="stage-pick" ${_step>1?'disabled':''}>
<option value="" ${_entry.stage?'':'selected'}>— Select Stage —</option>
${entryStages().map(s=>`<option value="${s.v}" ${_entry.stage===s.v?'selected':''}>${esc(s.label)}</option>`).join('')}
</select>
</div>
<div class="fg" style="max-width:140px"><label>Step ${_step}/4</label><div style="padding-top:7px;font-weight:700;color:var(--text)">${['Which batch','Count rejections','Rework','Review'][_step-1]}</div></div>
</div>
${ENTRY_STAGE_ALLOWLIST.length&&!entryStages().length?`<div style="font-size:11px;color:var(--red);padding:0 2px 8px">You're assigned to record rejections for a Stage that's since been deleted/deactivated — ask an admin to update your Stage assignment.</div>`:''}
<div class="step-track">${track}</div>
</div>
${body}
`;
}
function stepLookup(stageLabel){
const multi=_lookupCandidates.length>1;
const msg=_lookupLoading?''
:!_entry.batchNo.trim()?''
:multi?`<div class="lookup-msg found">Found ${_lookupCandidates.length} items using this batch number — pick which one below.</div>`
:_lookupHit?`<div class="lookup-msg found">✓ Found — pulled from the batch record.</div>`
:`<div class="lookup-msg miss">No batch record found yet for "${esc(_entry.batchNo)}" — you can still continue, QA will reconcile it.</div>`;
// Same batch number CAN genuinely belong to more than one item (reused
// across products, or a coincidental/typo collision) — never silently
// pick one, since that risks recording the rejection against the wrong
// item entirely. Show a picker instead and require a choice before
// Start Counting unlocks.
const productBox=_lookupLoading
?`<div class="prod-box" style="display:flex;align-items:center;gap:8px;color:var(--text2);font-size:12.5px"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Looking up batch…</div>`
:multi
?`<div class="prod-box" style="min-width:280px">
<div class="lbl">Multiple items found — pick one</div>
<select class="fi select-sm" id="f-candidate" style="margin-top:4px">
<option value="">— Select product —</option>
${_lookupCandidates.map((c,i)=>`<option value="${i}" ${_lookupHit===c?'selected':''}>${esc(c.productCode)} — ${esc(c.productName||'').slice(0,40)}</option>`).join('')}
</select>
</div>`
:`<div class="prod-box">
<div class="lbl">Product — from batch record</div>
<div class="name">${esc(_lookupHit?_lookupHit.productName:'—')}</div>
<div class="code">${esc(_lookupHit?_lookupHit.productCode:'')}</div>
</div>`;
const canStart=!_lookupLoading&&_entry.stage&&_entry.batchNo.trim()&&_entry.date&&_entry.shift&&parseFloat(_entry.batchSize)>0&&(!multi||_lookupHit);
return `<div class="card">
<h3>Which batch are you counting?</h3>
<div class="hint">Type the batch number — the product comes up on its own if a batch record already exists. ${stageLabel?`You are recording <b>${esc(stageLabel)}</b> only.`:'Pick a Stage above first.'}</div>
<div class="row">
<div class="fg" style="max-width:280px"><label>Batch number</label><input class="fi mono" id="f-batchno" value="${esc(_entry.batchNo)}" placeholder="e.g. BB2609053 S" autocomplete="off"/></div>
<div id="lookup-prodbox">${productBox}</div>
</div>
<div id="lookup-msg">${msg}</div>
<div class="row">
<div class="fg" style="max-width:200px"><label>Date of production</label><input class="fi" id="f-date" type="date" value="${esc(_entry.date)}"/></div>
<div class="fg"><label>Shift — required</label><div class="radio-row" id="f-shift">${SHIFTS.map(s=>`<label><input type="radio" name="shift" value="${esc(s)}" ${_entry.shift===s?'checked':''}/> ${esc(s)}</label>`).join('')}</div></div>
<div class="fg" style="max-width:160px"><label>Batch size (units)</label><input class="fi mono" id="f-size" type="number" min="0" value="${esc(_entry.batchSize)}" placeholder="0"/></div>
</div>
<div class="btn-bar"><span></span><button class="btn b-primary" id="btn-start" ${canStart?'':'disabled'}>Start counting →</button></div>
</div>`;
}
function stepCount(){
if(_causesLoading)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 causes…</div></div>`;
if(!_causes.length)return `<div class="card">
<h3>Count the rejections</h3>
<div class="hint">Rejection causes for this stage have not been set up yet. Record the totals below and write what happened — QA will add the causes (Rejection Analytics → Root Cause Setup).</div>
<div class="fg"><label>What happened</label><textarea class="fi" id="f-freetext" rows="3" style="resize:vertical">${esc(_entry.remarks)}</textarea></div>
<div class="fg" style="max-width:160px"><label>Total rejected (units, max ${parseFloat(_entry.batchSize)||0})</label><input class="fi mono" id="f-freetotal" type="number" min="0" max="${parseFloat(_entry.batchSize)||0}" value="${esc((_entry.causes[0]&&_entry.causes[0].rejected)||'')}"/></div>
<div class="btn-bar"><button class="btn b-ghost" id="btn-back">← Back</button><button class="btn b-primary" id="btn-next">Next: Rework →</button></div>
</div>`;
const rows=_causes.map(c=>{
const row=_entry.causes.find(x=>x.name===c.name)||{name:c.name,rejected:0};
return `<tr><td>${esc(c.name)}</td>
<td class="right"><div class="num-cell"><button class="stepper" data-dec="${esc(c.name)}">−</button><input class="num-inp" data-cnt="${esc(c.name)}" type="number" min="0" value="${row.rejected||0}"/><button class="stepper" data-inc="${esc(c.name)}">+</button></div></td>
<td class="right"><div class="num-cell"><button class="stepper" data-rdec="${esc(c.name)}">−</button><input class="num-inp" data-rw="${esc(c.name)}" type="number" min="0" value="${row.rework||0}"/><button class="stepper" data-rinc="${esc(c.name)}">+</button></div></td>
</tr>`;
}).join('');
// "Other / Unknown" is a fixed sentinel row, always available alongside
// the real causes — if the operator DOES know which listed cause it is,
// they put the qty against that one; if they DON'T know (or it's none of
// the ones listed), they put it here instead of guessing wrong, and note
// what happened in Remarks on the next step. Reuses the exact same
// stepper/counter wiring as a real cause (see wire()'s generic
// [data-inc]/[data-cnt] handlers) — no special-case JS needed here. The
// server flags any submitted entry that used this bucket for QA follow-up
// (assign it to an existing cause, or add a new one) — see
// services/rejectionRegisterStore.js's createEntry()/pendingCauseSetupSummary().
const OTHER_CAUSE='Other / Unknown';
const otherRow=_entry.causes.find(x=>x.name===OTHER_CAUSE)||{name:OTHER_CAUSE,rejected:0};
const otherRowHtml=`<tr style="background:var(--surface2)"><td>${esc(OTHER_CAUSE)}<div style="font-size:10px;color:var(--text3);margin-top:2px">Not sure which cause — describe it in Remarks on the next step, QA will assign/add the right cause</div></td>
<td class="right"><div class="num-cell"><button class="stepper" data-dec="${esc(OTHER_CAUSE)}">−</button><input class="num-inp" data-cnt="${esc(OTHER_CAUSE)}" type="number" min="0" value="${otherRow.rejected||0}"/><button class="stepper" data-inc="${esc(OTHER_CAUSE)}">+</button></div></td>
<td class="right"><div class="num-cell"><button class="stepper" data-rdec="${esc(OTHER_CAUSE)}">−</button><input class="num-inp" data-rw="${esc(OTHER_CAUSE)}" type="number" min="0" value="${otherRow.rework||0}"/><button class="stepper" data-rinc="${esc(OTHER_CAUSE)}">+</button></div></td>
</tr>`;
const size=parseFloat(_entry.batchSize)||0;
// Rejected and Sent for rework are now two INDEPENDENT piles pulled out of
// the same batch (not one nested inside the other) — both count against
// the same physical Batch Size ceiling together.
const grossSoFar=_entry.causes.reduce((s,c)=>s+(parseFloat(c.rejected)||0),0);
const reworkSoFar=_entry.causes.reduce((s,c)=>s+(parseFloat(c.rework)||0),0);
const pulledSoFar=grossSoFar+reworkSoFar;
const totalOver=size>0&&pulledSoFar>size;
return `<div class="card">
<h3>Count the rejections</h3>
<div class="hint">If you know the cause, put the rejected quantity against it. Don't know which one it is? Use "Other / Unknown" below instead of guessing — leave a cause blank if there was nothing to record for it. Also record how many (of that same cause) are being Sent for rework — these are two separate piles, not one inside the other. Rejected + Sent for rework across every cause can never exceed the Batch Size (${size} units).</div>
<table><thead><tr><th>Rejection cause</th><th class="right">Rejected (units)</th><th class="right">Sent for rework (units)</th></tr></thead><tbody>${rows}${otherRowHtml}</tbody></table>
<div style="text-align:right;margin-top:8px;font-size:11.5px;color:${totalOver?'var(--red)':'var(--text2)'}">Rejected: <b class="mono">${grossSoFar}</b> · Sent for rework: <b class="mono">${reworkSoFar}</b> · Total: <b class="mono">${pulledSoFar}</b> / ${size} units</div>
<div class="btn-bar" style="margin-top:14px"><button class="btn b-ghost" id="btn-back">← Back</button><button class="btn b-primary" id="btn-next">Next: Rework →</button></div>
</div>`;
}
function stepRework(){
// Rejected and Sent for rework were both entered in "Count" — READ-ONLY
// here, carried straight over. This step only records the OUTCOME of
// rework: Okay after rework (≤ Sent for rework — it's a subset of that
// pile specifically, not of Rejected, since the two are separate piles).
const active=_entry.causes.filter(c=>(c.rejected||0)>0||(c.rework||0)>0);
if(!active.length)return `<div class="card">
<h3>Rework</h3>
<div class="hint">Nothing here yet — go back and enter Rejected / Sent for rework and the cause will appear here.</div>
<div class="fg"><label>Remarks (optional)</label><input class="fi" id="f-remarks" value="${esc(_entry.remarks)}" placeholder="Anything the shift in-charge should know"/></div>
<div class="btn-bar"><button class="btn b-ghost" id="btn-back">← Back</button><button class="btn b-primary" id="btn-next">Check &amp; submit →</button></div>
</div>`;
const rows=active.map(c=>{
const rejectAfterRework=(c.rework||0)-(c.ok||0);
const net=(c.rejected||0)+rejectAfterRework;
return `<tr>
<td>${esc(c.name)}</td>
<td class="right mono">${c.rejected||0}</td>
<td class="right mono">${c.rework||0}</td>
<td class="right"><div class="num-cell"><button class="stepper" data-odec="${esc(c.name)}">−</button><input class="num-inp" data-ok="${esc(c.name)}" type="number" min="0" max="${c.rework||0}" value="${c.ok||0}"/><button class="stepper" data-oinc="${esc(c.name)}">+</button></div></td>
<td class="right mono">${rejectAfterRework}</td>
<td class="right mono" style="font-weight:700;color:${net<0?'var(--red)':'#fff'}">${net}</td>
</tr>`;
}).join('');
return `<div class="card">
<h3>Rework</h3>
<div class="hint">Rejected and Sent for rework, carried over from Count. Enter Okay after rework — how many of the Sent-for-rework pile came back good (≤ Sent for rework). Reject after rework = Sent for rework − Okay after rework. Net = Rejected + Reject after rework.</div>
<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">Reject after rework</th><th class="right">Net</th></tr></thead><tbody>${rows}</tbody></table>
<div class="fg" style="margin-top:14px"><label>Remarks (optional)</label><input class="fi" id="f-remarks" value="${esc(_entry.remarks)}" placeholder="Anything the shift in-charge should know"/></div>
<div class="btn-bar"><button class="btn b-ghost" id="btn-back">← Back</button><button class="btn b-primary" id="btn-next">Check &amp; submit →</button></div>
</div>`;
}
// Rejected and Sent for rework are now TWO INDEPENDENT piles (both entered
// in Count), not one nested inside the other — so there's no Rework ≤
// Rejected constraint anymore. Okay after rework IS a subset of Sent for
// rework specifically (Okay after Rework ≤ Sent for Rework). Reject after
// rework = Sent for rework − Okay after rework (whatever didn't recover).
// Net = Rejected + Reject after rework — the outright-rejected pile PLUS
// whatever the rework pile still lost, added together since they're
// separate physical piles pulled from the same batch. E.g. Rejected=10,
// Rework=8, OK=8 → Net=10 (all 8 reworked pieces recovered, but the
// original 10 rejects still stand); Rejected=10, Rework=8, OK=5 → Net=13
// (10 rejected + 3 that failed rework).
function totalsFor(entry){
const gross=entry.causes.reduce((s,c)=>s+(parseFloat(c.rejected)||0),0);
const rework=entry.causes.reduce((s,c)=>s+(parseFloat(c.rework)||0),0);
const ok=entry.causes.reduce((s,c)=>s+(parseFloat(c.ok)||0),0);
const rejectAfterRework=rework-ok;
const net=gross+rejectAfterRework;
const size=parseFloat(entry.batchSize)||0;
const pct=size?Math.round((net/size)*1000)/10:0;
return {gross,rework,ok,rejectAfterRework,net,pct};
}
function stepReview(){
const t=totalsFor(_entry);
const sizeMissing=!(parseFloat(_entry.batchSize)>0);
const pctClass=t.pct>=2?'bad':t.pct>=1?'warn':'good';
const words=t.pct>=2?'Above threshold — QA sign-off will flag this.':t.pct>=1?'Within normal range, worth a look.':'Well within normal range.';
const rows=_entry.causes.filter(c=>(c.rejected||0)>0||(c.rework||0)>0).map(c=>{
const rejectAfterRework=(c.rework||0)-(c.ok||0);
const net=(c.rejected||0)+rejectAfterRework;
return `<tr><td>${esc(c.name)}</td><td class="right mono">${c.rejected||0}</td><td class="right mono">${c.rework||0}</td><td class="right mono">${c.ok||0}</td><td class="right mono">${rejectAfterRework}</td><td class="right mono" style="font-weight:700">${net}</td></tr>`;
}).join('')||`<tr><td colspan="6" style="text-align:center;color:var(--text3)">No cause-level rejections — see remarks.</td></tr>`;
// Batch size should always be set by now (Step 1 requires it > 0 before
// "Start counting" unlocks) — but if it somehow ended up missing anyway,
// showing a misleading "0%" here would be worse than showing nothing:
// make it an explicit blocker instead of a silently-wrong number.
const netBlock=sizeMissing
?`<div class="review-net"><div style="font-size:10px;color:var(--red);text-transform:uppercase;letter-spacing:.4px;margin-bottom:6px">Net rejection after rework</div><div class="pct bad">—</div><div class="words" style="color:var(--red)">Batch size is missing — go back to Step 1 and set it before submitting.</div></div>`
:`<div class="review-net"><div style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-bottom:6px">Net rejection after rework</div><div class="pct ${pctClass}">${t.pct}%</div><div class="words">${words}</div></div>`;
return `<div class="card">
<h3>Check before you submit</h3>
<div class="hint">If anything is wrong, go back and change it. Once submitted, only QA can edit.</div>
<div class="row">
<div class="fg"><label>Batch</label><div class="mono" style="color:var(--text);font-weight:700">${esc(_entry.batchNo)}</div></div>
<div class="fg"><label>Shift</label><div style="color:var(--text)">${esc(_entry.shift)}</div></div>
<div class="fg"><label>Batch size</label><div class="mono" style="color:${sizeMissing?'var(--red)':'var(--text)'}">${sizeMissing?'Missing':parseFloat(_entry.batchSize)+' units'}</div></div>
</div>
<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">Reject after rework</th><th class="right">Net</th></tr></thead><tbody>${rows}</tbody></table>
${netBlock}
<div class="btn-bar"><button class="btn b-ghost" id="btn-back">← Go back and fix</button><button class="btn b-primary" id="btn-submit" ${(_submitting||sizeMissing)?'disabled':''}>${_submitting?'<span class="sp"></span> Submitting…':'Submit batch'}</button></div>
</div>`;
}
function renderSaved(){
return `<div class="card"><div class="saved-box">
<div class="tick">✓</div>
<h3>Batch ${esc(_savedBatchNo)} is recorded.</h3>
<div class="sum">QA will see this on the next analytics refresh.</div>
<div class="btn-bar" style="justify-content:center"><button class="btn b-ghost" id="btn-another">Enter another batch</button>${CAN_VIEW_ANALYTICS?'<button class="btn b-primary" id="btn-see-analytics">See analytics</button>':''}</div>
</div></div>`;
}
async function loadCauses(){
_causesLoading=true;render();
try{
const r=await api('GET',`/rejection-register/causes?stage=${encodeURIComponent(_entry.stage)}&company=${encodeURIComponent(_co)}`);
_causes=r.data||[];
}catch(e){_causes=[];toast(e.message,'err');}
_causesLoading=false;render();
}
// Applies one resolved candidate's Batch Size/Date onto the in-progress
// entry — never overwrites with a blank/0 value (the user may have already
// typed their own), shared by both the auto-pick (single match) and the
// manual picker (multiple matches) paths.
function applyLookupCandidate(c){
_lookupHit=c;
if(!c)return;
if(c.batchSize)_entry.batchSize=String(c.batchSize);
if(c.prodDate)_entry.date=c.prodDate;
}
let _lookupTimer=null;
// Turning the spinner ON is a direct DOM patch, not a full render() — this
// runs on every keystroke via scheduleLookup(), and a full render() there
// would recreate #f-batchno itself on every character typed, stealing focus
// mid-type (see [[feedback-avoid-full-render-on-input]]). Turning it back
// OFF still goes through the normal full render() at the end of the debounce
// timeout below, same as before — that only fires once per pause in typing.
function patchLookupLoadingOn(){
const box=q('#lookup-prodbox');
if(box)box.innerHTML=`<div class="prod-box" style="display:flex;align-items:center;gap:8px;color:var(--text2);font-size:12.5px"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Looking up batch…</div>`;
const msgEl=q('#lookup-msg');
if(msgEl)msgEl.innerHTML='';
const btn=q('#btn-start');
if(btn)btn.disabled=true;
}
function scheduleLookup(){
clearTimeout(_lookupTimer);
const v=_entry.batchNo.trim();
if(!v){_lookupLoading=false;_lookupCandidates=[];_lookupHit=null;render();return;}
_lookupLoading=true;patchLookupLoadingOn();
_lookupTimer=setTimeout(async()=>{
try{
const r=await api('GET',`/rejection-register/lookup-batch/${encodeURIComponent(v)}?company=${encodeURIComponent(_co)}`);
_lookupCandidates=r.data||[];
// Exactly one match auto-picks itself; more than one requires the user
// to choose (never silently guess — see the picker in stepLookup()).
applyLookupCandidate(_lookupCandidates.length===1?_lookupCandidates[0]:null);
}catch(_e){_lookupCandidates=[];_lookupHit=null;}
_lookupLoading=false;
render();
},350);
}
function wire(){
if(!CAN_ENTRY)return;
if(_step==='saved'){
if(q('#btn-another'))q('#btn-another').onclick=()=>{_entry=blankEntry();_lookupCandidates=[];_lookupHit=null;_step=1;render();};
if(q('#btn-see-analytics'))q('#btn-see-analytics').onclick=()=>{window.location.href='/rejection-analytics';};
return;
}
if(q('#sel-co'))q('#sel-co').onchange=function(){_co=this.value;render();};
if(q('#stage-pick'))q('#stage-pick').onchange=function(){_entry.stage=this.value;_entry.causes=[];render();};
if(q('#f-batchno'))q('#f-batchno').oninput=function(){_entry.batchNo=this.value;scheduleLookup();};
if(q('#f-candidate'))q('#f-candidate').onchange=function(){applyLookupCandidate(this.value!==''?_lookupCandidates[parseInt(this.value)]:null);render();};
if(q('#f-date'))q('#f-date').onchange=function(){_entry.date=this.value;};
if(q('#f-shift'))q('#f-shift').querySelectorAll('input').forEach(r=>r.onchange=function(){_entry.shift=this.value;render();});
if(q('#f-size'))q('#f-size').oninput=function(){_entry.batchSize=this.value;render();};
if(q('#btn-start'))q('#btn-start').onclick=()=>{_step=2;loadCauses();};
if(q('#btn-back'))q('#btn-back').onclick=()=>{_step--;render();};
if(q('#btn-next'))q('#btn-next').onclick=()=>{
if(_step===2){
if(q('#f-freetext')){
const size=parseFloat(_entry.batchSize)||0;
let total=parseFloat(q('#f-freetotal').value)||0;
if(total>size){toast(`Total rejected can't exceed Batch Size (${size})`,'err');total=size;}
_entry.remarks=q('#f-freetext').value;_entry.causes=[{name:'General',rejected:Math.max(0,total)}];
}
_step=3;
}else if(_step===3){
if(q('#f-remarks'))_entry.remarks=q('#f-remarks').value;
_step=4;
}
render();
};
if(q('#btn-submit'))q('#btn-submit').onclick=submitEntry;
// Rejected and Sent for rework (both entered in Count, Step 2) are two
// INDEPENDENT piles pulled from the same batch — together (not each
// alone) they can never exceed the Batch Size. Each handler caps against
// the remaining capacity (Batch Size minus every OTHER cause's
// Rejected+Rework, minus this row's OTHER field), not just its own field
// in isolation.
document.querySelectorAll('[data-inc],[data-dec]').forEach(b=>b.onclick=function(){
const name=this.dataset.inc||this.dataset.dec;
let row=_entry.causes.find(c=>c.name===name);
if(!row){row={name,rejected:0};_entry.causes.push(row);}
const size=parseFloat(_entry.batchSize)||0;
const othersSum=_entry.causes.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.rework)||0));
const next=(row.rejected||0)+(this.dataset.inc?1:-1);
row.rejected=Math.max(0,Math.min(cap,next));
render();
});
document.querySelectorAll('[data-cnt]').forEach(inp=>inp.onchange=function(){
const name=this.dataset.cnt;
let row=_entry.causes.find(c=>c.name===name);
if(!row){row={name,rejected:0};_entry.causes.push(row);}
const size=parseFloat(_entry.batchSize)||0;
const othersSum=_entry.causes.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.rework)||0));
const v=Math.max(0,parseFloat(this.value)||0);
if(v>cap)toast(`Rejected + Sent for rework across all causes can't exceed Batch Size (${size}) — max here is ${cap}`,'err');
row.rejected=Math.min(cap,v);
render();
});
// Sent for rework: the OTHER independent pile — same combined-capacity
// rule as Rejected above, not related to Rejected itself anymore.
document.querySelectorAll('[data-rinc],[data-rdec]').forEach(b=>b.onclick=function(){
const name=this.dataset.rinc||this.dataset.rdec;
let row=_entry.causes.find(c=>c.name===name);
if(!row){row={name,rejected:0};_entry.causes.push(row);}
const size=parseFloat(_entry.batchSize)||0;
const othersSum=_entry.causes.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.rejected)||0));
const next=(row.rework||0)+(this.dataset.rinc?1:-1);
row.rework=Math.max(0,Math.min(cap,next));
// Lowering Sent for rework below the already-entered OK pulls OK down
// with it (OK can never exceed how many were actually sent back).
if((row.ok||0)>row.rework)row.ok=row.rework;
render();
});
document.querySelectorAll('[data-rw]').forEach(inp=>inp.onchange=function(){
const name=this.dataset.rw;
let row=_entry.causes.find(c=>c.name===name);
if(!row){row={name,rejected:0};_entry.causes.push(row);}
const size=parseFloat(_entry.batchSize)||0;
const othersSum=_entry.causes.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.rejected)||0));
const v=Math.max(0,parseFloat(this.value)||0);
if(v>cap)toast(`Rejected + Sent for rework across all causes can't exceed Batch Size (${size}) — max here is ${cap}`,'err');
row.rework=Math.min(cap,v);
if((row.ok||0)>row.rework){toast(`Okay after rework reduced to match — it can't exceed Sent for rework (${row.rework})`,'err');row.ok=row.rework;}
render();
});
// "Okay after rework" is a subset of Sent for rework specifically (not of
// Rejected — the two piles are independent now).
document.querySelectorAll('[data-oinc],[data-odec]').forEach(b=>b.onclick=function(){
const name=this.dataset.oinc||this.dataset.odec;
const row=_entry.causes.find(c=>c.name===name);
const next=(row.ok||0)+(this.dataset.oinc?1:-1);
row.ok=Math.max(0,Math.min(row.rework||0,next));
render();
});
document.querySelectorAll('[data-ok]').forEach(inp=>inp.onchange=function(){
const row=_entry.causes.find(c=>c.name===this.dataset.ok);
const v=Math.max(0,parseFloat(this.value)||0);
if(v>(row.rework||0))toast(`Okay after rework can't exceed Sent for rework (${row.rework||0})`,'err');
row.ok=Math.min(row.rework||0,v);
render();
});
}
async function submitEntry(){
_submitting=true;render();
try{
await api('POST','/rejection-register/entries',{
batchNo:_entry.batchNo.trim(), productCode:_lookupHit?.productCode||'', productName:_lookupHit?.productName||'',
stage:_entry.stage, shift:_entry.shift, prodDate:_entry.date, batchSize:_entry.batchSize,
causes:_entry.causes, remarks:_entry.remarks, company:_co,
});
_savedBatchNo=_entry.batchNo;
_submitting=false;_step='saved';render();
}catch(e){toast(e.message||'Failed','err');_submitting=false;render();}
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const cfg=await api('GET','/config');if(Array.isArray(cfg?.rejectionShifts)&&cfg.rejectionShifts.length)SHIFTS=cfg.rejectionShifts;}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=25"></script>
</body>
</html>