576 lines
39 KiB
HTML
576 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
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 & 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 & 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=31"></script>
|
||
</body>
|
||
</html>
|