first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+991
View File
@@ -0,0 +1,991 @@
<!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>Receipt from Production — 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"/>
<script src="/lucide.min.js?v=399"></script>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--green2:#059669;--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(--green);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(--green);background:rgba(13,187,138,.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:12px;max-width:720px;margin:0 auto}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .ico{font-size:15px}
.card-bd{padding:12px 14px}
.fg{margin-bottom:10px}
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--green);box-shadow:0 0 0 2px rgba(13,187,138,.15)}.fi::placeholder{color:var(--text3)}
select.fi{cursor:pointer;appearance:none;background:var(--surface2) 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}
select.fi option{background:var(--surface2)}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:420px){.fi-row{grid-template-columns:1fr}}
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
/* Order cards — square-tile grid, same pattern as issue-production.html */
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.po-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:10px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.po-card:hover{border-color:var(--green);box-shadow:0 6px 18px rgba(13,187,138,.14);transform:translateY(-2px)}
.po-card.selected{border-color:var(--green);background:rgba(13,187,138,.10);box-shadow:0 0 0 2px rgba(13,187,138,.25)}
.po-top{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;flex-wrap:wrap}
.po-code{font-family:var(--mono);font-size:10px;color:var(--green);font-weight:600}
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(13,187,138,.12);color:var(--green);display:flex;align-items:center;justify-content:center;margin:5px 0;font-size:13px}
.po-name{font-size:11px;font-weight:600;color:var(--text);line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.po-meta{font-size:9.5px;color:var(--text3);display:flex;flex-direction:column;gap:2px;margin-top:auto;padding-top:5px;border-top:1px solid var(--border)}
.po-meta b{color:var(--text2)}
/* Detail / receipt popup */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
.modal-bg.show{display:flex}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:980px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between}
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
.order-info{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;font-size:11px}
@media(max-width:680px){.order-info{grid-template-columns:1fr 1fr}}
@media(max-width:420px){.order-info{grid-template-columns:1fr}}
.oi-item{display:flex;flex-direction:column;gap:1px}
.oi-l{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
.oi-v{font-family:var(--mono);font-size:11px;color:var(--text)}
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
.b-receipt{background:var(--green);color:#fff;flex:1}.b-receipt:hover:not(:disabled){background:var(--green2);box-shadow:0 4px 16px rgba(13,187,138,.4)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-new{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.3);flex:1}.b-new:hover{background:rgba(13,187,138,.2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
.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);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
.suc-box{text-align:center;padding:28px 16px}
.suc-icon{font-size:44px;margin-bottom:10px}
.suc-title{font-family:var(--display);font-size:26px;color:var(--green);margin-bottom:6px}
.suc-pill{display:inline-block;font-family:var(--mono);font-size:13px;padding:5px 16px;border-radius:6px;background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25);margin:8px 0 12px}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
/* Return Components table */
.ret-tbl{width:100%;border-collapse:separate;border-spacing:0}
.ret-tbl th{padding:8px 8px;font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--text3);text-align:left;border-bottom:2px solid var(--border2)}
.ret-tbl td{padding:10px 8px;vertical-align:middle;border-bottom:1px solid var(--border)}
.ret-tbl tr:nth-child(even) td{background:var(--surface3)}
.ret-tbl tr:hover td{background:rgba(245,166,35,.06)}
.ret-item{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--teal)}
.ret-desc{font-size:12px;color:var(--text2)}
.ret-issued{text-align:right;font-family:var(--mono);font-size:12px;color:var(--text2)}
.ret-batches{display:flex;flex-direction:column;gap:6px}
.ret-batch-row{display:flex;align-items:center;gap:8px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:5px 8px}
.ret-batch-num{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--teal);min-width:66px}
.ret-batch-avail{font-size:9.5px;color:var(--text3);white-space:nowrap;min-width:70px}
.ret-batch-row .fi{height:26px;font-size:11px;text-align:right;width:64px;margin-left:auto}
.ret-total{display:inline-block;font-family:var(--mono);font-size:13px;font-weight:700;color:var(--amber);background:rgba(245,166,35,.1);border-radius:6px;padding:4px 9px}
/* Multi-warehouse receipt rows */
.recv-row{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.recv-remove{width:30px;height:30px;flex:none;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--red);cursor:pointer;font-size:13px}
.recv-remove:hover{background:var(--red);color:#fff;border-color:var(--red)}
</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="/issue-production" class="nav-link">Issue</a>
<a href="/receipt-production" class="nav-link active">Receipt</a>
<a href="/grpo" class="nav-link">GRPO</a><a href="/approvals" class="nav-link">Approvals</a>
</div>
<button class="btn-sm" id="btn-logout">Logout</button>
</nav>
<main id="app"></main>
<div class="modal-bg" id="rcpt-detail-modal"><div class="modal" id="rcpt-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null,_wh=[],_branches=[],_varieties=[];
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='/';
// True only if the logged-in user has their own SAP login set (the JWT
// carries a non-empty sapUser claim). Used to fail fast — with a clear
// message — before attempting any SAP action, instead of the request
// silently failing deep inside the SAP call.
function hasSapLogin(){try{const p=JSON.parse(atob(_tok.split('.')[1]));return !!(p&&p.sapLogins&&p.sapLogins[_co]&&p.sapLogins[_co].pwdEnc);}catch(_e){return false;}}
function requireSapLogin(){if(!hasSapLogin()){toast('Set your SAP User ID & Password first — open Profile → "My SAP Account".','err');return false;}return true;}
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='/';};
// Mirrors middleware/auth.js's hasStepPerm — this page is normally only
// reached via production.html's already-gated button, but a direct/bookmarked
// URL should still be blocked here too (the server enforces this regardless).
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;
}
// Being assigned the step (any perm) is enough to perform the action.
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
const CAN_RECEIPT_PO=hasStepAssigned('production_order:receipt');
const CAN_RETURN_COMP=hasStepAssigned('production_order:return_components');
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
let _requireFullQty=true; // RECEIPT_REQUIRE_FULL_QTY from .env (see server.js /api/config) — fetched in init()
let _autoclaveEnabled=false; // RECEIPT_AUTOCLAVE_REJECTION flag
let _autoclaveGroups=[]; // RECEIPT_AUTOCLAVE_ITEM_GROUPS — ItmsGrpCod list that counts as FG
let _isFgItem=false; // is THIS order's item in one of those FG groups (looked up per order)
let _autoclaveQty=''; // single Autoclave Rejection qty — informational only, never sent to SAP
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _order=null;
let _loadingOrder=false;
let _submitting=false;
let _success=null;
let _readyOrders=[];
let _readyLoading=false;
let _modalOpen=false;
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
// Receipt fields
let _recvLines=[]; // [{warehouse, qty, transType}] — receipt can be split across multiple warehouses, each with its own transaction type
let _whTranMap={}; // admin mapping: warehouse code → 'C'|'R'. Transaction Type auto-derives from this (user can't pick it).
// Transaction Type for a warehouse — from the admin map, default Complete.
function whTran(wh){return _whTranMap[wh]==='R'?'Reject':'Complete';}
let _batchReq=true; // "Batch Number Required" checkbox → OIGN.U_IS_BATCH_REQ (1/0)
let _batchNumber='';
let _batchLocked=false;
let _mfgDate='';
let _expDate='';
let _datesLocked=false;
let _variety='';
let _postDate='';
// Return Components (defective components back to stock after Issuance)
let _retLines=[];
let _retSubmitting=false;
let _showReturn=false;
let _retBatchReq=true; // its own "Batch Number Required" checkbox — when on, every returned line needs a batch number
function today(){return new Date().toISOString().slice(0,10);}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
// Short date for cards — helps tell apart duplicate document numbers that
// SAP re-uses across fiscal years (the doc-number series is date-based).
function fmtDate(iso){if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';return `${String(d.getUTCDate()).padStart(2,'0')} ${_MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;}
function esc(v){return(v||'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
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)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);}
// Whether a next page of Released orders exists — from the server total when
// known, else from whether a full RAW (pre-filter) page came back.
function pagerHasNext(){ if(_total!=null) return (_page+1)*_pageSize<_total; return _rawCount>=_pageSize; }
function renderPager(){
if(_readyLoading) return '';
const hasNext=pagerHasNext();
const totalPages=_total!=null?Math.max(1,Math.ceil(_total/_pageSize)):null;
const label=_total!=null?`Page ${_page+1} of ${totalPages} · ${_total} released`:`Page ${_page+1}`;
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="rp-prev" ${_page===0?'disabled':''} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${_page===0?';opacity:.45;cursor:not-allowed':''}">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">${label}</span>
<button class="btn" id="rp-next" ${hasNext?'':'disabled'} style="padding:6px 14px;font-size:11px;background:var(--surface2);color:var(--text2);border:1px solid var(--border2)${hasNext?'':';opacity:.45;cursor:not-allowed'}">Next ›</button>
</div>`;
}
// Released orders that have components issued but aren't fully received yet
// — the default "ready to receive" list shown when not deep-linked.
// readyToReceive=1: server-side filters to fully-issued-not-fully-received
// orders when Admin → System Settings → "Require full issuance before
// Receipt/Close" is on (partial-issue orders excluded entirely — no point
// showing a card that would just 409). When that setting is off, the server
// leaves the list unfiltered by issuance — falls back to the same
// full-or-partial inclusion this always had.
async function loadReadyOrders(resetPage){
if(resetPage)_page=0;
_readyLoading=true;render();
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
try{
const r=await api('GET',`/sap/production-orders?status=Released&readyToReceive=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>(o._issueStatus==='full'||o._issueStatus==='partial')&&o._receiptStatus!=='done');
_total=(typeof r.total==='number')?r.total:null;
}catch(_e){_readyOrders=[];_rawCount=0;_total=null;}
_readyLoading=false;render();
}
async function loadWH(){try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_wh=r.data;}catch(_e){}}
async function loadWhTranMap(){try{const r=await api('GET',`/warehouse-trantype?company=${encodeURIComponent(_co)}`);_whTranMap=(r&&r.success&&r.data)?r.data:{};}catch(_e){_whTranMap={};}}
async function loadBranches(){try{const r=await api('GET',`/sap/lookup/branches?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_branches=r.data;}catch(_e){}}
async function loadVarieties(){try{const r=await api('GET',`/sap/lookup/costing-codes?dim=1&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_varieties=r.data;}catch(_e){}}
async function loadOrder(absEntry){
_modalOpen=true;_loadingOrder=true;_order=null;render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
_order=r.data||r;
await loadWhTranMap(); // ensure the warehouse→type map is current for this company
// Set defaults from order
const remaining=Math.max(0,(_order.PlannedQuantity||0)-(_order.CompletedQuantity||0));
_recvLines=[{warehouse:_order.Warehouse||(_wh[0]?.code||''),qty:remaining,transType:whTran(_order.Warehouse||(_wh[0]?.code||''))}];
// By-products / process loss: a Production Order can have component
// lines with a NEGATIVE Planned Qty (e.g. ICO10791 "PROCESS LOSS PROD")
// — these are real SAP by-product rows that need their OWN receipt line
// (its BaseLine, not the FG's), so default them in as extra rows here.
// Warehouse defaults to whatever SAP already has on that component line.
(_order.ProductionOrderLines||[]).forEach(l=>{
if((l.PlannedQuantity||0)<0){
const wh=l.Warehouse||_order.Warehouse||(_wh[0]?.code||'');
_recvLines.push({warehouse:wh,qty:Math.abs(l.PlannedQuantity),transType:whTran(wh),
isByProduct:true,itemCode:l.ItemNo||'',itemName:l.ItemName||'',lineNum:l.LineNumber});
}
});
// Batch Number comes from the order's own Remarks field — that's where
// it's captured at creation time (production.html carries the source
// Work Order's batch into Remarks, since Production Orders have no
// dedicated batch field of their own). Falls back to manual entry only
// if this order somehow has no Remarks (e.g. created outside the app).
_batchReq=true; // default: batch required (matches prior behavior)
_batchNumber=(_order.Remarks||'').trim();
_batchLocked=!!_batchNumber;
// MFG/EXP Date come from the Batch Issuance Intimation that started this
// order's Work Order (carried through onto our local production-order
// link record — SAP itself has no such fields). Falls back to manual
// entry if this order has no local link (e.g. created outside the app).
_mfgDate=(_order._localMfgDate||'').trim();
_expDate=(_order._localExpDate||'').trim();
_datesLocked=!!(_mfgDate||_expDate);
_rcptDateFmt=null; // redetect MFG/EXP format fresh for this order
// Component lines with something actually issued — the candidates for a
// defective-quantity return. Resources can't be returned to stock.
_retLines=(_order.ProductionOrderLines||[])
.filter(l=>l.ItemType!=='pit_Resource'&&(l.IssuedQuantity||0)>0)
.map(l=>({lineNum:l.LineNumber,itemCode:l.ItemNo||'',itemName:l.ItemName||'',
issuedQty:l.IssuedQuantity||0,returnQty:0,warehouse:l.Warehouse||_order.Warehouse||'',
batchNumber:'',batchLocked:false,batches:null}));
_showReturn=false;
// Autoclave Rejection (FG only): reset rows, then decide if the section
// applies by looking up THIS item's SAP Item Group against the configured
// FG group list. Non-blocking — the modal renders immediately.
_autoclaveQty='';_isFgItem=false;
}catch(e){toast(e.message,'err');}
_loadingOrder=false;render();
if(_retLines.length) loadReturnBatches();
if(_autoclaveEnabled&&_autoclaveGroups.length&&_order?.ItemNo) loadFgFlag(_order.ItemNo);
loadRecvUoms();
}
// Unit (Inventory UOM) shown next to each Warehouse/Qty/Transaction Type
// row's quantity — the FG's own UOM (_order.InventoryUOM) is usually already
// present, but by-product rows (e.g. ICO10791) need their own item's UOM
// looked up. Non-blocking, runs after the modal is already showing.
async function loadRecvUoms(){
const codes=[...new Set(_recvLines.map(rl=>rl.isByProduct?rl.itemCode:(_order?.ItemNo||'')).filter(Boolean))];
if(!codes.length)return;
try{
const r=await api('GET',`/sap/lookup/item-uoms?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data){
_recvLines.forEach(rl=>{
const code=rl.isByProduct?rl.itemCode:(_order?.ItemNo||'');
if(r.data[code])rl.uom=r.data[code];
});
render();
}
}catch(_e){/* non-fatal — rows just show without a unit label */}
}
async function loadFgFlag(itemCode){
try{
const r=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(itemCode)}&company=${encodeURIComponent(_co)}`);
const grp=r.data?.[itemCode];
_isFgItem=grp!=null&&_autoclaveGroups.includes(String(grp));
if(_isFgItem)render();
}catch(_e){/* lookup failed — just don't show the section */}
}
// A return goes back into whatever batch the component is CURRENTLY sitting
// in, in that warehouse right now (OIBT) — same source Issue for Production
// uses to auto-select batches, just reused here per return line. Runs after
// the modal is already showing so it doesn't block the initial order load.
async function loadReturnBatches(){
await Promise.all(_retLines.map(async(line)=>{
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(line.warehouse)}&company=${encodeURIComponent(_co)}`);
const data=r.data||[];
if(data.length===1){
line.batchNumber=data[0].BatchNumber;
line.batchLocked=true;
}else if(data.length>1){
// More than one batch currently in stock — show them all as
// separate rows (like Issue for Production does) so the user picks
// how much comes back from each, instead of one free-text field.
line.batches=data.map(b=>({BatchNumber:b.BatchNumber,Quantity:b.Quantity,returnQty:0}));
}
}catch(_e){/* item not batch-managed, or lookup failed — leave free-text */}
}));
render();
}
function fmtQty(n){return(Math.round((parseFloat(n)||0)*1000)/1000).toString();}
function recvTotal(){return _recvLines.filter(rl=>!rl.isByProduct).reduce((s,rl)=>s+(parseFloat(rl.qty)||0),0);}
// Content of the "Total: X NOS ✓ matches remaining" line — factored out so
// it can be surgically refreshed (see refreshRecvTotal()) when a qty input's
// onchange updates the total without a full renderModal() (which would drop
// focus / reset the modal's scroll on every edit).
function recvTotalHtml(){
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
return `Total: <strong style="color:var(--text);font-family:var(--mono)">${fmtQty(recvTotal())}</strong> ${esc(_order?.InventoryUOM||'PCS')}${_requireFullQty?` <span style="color:${qtyMatches?'var(--green)':'var(--red)'};font-weight:700">${qtyMatches?'✓ matches remaining':`✗ must equal remaining (${fmtQty(remainingQty())})`}</span>`:''}`;
}
function refreshRecvTotal(){
const el=document.getElementById('recv-total');if(el)el.innerHTML=recvTotalHtml();
// Post Receipt's disabled state also depends on the total (see renderModal's
// qtyMatches) — keep it in sync without a full re-render too.
const btn=document.getElementById('btn-receipt');
if(btn){
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
btn.disabled=_submitting||!qtyMatches;
}
}
// MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is
// picker-only (see rcptDateField()/openRcptDatePicker() below) — the field
// itself is read-only, so a malformed date can never be typed at all; these
// regexes exist to (a) validate historical free-typed data from before this
// redesign, and (b) let parseMEDate() work on whatever string is already
// saved. Same format set as Batch Issuance/Work Order — kept in sync there.
const MONTH_ABBR=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function pad2(n){return String(n).padStart(2,'0');}
const DATE_FORMATS=[
{id:'DMY', label:'DD-MMM-YYYY', kind:'full', re:/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/},
{id:'DMYn',label:'DD-MM-YYYY', kind:'full', re:/^(\d{1,2})-(\d{1,2})-(\d{4})$/},
{id:'MY', label:'MMM-YYYY', kind:'month', re:/^([A-Za-z]{3})[-/](\d{4})$/},
{id:'MYn', label:'MM-YYYY', kind:'month', re:/^(\d{1,2})-(\d{4})$/},
{id:'YM', label:'YYYY-MMM', kind:'month', re:/^(\d{4})-([A-Za-z]{3})$/},
{id:'YMn', label:'YYYY-MM', kind:'month', re:/^(\d{4})-(\d{1,2})$/},
];
function fmtById(id){return DATE_FORMATS.find(f=>f.id===id)||DATE_FORMATS[0];}
function detectFormat(v){
const t=(v||'').trim();
if(!t)return null;
for(const f of DATE_FORMATS) if(f.re.test(t)) return f.id;
return null;
}
function formatByPicked(iso,fmtId){
const parts=iso.split('-').map(Number);
const y=parts[0],mo=parts[1],d=parts[2];
const mon3=MONTH_ABBR[mo-1];
switch(fmtId){
case 'DMY': return `${pad2(d)}-${mon3}-${y}`;
case 'DMYn':return `${pad2(d)}-${pad2(mo)}-${y}`;
case 'MY': return `${mon3}-${y}`;
case 'MYn': return `${pad2(mo)}-${y}`;
case 'YM': return `${y}-${mon3}`;
case 'YMn': return `${y}-${pad2(mo)}`;
default: return `${pad2(d)}-${mon3}-${y}`;
}
}
function parseMEDate(v){
const t=(v||'').trim();
const fmtId=detectFormat(t);
if(!fmtId)return null;
const f=fmtById(fmtId),m=t.match(f.re);
const monFromName=s=>MONTH_ABBR.findIndex(x=>x.toLowerCase()===s.toLowerCase());
let y,mon,d=1;
switch(fmtId){
case 'DMY': d=parseInt(m[1]);mon=monFromName(m[2]);y=parseInt(m[3]);break;
case 'DMYn':d=parseInt(m[1]);mon=parseInt(m[2])-1;y=parseInt(m[3]);break;
case 'MY': mon=monFromName(m[1]);y=parseInt(m[2]);break;
case 'MYn': mon=parseInt(m[1])-1;y=parseInt(m[2]);break;
case 'YM': y=parseInt(m[1]);mon=monFromName(m[2]);break;
case 'YMn': y=parseInt(m[1]);mon=parseInt(m[2])-1;break;
}
if(mon==null||mon<0||mon>11)return null;
return new Date(y,mon,d);
}
function validMfgExp(v){return!v||!v.trim()||!!parseMEDate(v);}
// Converts our MFG/EXP string (any of the 6 formats, day defaults to 1st for
// month-only ones) into the ISO date SAP's BatchNumbers ManufacturingDate/
// ExpiryDate fields expect. Returns null if it doesn't parse — those fields
// are then simply omitted from the post rather than sent malformed.
function mfgExpToISO(v){
const dt=parseMEDate(v);
if(!dt)return null;
return `${dt.getFullYear()}-${pad2(dt.getMonth()+1)}-${pad2(dt.getDate())}`;
}
// Picker-only entry (same pattern as Batch Issuance/Work Order) for the two
// plain MFG/EXP fields here — 'mfg' reads/writes _mfgDate, 'exp' _expDate.
// Both fields always share ONE format (changing either dropdown changes
// both) — reset to null (→ redetected) wherever _mfgDate/_expDate get
// reset to a fresh receipt (see closeRcptModal() and wherever a new order
// is loaded into the modal).
let _rcptDateFmt=null;
function rcptCurFmt(){
if(!_rcptDateFmt)_rcptDateFmt=detectFormat(_mfgDate)||detectFormat(_expDate)||'DMY';
return _rcptDateFmt;
}
function reformatDate(value,newFmtId){
const dt=parseMEDate(value);
if(!dt)return value;
const iso=`${dt.getFullYear()}-${pad2(dt.getMonth()+1)}-${pad2(dt.getDate())}`;
return formatByPicked(iso,newFmtId);
}
function rcptDateField(which,value,locked){
if(locked)return `<input class="fi fi-mono" id="inp-${which}date" value="${esc(value)}" readonly style="background:var(--bg2);cursor:not-allowed"/>`;
const fmtId=rcptCurFmt();
return `<div style="display:flex;gap:4px;align-items:center">
<select class="fi" data-rdfmt="1" style="width:100px;flex-shrink:0;font-size:11px;padding:0 16px 0 6px">
${DATE_FORMATS.map(f=>`<option value="${f.id}" ${fmtId===f.id?'selected':''}>${f.label}</option>`).join('')}
</select>
<input class="fi fi-mono" id="inp-${which}date" value="${esc(value)}" placeholder="Pick a date" readonly autocomplete="off" style="flex:1;min-width:0;cursor:pointer;background:var(--surface2)" data-rdpopen="${which}"/>
<button type="button" class="date-pick-btn" data-rdp="${which}" title="Pick date" style="flex-shrink:0;width:34px;height:34px;border:1px solid var(--border2);border-radius:7px;background:var(--surface);color:var(--text3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><i data-lucide="calendar" style="width:13px;height:13px"></i></button>
</div>`;
}
function getDatePickerEl(){
let el=document.getElementById('inline-date-input');
if(!el){
el=document.createElement('input');el.id='inline-date-input';
el.style.cssText='position:fixed;z-index:9000;width:130px;height:30px;opacity:.01;border:none';
document.body.appendChild(el);
}
return el;
}
function openRcptDatePicker(which,btn){
const fmtId=rcptCurFmt();
const kind=fmtById(fmtId).kind;
const el=getDatePickerEl();
el.type=kind==='full'?'date':'month';
el.min='';
if(which==='exp'){
const md=parseMEDate(_mfgDate);
if(md){
const pad=n=>String(n).padStart(2,'0');
if(kind==='full'){
const nd=new Date(Date.UTC(md.getFullYear(),md.getMonth(),md.getDate()+1));
el.min=`${nd.getUTCFullYear()}-${pad(nd.getUTCMonth()+1)}-${pad(nd.getUTCDate())}`;
}else{
const nm=new Date(Date.UTC(md.getFullYear(),md.getMonth()+1,1));
el.min=`${nm.getUTCFullYear()}-${pad(nm.getUTCMonth()+1)}`;
}
}
}
const r=btn.getBoundingClientRect();
const z=parseFloat(getComputedStyle(document.body).zoom)||1;
el.style.left=(r.left/z)+'px';
el.style.top=((r.bottom+4)/z)+'px';
el.value='';
el.onchange=()=>{
if(!el.value)return;
const v=formatByPicked(el.value,fmtId);
if(which==='mfg')_mfgDate=v; else _expDate=v;
render();
};
el.focus();
if(el.showPicker)el.showPicker();
}
function remainingQty(){return Math.max(0,(_order?.PlannedQuantity||0)-(_order?.CompletedQuantity||0));}
function returnLineTotal(l){
return l.batches&&l.batches.length>1?l.batches.reduce((s,b)=>s+(parseFloat(b.returnQty)||0),0):(parseFloat(l.returnQty)||0);
}
async function doReturnComponents(){
if(!_order){toast('Load an order first','err');return;}
const toReturn=_retLines.filter(l=>returnLineTotal(l)>0);
if(!toReturn.length){toast('Enter a return quantity for at least one component','err');return;}
for(const l of toReturn){
const total=returnLineTotal(l);
if(total>l.issuedQty){toast(`${l.itemCode}: return qty (${total}) exceeds issued qty (${l.issuedQty})`,'err');return;}
// Multi-batch lines always carry a real batch number (picked from stock);
// only the free-text single-batch case can be left blank.
const multi=l.batches&&l.batches.length>1;
if(_retBatchReq&&!multi&&!l.batchNumber.trim()){toast(`${l.itemCode}: batch number is required`,'err');return;}
}
if(!requireSapLogin())return;
if(!confirm(`Return ${toReturn.length} component(s) to stock as defective?`))return;
_retSubmitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const payload={
company:_co,
branchId:bplId,
DocumentLines:toReturn.map(l=>{
const total=returnLineTotal(l);
const dl={BaseEntry:_order.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,Quantity:total,WarehouseCode:l.warehouse};
if(l.batches&&l.batches.length>1){
dl.BatchNumbers=l.batches.filter(b=>b.returnQty>0).map(b=>({BatchNumber:b.BatchNumber,Quantity:b.returnQty}));
}else if(l.batchNumber.trim()){
dl.BatchNumbers=[{BatchNumber:l.batchNumber.trim(),Quantity:total}];
}
return dl;
}),
};
if(_postDate){payload.DocDate=_postDate;payload.DocDueDate=_postDate;}
try{
const r=await api('POST','/sap/return-components',payload);
toast(`Components returned — Doc #${r.data?.DocNum||r.data?.DocEntry||''}`);
await loadOrder(_order.AbsoluteEntry); // refresh issued quantities
}catch(e){toast(e.message||'Failed','err');}
_retSubmitting=false;render();
}
async function doReceipt(){
if(!_order){toast('Load an order first','err');return;}
const lines=_recvLines.filter(rl=>(parseFloat(rl.qty)||0)>0);
if(!lines.length){toast('Quantity must be > 0','err');return;}
if(lines.some(rl=>!rl.warehouse)){toast('Select a warehouse for every line','err');return;}
// Batch/date fields are required ONLY when "Batch Number Required" is on.
if(_batchReq){
if(!_batchNumber.trim()){toast('Batch number is required','err');return;}
if(!_mfgDate.trim()){toast('Mfr Date is required when batch is required','err');return;}
if(!_expDate.trim()){toast('Expiration Date is required when batch is required','err');return;}
if(!validMfgExp(_mfgDate)){toast(`Invalid Mfr Date "${_mfgDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
if(!validMfgExp(_expDate)){toast(`Invalid Expiration Date "${_expDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
}
if(_requireFullQty&&Math.abs(recvTotal()-remainingQty())>0.0001){
toast(`Total receipt quantity (${fmtQty(recvTotal())}) must equal the remaining planned quantity (${fmtQty(remainingQty())})`,'err');return;
}
if(!requireSapLogin())return;
_submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const mfgISO=mfgExpToISO(_mfgDate);
const expISO=mfgExpToISO(_expDate);
const payload={
company:_co,
itemCode:_order.ItemNo||'', // lets the backend locate the batch master to fix its dates
branchId:bplId,
batchReq:_batchReq, // → OIGN.U_IS_BATCH_REQ (1/0) on the header
batchNo:_batchReq?_batchNumber.trim():'', // → OIGN.U_BTCHNO header UDF
DocumentLines:lines.map(rl=>rl.isByProduct?({
// Process-loss/by-product line (e.g. ICO10791) — a real, DIFFERENT
// component of the Production Order (its own BaseLine), not another
// warehouse split of the FG. No batch tracking (these items aren't
// batch-managed), no Reject/Complete classification (that's a FG
// production-quality concept, not applicable to a waste/scrap item).
BaseEntry:_order.AbsoluteEntry,
BaseLine:rl.lineNum,
BaseType:202,
Quantity:parseFloat(rl.qty)||0,
WarehouseCode:rl.warehouse,
TransactionType:'C',
isByProduct:true,
}):({
BaseEntry:_order.AbsoluteEntry,
BaseLine:0,
BaseType:202,
Quantity:parseFloat(rl.qty)||0,
WarehouseCode:rl.warehouse,
TransactionType:whTran(rl.warehouse)==='Reject'?'R':'C',
CostingCode:_variety||undefined,
// Only carry batch details when the user asked for batch tracking.
...(_batchReq?{BatchNumbers:[{
BatchNumber:_batchNumber.trim(),
Quantity:parseFloat(rl.qty)||0,
...(mfgISO?{ManufacturingDate:mfgISO}:{}),
...(expISO?{ExpiryDate:expISO}:{}),
}]}:{})
}))
};
if(_postDate){payload.DocDate=_postDate;payload.DocDueDate=_postDate;}
// Autoclave Rejection (FG only) — informational, saved to app DB only on a
// successful SAP post. Sent alongside, but the backend keeps it out of the
// SAP document entirely.
if(_autoclaveEnabled&&_isFgItem){
const qty=parseFloat(_autoclaveQty)||0;
if(qty>0){payload.autoclaveRejections=[qty];payload.itemCode=_order.ItemNo||'';}
}
try{
const r=await api('POST','/sap/receipt-production',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Receipt from Production posted!');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
function render(){
try{_doRender();}catch(e){console.error('Render error:',e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
if(window.lucide&&lucide.createIcons)lucide.createIcons();
}
function closeRcptModal(){_modalOpen=false;_order=null;_success=null;_recvLines=[];_batchReq=true;_batchNumber='';_batchLocked=false;_mfgDate='';_expDate='';_datesLocked=false;_rcptDateFmt=null;_variety='';_autoclaveQty='';_isFgItem=false;_retBatchReq=true;render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
// Return Components lives on this page too — a user holding ONLY that
// permission still gets in; each form below is gated individually.
if(!CAN_RECEIPT_PO&&!CAN_RETURN_COMP){
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Receipt from Production" approval step. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
document.getElementById('rcpt-detail-modal')?.classList.remove('show');
return;
}
// Company + Branch
const c1=mk('card');
const brOpts=_branches.map(b=>`<option value="${b.BPLId}" ${b.BPLId===2?'selected':''}>${esc(b.BPLName)} (${b.BPLId})</option>`).join('')||'<option value="2">FACTORY (2)</option>';
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Receipt from Production</div>
<div class="card-bd">
<div class="fi-row">
<div class="fg"><label class="fl">Company</label><select class="fi" id="sel-co">
${coOptions(_co)}
</select></div>
<div class="fg"><label class="fl">Branch</label><select class="fi" id="sel-branch">${brOpts}</select></div>
</div>
</div>`;
app.appendChild(c1);
// Order input (manual AbsoluteEntry — still supported alongside the picker)
// Orders ready to receive — square-tile grid, opens the detail popup on click.
const c2b=mk('card');
const gridBody=_readyLoading
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(13,187,138,.25);border-top-color:var(--green)"></span> Loading released orders...</div>'
:!_readyOrders.length?'<div class="empty-msg">No issued orders are awaiting receipt right now.</div>'
:`<div class="po-grid">${_readyOrders.map(o=>`<div class="po-card${_order&&_order.AbsoluteEntry===o.AbsoluteEntry?' selected':''}" data-ready="${o.AbsoluteEntry}">
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${o._issueStatus==='partial'?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(245,166,35,.12);color:var(--amber)">PARTIAL</span>':''}</div>
<div class="po-icon">📥</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-meta"><span>PWO No#<b>${o.DocumentNumber||'-'}</b>${o.PostingDate?` · ${fmtDate(o.PostingDate)}`:''}</span><span>Qty <b>${o.PlannedQuantity}</b> · Done <b>${o.CompletedQuantity||0}</b> · WH <b>${esc(o.Warehouse||'-')}</b></span></div>
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
</div>`).join('')}</div>`;
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--green);color:#fff;white-space:nowrap">Search</button>
</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Receive<span class="right">${_readyOrders.length}</span></div>
<div class="card-bd">
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">Only Released orders that are fully issued (all components) are listed here, per Admin → System Settings → "Require full issuance before Receipt/Close". A partially-issued order isn't shown — issue the rest first, then it appears here.</div>
${searchRow}${gridBody}${renderPager()}</div>`;
app.appendChild(c2b);
renderModal();
// Wire (page-level controls)
setTimeout(()=>{
q('#sel-co')&&(q('#sel-co').onchange=async function(){_co=this.value;await Promise.all([loadWH(),loadBranches(),loadVarieties(),loadWhTranMap()]);loadReadyOrders(true);});
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
q('#rp-search')&&(q('#rp-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadReadyOrders(true);}});
q('#rp-load')&&(q('#rp-load').onclick=()=>loadReadyOrders(true));
q('#rp-prev')&&(q('#rp-prev').onclick=()=>{if(_page>0){_page--;loadReadyOrders();}});
q('#rp-next')&&(q('#rp-next').onclick=()=>{if(pagerHasNext()){_page++;loadReadyOrders();}});
document.querySelectorAll('[data-ready]').forEach(c=>{c.onclick=()=>loadOrder(parseInt(c.dataset.ready));});
},0);
}
// ═══════════════════════════════════════
// DETAIL / RECEIPT POPUP
// ═══════════════════════════════════════
function renderModal(){
const bg=document.getElementById('rcpt-detail-modal');
const inner=document.getElementById('rcpt-modal-inner');
if(!_modalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
// Success
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Receipt from Production — Posted</span><button class="modal-close" id="rcpt-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Receipt Posted!</div>
<p style="font-size:13px;color:var(--text3)">Finished goods received for Production Order #${esc(String(_order?.DocumentNumber||_order?.AbsoluteEntry||''))}</p>
<div class="suc-pill">DocEntry #${esc(String(_success.docEntry))} | DocNum #${esc(String(_success.docNum))}</div>
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">
<strong>${esc(_order?.ItemNo||'')}</strong> — ${fmtQty(recvTotal())} ${esc(_order?.InventoryUOM||'PCS')} received into inventory.<br/>
${_batchReq?`Batch: <strong>${esc(_batchNumber)}</strong> | `:''}Warehouse: <strong>${esc(_recvLines.map(rl=>rl.warehouse).filter(Boolean).join(', '))}</strong>${_batchReq&&(_mfgDate||_expDate)?`<br/>Mfr: <strong>${esc(_mfgDate||'-')}</strong> | Exp: <strong>${esc(_expDate||'-')}</strong>`:''}
</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn" id="btn-close-order" style="flex:1;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)">Close Order</button>
<button class="btn b-new" id="btn-new">Done</button>
</div>
</div></div>`;
setTimeout(()=>{
q('#rcpt-modal-x').onclick=closeRcptModal;
q('#btn-new').onclick=closeRcptModal;
q('#btn-close-order')&&(q('#btn-close-order').onclick=function(){
window.location.href=`/close-production#order=${_order.AbsoluteEntry}&company=${encodeURIComponent(_co)}`;
});
},0);
return;
}
// Loading
if(_loadingOrder||!_order){
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="rcpt-modal-x">✕</button></div>
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(13,187,138,.25);border-top-color:var(--green)"></span> Loading order details...</div></div>`;
setTimeout(()=>{q('#rcpt-modal-x').onclick=closeRcptModal;},0);
return;
}
const d=_order;
const qtyMatches=!_requireFullQty||Math.abs(recvTotal()-remainingQty())<=0.0001;
// Autoclave Rejection (FG items only, flag-gated). Purely informational —
// single manual value, separate from all receipt quantities, never sent to SAP.
const autoclaveHtml=(_autoclaveEnabled&&_isFgItem&&CAN_RECEIPT_PO)?`
<div class="fg" style="border-top:1px solid var(--border);margin-top:12px;padding-top:10px">
<label class="fl" style="display:flex;align-items:center;gap:6px">Autoclave Rejection <span style="font-weight:400;text-transform:none;color:var(--text3)">— FG only, for records; not sent to SAP, no effect on quantities</span></label>
<input class="fi fi-mono" id="inp-autoclave" type="number" min="0" step="1" value="${esc(String(_autoclaveQty))}" placeholder="Rejection Qty" style="text-align:right"/>
</div>`:'';
const fgRowCount=_recvLines.filter(rl=>!rl.isByProduct).length;
const receiptHtml=CAN_RECEIPT_PO?`
<div class="fg" style="margin-top:12px"><label class="fl">Warehouse, Quantity &amp; Transaction Type * <span style="font-weight:400;text-transform:none;color:var(--text3)">(Transaction Type comes automatically from the warehouse — set by admin)</span></label>
<div id="recv-lines">${_recvLines.map((rl,i)=>{
// Transaction Type is auto-derived from the warehouse's admin mapping
// (read-only) — the user only picks the warehouse & quantity.
const tt=whTran(rl.warehouse);
// By-product/process-loss rows (e.g. ICO10791, a negative-qty
// component already on the Production Order) are a DIFFERENT item
// from the FG being received — shown read-only in an Item column so
// they're not mistaken for another warehouse split of the FG. They
// can't be removed (they're a real, mandatory SAP component line).
const itemCell=rl.isByProduct
?`<div class="fi" style="flex:1.1;display:flex;flex-direction:column;justify-content:center;line-height:1.25;background:var(--bg2,var(--surface2));cursor:not-allowed" title="${esc(rl.itemName)} — process loss/by-product, from the Production Order">
<span style="font-family:var(--mono);font-weight:700">${esc(rl.itemCode)}</span>
<span style="font-size:9.5px;color:var(--text3)">${esc(rl.itemName)}</span>
</div>`
:`<div class="fi" style="flex:1.1;display:flex;align-items:center;font-weight:700;background:var(--bg2,var(--surface2));cursor:not-allowed;font-family:var(--mono)">${esc(d.ItemNo||'')}</div>`;
const unit=rl.uom||(rl.isByProduct?'':(d.InventoryUOM||''));
return `
<div class="recv-row">
${itemCell}
<select class="fi" data-rl="${i}" data-rf="warehouse" style="flex:1.4">${_wh.map(w=>`<option value="${esc(w.code)}" ${rl.warehouse===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}${_whTranMap[w.code]?` [${_whTranMap[w.code]==='R'?'Reject':'Complete'}]`:''}</option>`).join('')}</select>
<div style="position:relative;width:110px">
<input class="fi fi-mono" data-rl="${i}" data-rf="qty" type="number" min="${rl.isByProduct?'0.000001':'0.001'}" step="${rl.isByProduct?'0.000001':'0.001'}" value="${rl.qty}" style="text-align:right;width:100%;${unit?'padding-right:38px':''}"/>
${unit?`<span style="position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:9.5px;color:var(--text3);pointer-events:none">${esc(unit)}</span>`:''}
</div>
<div class="fi" data-rl-tt="${i}" style="flex:1;display:flex;align-items:center;font-weight:700;background:var(--bg2,var(--surface2));cursor:not-allowed;color:${tt==='Reject'?'var(--red)':'var(--green)'}">${tt}</div>
${(!rl.isByProduct&&fgRowCount>1)?`<button type="button" class="recv-remove" data-rl="${i}" title="Remove">✕</button>`:''}
</div>`;}).join('')}</div>
<button type="button" class="btn b-cancel" id="btn-add-wh" style="margin-top:6px;padding:6px 14px;font-size:12px">+ Add Warehouse</button>
<div id="recv-total" style="margin-top:6px;font-size:11px;color:var(--text3)">${recvTotalHtml()}</div>
</div>
<div class="fg" style="margin-top:4px;border-top:1px solid var(--border);padding-top:10px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text);font-weight:600">
<input type="checkbox" id="chk-batchreq" ${_batchReq?'checked':''} style="width:16px;height:16px;accent-color:var(--green);cursor:pointer"/>
Batch Number Required <span style="font-weight:400;color:var(--text3)">— when on, batch no. + Mfr/Exp dates are captured (saved to SAP as U_IS_BATCH_REQ)</span>
</label>
</div>
${_batchReq?`
<div class="fg"><label class="fl">Batch Number * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_batchLocked?'(from order Remarks)':'(enter manually — no Remarks on this order)'}</span></label>
<input class="fi fi-mono" id="inp-batch" value="${esc(_batchNumber)}" placeholder="e.g. BATCH-09-04-2026" autocomplete="off" ${_batchLocked?'readonly':''} style="${_batchLocked?'background:var(--bg2);cursor:not-allowed':''}"/>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Mfr Date * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_datesLocked?'(from Batch Issuance Intimation)':'(enter manually)'}</span></label>
${rcptDateField('mfg',_mfgDate,_datesLocked)}
</div>
<div class="fg"><label class="fl">Expiration Date * <span style="font-weight:400;text-transform:none;color:var(--text3)">${_datesLocked?'(from Batch Issuance Intimation)':'(enter manually)'}</span></label>
${rcptDateField('exp',_expDate,_datesLocked)}
</div>
</div>`:''}
<div class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${_postDate||today()}"/></div>
<div class="alert al-amber" style="margin-top:4px">${_batchReq?(_batchLocked?'Batch number is taken from the production order\'s Remarks (set when it was created) and will be created in SAP B1.':'Enter the batch number for the finished product being received into inventory. This batch will be created in SAP B1.'):'Batch tracking is OFF for this receipt — no batch number or Mfr/Exp dates will be recorded in SAP.'}</div>
${autoclaveHtml}
<div class="btn-bar" style="margin-top:8px">
<button class="btn b-receipt" id="btn-receipt" ${_submitting||!qtyMatches?'disabled':''}><span id="sp-receipt" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Post Receipt</button>
</div>`:'';
// Return Components — corrective action, own permission
let returnHtml='';
if(CAN_RETURN_COMP&&_retLines.length){
const rows=_retLines.map((l,i)=>{
const multi=l.batches&&l.batches.length>1;
const qtyCell=multi
?`<span id="ret-total-${i}" class="ret-total">${fmtQty(returnLineTotal(l))}</span>`
:`<input class="fi fi-mono" data-ret="${i}" data-rf="returnQty" type="number" min="0" max="${l.issuedQty}" step="0.001" value="${l.returnQty}" style="height:30px;font-size:12px;text-align:right;width:80px"/>`;
const batchCell=multi
?`<div class="ret-batches">${l.batches.map((b,bi)=>`
<div class="ret-batch-row">
<span class="ret-batch-num">${esc(b.BatchNumber)}</span>
<span class="ret-batch-avail">avail ${fmtQty(b.Quantity)}</span>
<input class="fi fi-mono" data-ret="${i}" data-batch="${bi}" data-brf="returnQty" type="number" min="0" max="${b.Quantity}" step="0.001" value="${b.returnQty}"/>
</div>`).join('')}</div>`
:`<input class="fi fi-mono" data-ret="${i}" data-rf="batchNumber" value="${esc(l.batchNumber)}" placeholder="Batch" title="${l.batchLocked?'Current batch in warehouse':''}" ${l.batchLocked?'readonly':''} style="height:30px;font-size:12px;width:110px${l.batchLocked?';background:var(--bg2);cursor:not-allowed':''}"/>`;
return `<tr>
<td class="ret-item">${esc(l.itemCode)}</td>
<td class="ret-desc">${esc(l.itemName)}</td>
<td class="ret-issued">${fmtQty(l.issuedQty)}</td>
<td>${qtyCell}</td>
<td><select class="fi" data-ret="${i}" data-rf="warehouse" style="height:30px;font-size:12px;padding:2px 20px 2px 6px;width:160px" title="${esc(l.warehouse)}">${_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('')}</select></td>
<td>${batchCell}</td>
</tr>`;
}).join('');
returnHtml=`<div style="border-top:1px solid var(--border);margin-top:14px;padding-top:12px">
<div class="fl" style="display:flex;align-items:center;gap:6px">Return Components <span style="font-weight:400;text-transform:none;color:var(--text3)">— defective quantities back to stock</span></div>
${_showReturn?`
<div class="alert al-amber" style="margin-top:6px">Enter the DEFECTIVE quantity to return for each affected component. Returned quantities go back into the selected warehouse and reduce the order's issued quantity in SAP.</div>
<div class="fg" style="margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:var(--text);font-weight:600">
<input type="checkbox" id="chk-ret-batchreq" ${_retBatchReq?'checked':''} style="width:16px;height:16px;accent-color:var(--green);cursor:pointer"/>
Batch Number Required <span style="font-weight:400;color:var(--text3)">— when on, every returned line must have a batch number before submitting</span>
</label>
</div>
<div style="overflow-x:auto"><table class="ret-tbl">
<thead><tr>
<th>Item</th><th>Description</th><th style="text-align:right">Issued</th><th>Return Qty</th><th>Warehouse</th><th>Batch</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>
<div class="btn-bar" style="margin-top:10px">
<button class="btn" id="btn-return" ${_retSubmitting||!_retLines.some(l=>returnLineTotal(l)>0)?'disabled':''} style="flex:1;background:var(--amber);color:#fff"><span id="sp-return" style="display:${_retSubmitting?'inline-block':'none'}" class="sp"></span> Return Components</button>
<button class="btn b-cancel" id="btn-ret-hide">Hide</button>
</div>`
:`<button class="btn" id="btn-ret-show" style="width:100%;margin-top:6px;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)">Return Components (defective items)</button>`}
</div>`;
}
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--green)">${esc(d.ItemNo)}</span>
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
<button class="modal-close" id="rcpt-modal-x" style="margin-left:auto">✕</button>
</div>
<div class="modal-bd">
<div class="order-info">
<div class="oi-item"><span class="oi-l">Product</span><span class="oi-v" style="color:var(--green)">${esc(d.ItemNo)}</span></div>
<div class="oi-item"><span class="oi-l">Description</span><span class="oi-v" style="font-family:var(--font)">${esc(d.ProductDescription||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Planned</span><span class="oi-v">${d.PlannedQuantity}</span></div>
<div class="oi-item"><span class="oi-l">Completed</span><span class="oi-v">${d.CompletedQuantity||0}</span></div>
<div class="oi-item"><span class="oi-l">Remaining</span><span class="oi-v" style="color:var(--amber)">${Math.max(0,(d.PlannedQuantity||0)-(d.CompletedQuantity||0))}</span></div>
<div class="oi-item"><span class="oi-l">Warehouse</span><span class="oi-v">${esc(d.Warehouse||'-')}</span></div>
</div>
${receiptHtml}
${returnHtml}
</div>`;
setTimeout(()=>{
q('#rcpt-modal-x').onclick=closeRcptModal;
q('#btn-add-wh')&&(q('#btn-add-wh').onclick=function(){
// Insert before any by-product rows so the FG's own warehouse splits
// stay grouped together at the top of the table.
const insertAt=_recvLines.findIndex(rl=>rl.isByProduct);
const newRow={warehouse:_wh[0]?.code||'',qty:0,transType:whTran(_wh[0]?.code||'')};
if(insertAt===-1)_recvLines.push(newRow); else _recvLines.splice(insertAt,0,newRow);
renderModal();
});
document.querySelectorAll('.recv-remove').forEach(el=>{
el.onclick=function(){_recvLines.splice(parseInt(this.dataset.rl),1);renderModal();};
});
document.querySelectorAll('[data-rl]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.rl),f=this.dataset.rf;
const rl=_recvLines[i];if(!rl)return;
if(f==='qty'){rl.qty=parseFloat(this.value)||0;refreshRecvTotal();return;} // qty: no full re-render (keep focus) — just patch the Total line
rl.warehouse=this.value;
rl.transType=whTran(this.value);
// update the Transaction Type cell for this row immediately
const tt=rl.transType;
const ttEl=document.querySelector(`[data-rl-tt="${i}"]`);
if(ttEl){ttEl.textContent=tt;ttEl.style.color=(tt==='Reject'?'var(--red)':'var(--green)');}
};
});
q('#chk-batchreq')&&(q('#chk-batchreq').onchange=function(){_batchReq=this.checked;renderModal();});
q('#chk-ret-batchreq')&&(q('#chk-ret-batchreq').onchange=function(){_retBatchReq=this.checked;renderModal();});
q('#inp-batch')&&!_batchLocked&&(q('#inp-batch').oninput=function(){_batchNumber=this.value.toUpperCase();this.value=this.value.toUpperCase();});
document.querySelectorAll('[data-rdp]').forEach(btn=>btn.onclick=function(){openRcptDatePicker(this.dataset.rdp,this);});
document.querySelectorAll('[data-rdpopen]').forEach(inp=>inp.onclick=function(){openRcptDatePicker(this.dataset.rdpopen,this);});
document.querySelectorAll('[data-rdfmt]').forEach(sel=>sel.onchange=function(){
_rcptDateFmt=this.value;
_mfgDate=reformatDate(_mfgDate,this.value);
_expDate=reformatDate(_expDate,this.value);
render();
});
q('#inp-postdate')&&(q('#inp-postdate').onchange=function(){_postDate=this.value;});
// Autoclave Rejection wiring — single field, updates state in place so
// it keeps focus (no re-render on typing).
q('#inp-autoclave')&&(q('#inp-autoclave').oninput=function(){_autoclaveQty=this.value;});
q('#btn-receipt')&&(q('#btn-receipt').onclick=doReceipt);
// Return Components wiring
q('#btn-ret-show')&&(q('#btn-ret-show').onclick=()=>{_showReturn=true;renderModal();});
q('#btn-ret-hide')&&(q('#btn-ret-hide').onclick=()=>{_showReturn=false;renderModal();});
q('#btn-return')&&(q('#btn-return').onclick=doReturnComponents);
document.querySelectorAll('[data-ret]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.ret);
const l=_retLines[i];if(!l)return;
if(this.dataset.brf==='returnQty'){
const bi=parseInt(this.dataset.batch);
const b=l.batches?.[bi];if(!b)return;
b.returnQty=Math.min(parseFloat(this.value)||0,b.Quantity);
this.value=b.returnQty;
l.returnQty=returnLineTotal(l);
const totalEl=q(`#ret-total-${i}`);if(totalEl)totalEl.textContent=fmtQty(l.returnQty);
}else if(this.dataset.rf==='returnQty'){
l.returnQty=parseFloat(this.value)||0;
}else{
l[this.dataset.rf]=this.value;
}
const btn=q('#btn-return');if(btn)btn.disabled=_retSubmitting||!_retLines.some(x=>returnLineTotal(x)>0);
};
});
},0);
}
async function init(){
try{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
_postDate=today();
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{
const cfg=await api('GET','/config');
if(cfg&&typeof cfg.receiptRequireFullQty==='boolean')_requireFullQty=cfg.receiptRequireFullQty;
if(cfg&&typeof cfg.receiptAutoclaveRejection==='boolean')_autoclaveEnabled=cfg.receiptAutoclaveRejection;
if(cfg&&Array.isArray(cfg.receiptAutoclaveItemGroups))_autoclaveGroups=cfg.receiptAutoclaveItemGroups.map(String);
}catch(_e){}
await Promise.all([loadWH(),loadBranches(),loadVarieties(),loadWhTranMap()]);
render();
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry) await loadOrder(entry);
}else{
// No specific order in the URL — show what's ready to receive by default.
loadReadyOrders();
}
}catch(e){console.error('Init error:',e);render();}
}
init();
// Hash-only link changes (e.g. the sidebar "Pending Actions" bell linking
// here for a different order while already on this page) don't reload the
// page, so init() alone would never pick up the new order — re-check on
// hashchange too. See issue-production.html for the fuller explanation.
window.addEventListener('hashchange',()=>{
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch)_co=decodeURIComponent(coMatch[1]);
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry)loadOrder(entry);
}
});
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>