1011 lines
65 KiB
HTML
1011 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>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');
|
||
// Consumable Orders never go through Receipt at all (workflow is
|
||
// Release → Issue → Close only) — there is no consumable Receipt
|
||
// permission of any kind, so this page treats a Consumable Order exactly
|
||
// like any other order: only the general Receipt step grants access.
|
||
const CAN_CREATE_CONSUMABLE=hasStepPerm('production_order:consumable_create','add');
|
||
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 _excludeByProductFromTotal=true; // System Settings → receiptExcludeByProductFromTotal — fetched in init(); keeps this page's own total check in sync with the server's
|
||
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
|
||
let _viewOrderType=''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
|
||
|
||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
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}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
|
||
_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=>!_excludeByProductFromTotal||!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>':''}${o.IsConsumable?'<span style="font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple)">CONSUMABLE</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>`;
|
||
const typeRow=CAN_CREATE_CONSUMABLE?`<div style="display:flex;gap:4px;margin-bottom:10px;flex-wrap:wrap;align-items:center">
|
||
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-right:2px">Type</span>
|
||
${[['','All'],['regular','Regular PWO'],['consumable','Consumable']].map(([v,l])=>`<button class="btn" style="padding:5px 11px;font-size:11px;${_viewOrderType===v?'background:rgba(159,90,253,.15);color:var(--purple);border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
|
||
</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}${typeRow}${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));
|
||
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;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;
|
||
// Consumable Orders never go through Receipt (workflow is Release → Issue
|
||
// → Close only) — there is no consumable-specific access rule here at
|
||
// all, so a Consumable Order's actual receive FORM is gated exactly like
|
||
// any other order's: the general Receipt step, nothing else.
|
||
const canActThisOrder=CAN_RECEIPT_PO;
|
||
|
||
// 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&&canActThisOrder)?`
|
||
<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=canActThisOrder?`
|
||
<div class="fg" style="margin-top:12px"><label class="fl">Warehouse, Quantity & 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.receiptExcludeByProductFromTotal==='boolean')_excludeByProductFromTotal=cfg.receiptExcludeByProductFromTotal;
|
||
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>
|