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

992 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>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>