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

710 lines
44 KiB
HTML
Raw Permalink 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>Issue for 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"/>
<style>
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--purple2:#7c3aed;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
.nav-logo{background:var(--teal);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(--teal);background:rgba(6,182,212,.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-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
.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(--teal);box-shadow:0 0 0 2px rgba(6,182,212,.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 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(--teal);box-shadow:0 6px 18px rgba(6,182,212,.14);transform:translateY(-2px)}
.po-card.selected{border-color:var(--teal);background:rgba(6,182,212,.10);box-shadow:0 0 0 2px rgba(6,182,212,.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(--teal);font-weight:600}
.po-doc{font-family:var(--mono);font-size:10px;color:var(--text3)}
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(6,182,212,.12);color:var(--teal);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 / issue 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:640px;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}
/* Component cards */
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;position:relative;transition:opacity .15s,border-color .15s}
.comp-card:hover{border-color:var(--border2)}
.comp-card.unselected{opacity:.5}
.comp-card.done{opacity:.65}
.comp-top-row{display:flex;align-items:center;gap:8px;margin-bottom:2px}
.comp-check{width:16px;height:16px;accent-color:var(--teal);cursor:pointer;flex-shrink:0}
.comp-actions{margin-left:auto;display:flex;align-items:center;gap:6px}
.comp-remove{background:none;border:1px solid var(--border2);color:var(--text3);font-size:9px;padding:3px 8px;border-radius:5px;cursor:pointer;font-weight:700;white-space:nowrap}
.comp-remove:hover{background:rgba(229,62,62,.12);border-color:var(--red);color:var(--red)}
.comp-num{font-family:var(--mono);font-size:10px;color:var(--text3);background:var(--surface3);padding:1px 6px;border-radius:4px}
.comp-badge-done{font-size:9px;font-weight:700;padding:1px 6px;border-radius:4px;background:rgba(13,187,138,.12);color:var(--green)}
/* Toolbar above the components list */
.comp-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;padding:8px 10px;background:var(--surface3);border:1px solid var(--border);border-radius:8px;font-size:11px;color:var(--text2)}
.comp-toolbar b{color:var(--text)}
.chip-btn{background:none;border:1px solid var(--border2);color:var(--teal);font-size:10px;font-weight:700;padding:4px 10px;border-radius:6px;cursor:pointer}
.chip-btn:hover{background:rgba(6,182,212,.1);border-color:var(--teal)}
.removed-strip{display:flex;align-items:center;gap:8px;padding:7px 10px;background:rgba(229,62,62,.06);border:1px solid rgba(229,62,62,.18);border-radius:7px;font-size:11px;color:var(--text2);margin-bottom:10px}
.removed-strip .chip-btn{color:var(--amber);border-color:rgba(245,166,35,.3)}
.removed-strip .chip-btn:hover{background:rgba(245,166,35,.12);border-color:var(--amber)}
.comp-type{font-size:9px;font-weight:700;text-transform:uppercase;padding:2px 6px;border-radius:3px;display:inline-block;margin-bottom:6px}
.comp-type.item{background:rgba(6,182,212,.12);color:var(--teal)}
.comp-type.resource{background:rgba(245,166,35,.12);color:var(--amber)}
.comp-item{font-family:var(--mono);font-size:12px;color:var(--teal);font-weight:600}
.comp-desc{font-size:12px;color:var(--text);margin:3px 0 8px;line-height:1.3}
.comp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
@media(max-width:420px){.comp-grid{grid-template-columns:1fr 1fr}}
.comp-field{display:flex;flex-direction:column;gap:2px}
.comp-fl{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3);letter-spacing:.3px}
.comp-fv{font-size:12px;font-family:var(--mono);color:var(--text2)}
.comp-inp{width:100%;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:12px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;height:28px}.comp-inp:focus{border-color:var(--teal)}
/* Batch section */
.batch-sec{margin-top:8px;padding:8px;background:var(--surface3);border-radius:6px;border:1px solid var(--border)}
.batch-title{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--amber);margin-bottom:6px;display:flex;align-items:center;gap:4px}
.batch-row{display:flex;gap:6px;align-items:center;padding:4px 0;border-bottom:1px solid rgba(28,46,72,.3);font-size:11px;flex-wrap:wrap}
.batch-row:last-child{border-bottom:none}
.batch-num{font-family:var(--mono);color:var(--teal);font-size:11px;min-width:100px;flex:1}
.batch-avail{color:var(--text3);font-size:10px;min-width:60px}
.batch-inp{width:80px;padding:3px 5px;border:1px solid var(--border2);border-radius:4px;font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--text);outline:none;text-align:right}.batch-inp:focus{border-color:var(--teal)}
.batch-auto{font-size:9px;color:var(--green);font-weight:600}
.batch-loading{font-size:10px;color:var(--text3);padding:6px 0}
/* Buttons */
.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-issue{background:var(--teal);color:#fff;flex:1}.b-issue:hover:not(:disabled){background:#0891b2;box-shadow:0 4px 16px rgba(6,182,212,.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(6,182,212,.1);color:var(--teal);border:1px solid rgba(6,182,212,.3);flex:1}.b-new:hover{background:rgba(6,182,212,.2)}
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-teal{background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);color:#67e8f9}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#6ee7b7}
.al-red{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.2);color:#fca5a5}
.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(6,182,212,.12);color:var(--teal);border:1px solid rgba(6,182,212,.25);margin:8px 0 12px}
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
.order-info{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;font-size:11px}
@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)}
</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="/bom" class="nav-link">BOM</a>
<a href="/production" class="nav-link">Production</a>
<a href="/issue-production" class="nav-link active">Issue</a>
<a href="/receipt-production" class="nav-link">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="iss-detail-modal"><div class="modal" id="iss-modal-inner"></div></div>
<script>
'use strict';
let _tok=null,_user=null,_wh=[],_varieties=[];
const BE='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
if(!_tok)window.location.href='/';
// Fail fast (with a clear message) if the user hasn't set their own SAP
// login — the JWT carries a non-empty sapUser claim when they have.
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_ISSUE_PO=hasStepAssigned('production_order:issuance');
let _co=window.__DEFAULT_COMPANY__;
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
let _branches=[];
let _selectedOrder=null;
let _lines=[];
let _removedLines=[]; // components taken out of THIS issue (not submitted); can be restored
let _hasHiddenPending=false; // a per-user item-group restriction hid a component that still genuinely needs issuing
let _issueGroups=null; // this user's allowed Item Groups for issuing (null = not yet loaded / no restriction)
let _loadingDetail=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
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 not yet fully issued — the default "what's ready to
// issue" list shown when the page isn't deep-linked to one specific order.
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&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}`);
_rawCount=(r.data||[]).length;
_readyOrders=(r.data||[]).filter(o=>o._issueStatus!=='full');
_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 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 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 selectOrder(absEntry){
_modalOpen=true;_loadingDetail=true;_selectedOrder=null;_lines=[];_removedLines=[];render();
try{
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
const d=r.data||r;
_selectedOrder=d;
_lines=(d.ProductionOrderLines||[]).map(l=>({
lineNum:l.LineNumber,
itemCode:l.ItemNo||'',
itemName:l.ItemName||'',
itemType:l.ItemType||'pit_Item',
plannedQty:l.PlannedQuantity||0,
issuedQty:l.IssuedQuantity||0,
remainingQty:Math.max(0,(l.PlannedQuantity||0)-(l.IssuedQuantity||0)),
issueQty:Math.max(0,(l.PlannedQuantity||0)-(l.IssuedQuantity||0)),
selected:true, // include in this issue by default; user may uncheck or Remove
_savedQty:null, // remembers issueQty while unchecked, so re-checking restores it
warehouse:l.Warehouse||d.Warehouse||'',
costingCode:l.DistributionRule||'',
batches:[],
selectedBatches:[],
batchLoading:false,
}));
// Backflush-type lines are consumed by SAP itself, never manually issued
// here — hide them outright (not a permission thing, so it doesn't count
// toward _hasHiddenPending like the item-group filter below does).
_lines=_lines.filter(l=>{
const src=(d.ProductionOrderLines||[]).find(x=>x.LineNumber===l.lineNum);
return src?.ProductionOrderIssueType!=='im_Backflush';
});
// Per-user item-group restriction: hide components whose item group is NOT
// in this user's allowed list. Empty/no list = show everything (unchanged).
// Track whether anything genuinely still-outstanding got hidden this way
// — the "all fully issued" congratulatory message must NOT claim the
// order is done just because nothing left VISIBLE to this user needs
// issuing; that's a permission gap, not the order's real state.
_hasHiddenPending=false;
if(Array.isArray(_issueGroups)&&_issueGroups.length&&_lines.length){
try{
const codes=[...new Set(_lines.map(l=>l.itemCode).filter(Boolean))];
const gr=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
const grpByCode=gr.data||{};const allow=new Set(_issueGroups.map(String));
const hidden=_lines.filter(l=>!allow.has(String(grpByCode[l.itemCode])));
_hasHiddenPending=hidden.some(l=>l.remainingQty>0.001);
_lines=_lines.filter(l=>allow.has(String(grpByCode[l.itemCode])));
}catch(_e){}
}
// A component replaced by an approved Substitution deviation, whose own
// remaining is already down to SAP's zero-floor (see [[production-
// deviation-workflow]]) — must never be issuable (it isn't actually
// available), and must not keep this order from ever reading as "fully
// issued" just because a negligible 0.0001 sits unissued forever.
try{
const devR=await api('GET',`/sap/deviations?sapAbsEntry=${absEntry}&company=${encodeURIComponent(_co)}`);
const supersededCodes=new Set((devR.data||[])
.filter(dv=>dv.type==='SUBSTITUTION'&&dv.qaStatus==='APPROVED'&&dv.itemCode)
.map(dv=>dv.itemCode.toUpperCase()));
if(supersededCodes.size){
_lines.forEach(l=>{
if(supersededCodes.has((l.itemCode||'').toUpperCase())&&l.remainingQty<=0.001){
l.superseded=true; l.selected=false; l.issueQty=0; l.remainingQty=0;
}
});
}
}catch(_e){}
// Load batches for all lines in parallel
await Promise.all(_lines.map(l=>loadBatches(l)));
}catch(e){toast(e.message,'err');}
_loadingDetail=false;render();
}
async function loadBatches(line){
line.batchLoading=true;
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(line.warehouse)}&company=${encodeURIComponent(_co)}`);
line.batches=r.data||[];
if(line.batches.length) autoSelectBatches(line);
}catch{}
line.batchLoading=false;
}
function autoSelectBatches(line){
let rem=line.issueQty;
line.selectedBatches=[];
for(const b of line.batches){
if(rem<=0) break;
const take=Math.min(b.Quantity,rem);
line.selectedBatches.push({BatchNumber:b.BatchNumber,Quantity:take});
rem-=take;
}
}
async function doIssue(){
if(!_selectedOrder||!_lines.length)return;
const issuable=_lines.filter(l=>l.selected&&l.issueQty>0);
if(!issuable.length){toast('No items selected to issue — check at least one component and enter a quantity','err');return;}
// Validate batch totals
for(const l of issuable){
if(l.batches.length){
const batchTotal=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
if(Math.abs(batchTotal-l.issueQty)>0.001){
toast(`${l.itemCode}: batch qty (${batchTotal}) must equal issue qty (${l.issueQty})`,'err');return;
}
}
}
if(!requireSapLogin())return;
_submitting=true;render();
const bplId=parseInt(q('#sel-branch')?.value)||2;
const postDate=q('#inp-postdate')?.value||'';
const payload={
company:_co,
branchId:bplId,
};
if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;}
payload.DocumentLines=issuable.map(l=>{
const dl={BaseEntry:_selectedOrder.AbsoluteEntry,BaseLine:l.lineNum,BaseType:202,ItemCode:l.itemCode,Quantity:l.issueQty,WarehouseCode:l.warehouse};
if(l.costingCode) dl.CostingCode=l.costingCode;
if(l.selectedBatches.length){
dl.BatchNumbers=l.selectedBatches.filter(b=>b.Quantity>0).map(b=>({BatchNumber:b.BatchNumber,Quantity:b.Quantity}));
}
return dl;
});
try{
const r=await api('POST','/sap/issue-production',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Issue for Production posted!');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
// ═══════════════════════════════════════
// RENDER
// ═══════════════════════════════════════
function render(){
try{ _doRender(); }catch(e){ console.error('Render error:',e); document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Render error: ${e.message}</div>`; }
}
function closeIssModal(){_modalOpen=false;_selectedOrder=null;_lines=[];_removedLines=[];_success=null;render();loadReadyOrders();}
function _doRender(){
const app=document.getElementById('app');app.innerHTML='';
if(!CAN_ISSUE_PO){
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Issuance" approval step, so you can\'t issue items for production. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
document.getElementById('iss-detail-modal')?.classList.remove('show');
return;
}
// Company
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> Issue for 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 class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${new Date().toISOString().slice(0,10)}"/></div>
</div>`;
app.appendChild(c1);
// Orders ready for issuance — square-tile grid, always visible (matches
// production.html's own list/picker), opens the detail popup on click.
const c2b=mk('card');
const gridBody=_readyLoading
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(6,182,212,.25);border-top-color:var(--teal)"></span> Loading released orders...</div>'
:!_readyOrders.length?'<div class="empty-msg">No released orders are awaiting issuance right now.</div>'
:`<div class="po-grid">${_readyOrders.map(o=>`<div class="po-card${_selectedOrder&&_selectedOrder.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>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>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(--teal);color:#fff;white-space:nowrap">Search</button>
</div>`;
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready for Issuance<span class="right">${_readyOrders.length}</span></div><div class="card-bd">${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()]);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=()=>selectOrder(parseInt(c.dataset.ready));});
},0);
}
// ═══════════════════════════════════════
// DETAIL / ISSUE POPUP
// ═══════════════════════════════════════
function renderModal(){
const bg=document.getElementById('iss-detail-modal');
const inner=document.getElementById('iss-modal-inner');
if(!_modalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
if(_success){
inner.innerHTML=`<div class="modal-hd"><span>Issue for Production — Posted</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon">✓</div>
<div class="suc-title">Issue Posted!</div>
<p style="font-size:13px;color:var(--text3)">Components issued against Production Order #${esc(String(_selectedOrder?.DocumentNumber||''))}</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">
Inventory General Exit created in SAP B1. The issued quantities are now deducted from stock.
</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
<button class="btn b-new" id="btn-new">+ New Issue</button>
</div>
</div></div>`;
setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal;
q('#btn-new').onclick=closeIssModal;
},0);
return;
}
if(_loadingDetail||!_selectedOrder){
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="iss-modal-x">✕</button></div>
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(6,182,212,.25);border-top-color:var(--teal)"></span> Loading order details...</div></div>`;
setTimeout(()=>{q('#iss-modal-x').onclick=closeIssModal;},0);
return;
}
const d=_selectedOrder;
const linesHtml=_lines.map((l,i)=>{
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');
const typeLabel=l.itemType==='pit_Resource'?'Resource':'Item';
const typeCls=l.itemType==='pit_Resource'?'resource':'item';
const isDone=l.remainingQty<=0;
const inactive=isDone||!l.selected; // fully issued, or user unchecked it
let batchHtml='';
if(!inactive&&l.batchLoading){
batchHtml='<div class="batch-loading"><span class="sp" style="width:10px;height:10px;border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading batches...</div>';
}else if(!inactive&&l.batches.length){
batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div>
${l.batches.map((b,bi)=>{
const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0;
return`<div class="batch-row">
<span class="batch-num">${esc(b.BatchNumber)}</span>
<span class="batch-avail">Avail: ${b.Quantity}</span>
<input class="batch-inp" data-li="${i}" data-bi="${bi}" data-bn="${esc(b.BatchNumber)}" type="number" min="0" max="${b.Quantity}" step="0.001" value="${qty}"/>
</div>`;
}).join('')}
</div>`;
}
return`<div class="comp-card${!l.selected&&!isDone?' unselected':''}${isDone?' done':''}">
<div class="comp-top-row">
${isDone?'':`<input type="checkbox" class="comp-check" data-i="${i}" ${l.selected?'checked':''} title="Include this item in this issue"/>`}
<span class="comp-num">#${i+1}</span>
<span class="comp-type ${typeCls}">${typeLabel}</span>
${l.superseded?'<span class="comp-badge-done" style="background:rgba(122,154,184,.15);color:var(--text2)" title="Replaced by a Substitution deviation — no longer available to issue">↦ Superseded</span>':(isDone?'<span class="comp-badge-done">✓ Fully Issued</span>':'')}
<div class="comp-actions">${isDone?'':`<button type="button" class="comp-remove" data-remove-i="${i}" title="Take this item out of this issue">✕ Remove</button>`}</div>
</div>
<div class="comp-item">${esc(l.itemCode)}</div>
<div class="comp-desc">${esc(l.itemName)}</div>
<div class="comp-grid">
<div class="comp-field"><span class="comp-fl">Planned</span><span class="comp-fv">${l.plannedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Issued</span><span class="comp-fv">${l.issuedQty}</span></div>
<div class="comp-field"><span class="comp-fl">Remaining</span><span class="comp-fv" style="color:${l.remainingQty>0?'var(--amber)':'var(--green)'}">${l.remainingQty}</span></div>
<div class="comp-field"><span class="comp-fl">Issue Qty *</span><input class="comp-inp" data-i="${i}" data-f="issueQty" type="number" min="0" step="0.001" value="${l.issueQty}" ${inactive?'disabled':''}/></div>
<div class="comp-field" style="grid-column:span 2"><span class="comp-fl">Warehouse</span><select class="comp-inp" data-i="${i}" data-f="warehouse" style="padding:2px 4px" ${inactive?'disabled':''}>${whOpts}</select></div>
</div>
${batchHtml}
</div>`;
}).join('');
const pendingLines=_lines.filter(l=>l.remainingQty>0);
const selectedCount=pendingLines.filter(l=>l.selected).length;
const totalQty=_lines.filter(l=>l.selected).reduce((s,l)=>s+(l.issueQty||0),0);
const toolbar=pendingLines.length?`<div class="comp-toolbar">
<span><b>${selectedCount}</b> of <b>${pendingLines.length}</b> item${pendingLines.length===1?'':'s'} selected${totalQty>0?` · Qty to issue <b>${Math.round(totalQty*1000)/1000}</b>`:''}</span>
<button type="button" class="chip-btn" id="btn-select-all">Select all</button>
<button type="button" class="chip-btn" id="btn-select-none">Clear all</button>
</div>`:'';
const removedStrip=_removedLines.length?`<div class="removed-strip">
<span><b>${_removedLines.length}</b> item${_removedLines.length===1?'':'s'} removed from this issue</span>
<button type="button" class="chip-btn" id="btn-restore-removed" style="margin-left:auto">↩ Restore</button>
</div>`:'';
const allIssued=_lines.length>0&&_lines.every(l=>l.remainingQty<=0);
const hasQty=_lines.some(l=>l.selected&&l.issueQty>0);
// A per-user item-group restriction can hide a component that genuinely
// still needs issuing — in that case the order is NOT actually done, this
// user just has nothing left THEY'RE permitted to act on. Never claim
// "fully issued" when that's true — it's misleading and could make
// someone think the order needs no further attention from anyone.
const footer=allIssued
?(_hasHiddenPending
?`<div class="alert al-amber" style="margin:0">Nothing left for you to issue in your assigned item group(s) — but this order still has other components pending in a different item group. Someone with access to those groups still needs to issue them.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`
:`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order.</div>
<div class="btn-bar" style="margin-top:8px"><button class="btn b-cancel" id="btn-clear" style="flex:1">Select Another Order</button></div>`)
:`<div class="btn-bar" style="margin-top:8px">
<button class="btn b-issue" id="btn-issue" ${!hasQty||_submitting?'disabled':''}><span id="sp-issue" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Issue to Production</button>
<button class="btn b-cancel" id="btn-clear">Clear</button>
</div>`;
inner.innerHTML=`
<div class="modal-hd">
<span style="font-family:var(--mono);color:var(--teal)">${esc(d.ItemNo)}</span>
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
<button class="modal-close" id="iss-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">${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 Qty</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">Warehouse</span><span class="oi-v">${esc(d.Warehouse||'-')}</span></div>
<div class="oi-item"><span class="oi-l">Due Date</span><span class="oi-v">${d.DueDate?new Date(d.DueDate).toLocaleDateString('en-IN'):'-'}</span></div>
</div>
<div class="sec-title" style="font-size:10px;font-weight:800;text-transform:uppercase;color:var(--text3);margin:10px 0 8px">Issue Components (${_lines.length})</div>
${removedStrip}
${toolbar}
${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'}
${footer}
</div>`;
setTimeout(()=>{
q('#iss-modal-x').onclick=closeIssModal;
wireLineInputs();
wireBatchInputs();
wireSelectionControls();
q('#btn-issue')&&(q('#btn-issue').onclick=doIssue);
q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal);
},0);
}
// Checkbox (include/exclude), Remove button, and the select-all/clear-all/
// restore toolbar — lets the user issue only SOME of an order's components
// today and come back for the rest later, instead of an all-or-nothing post.
function wireSelectionControls(){
document.querySelectorAll('.comp-check[data-i]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.i);
const l=_lines[i];if(!l)return;
l.selected=this.checked;
if(l.selected){
l.issueQty=l._savedQty!=null?l._savedQty:l.remainingQty;
l._savedQty=null;
if(l.batches.length) autoSelectBatches(l);
}else{
l._savedQty=l.issueQty;
l.issueQty=0;
}
render();
};
});
document.querySelectorAll('[data-remove-i]').forEach(el=>{
el.onclick=function(){
const i=parseInt(this.dataset.removeI);
const removed=_lines.splice(i,1)[0];
if(removed)_removedLines.push(removed);
render();
};
});
q('#btn-select-all')&&(q('#btn-select-all').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&!l.selected){ l.selected=true; l.issueQty=l._savedQty!=null?l._savedQty:l.remainingQty; l._savedQty=null; if(l.batches.length)autoSelectBatches(l); } });
render();
});
q('#btn-select-none')&&(q('#btn-select-none').onclick=()=>{
_lines.forEach(l=>{ if(l.remainingQty>0&&l.selected){ l._savedQty=l.issueQty; l.selected=false; l.issueQty=0; } });
render();
});
q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{
_lines.push(..._removedLines);
_removedLines=[];
render();
});
}
function wireLineInputs(){
document.querySelectorAll('.comp-inp[data-i]').forEach(el=>{
el.onchange=function(){
const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='warehouse'){
_lines[i].warehouse=this.value;
// Reload batches for new warehouse
_lines[i].batches=[];_lines[i].selectedBatches=[];
loadBatches(_lines[i]).then(()=>render());
}else if(f==='issueQty'){
_lines[i].issueQty=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render();
}else if(f==='costingCode'){
_lines[i].costingCode=this.value;return;
}
};
});
}
function wireBatchInputs(){
document.querySelectorAll('.batch-inp').forEach(el=>{
el.onchange=function(){
const li=parseInt(this.dataset.li);
const bn=this.dataset.bn;
const qty=parseFloat(this.value)||0;
const line=_lines[li];
if(!line)return;
const existing=line.selectedBatches.find(b=>b.BatchNumber===bn);
if(existing) existing.Quantity=qty;
else if(qty>0) line.selectedBatches.push({BatchNumber:bn,Quantity:qty});
// Remove zeros
line.selectedBatches=line.selectedBatches.filter(b=>b.Quantity>0);
};
});
}
async function init(){
try{
// Check URL hash for auto-load (e.g. #order=8312&company=TEST_OIL_15122025)
const hash=window.location.hash;
const orderMatch=hash.match(/order=(\d+)/);
const coMatch=hash.match(/company=([^&]+)/);
if(coMatch){_co=decodeURIComponent(coMatch[1]);}
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
// Load this user's allowed issue Item Groups (fresh from DB, so admin
// changes apply without a re-login). Empty/absent = no restriction.
try{const pr=await api('GET','/auth/profile');_issueGroups=Array.isArray(pr?.user?.issueItemGroups)?pr.user.issueItemGroups:[];}catch(_e){_issueGroups=[];}
await Promise.all([loadWH(),loadBranches(),loadVarieties()]);
render();
if(orderMatch){
const entry=parseInt(orderMatch[1]);
if(entry) await selectOrder(entry);
}else{
// No specific order in the URL — show what's ready for issuance by
// default instead of an empty page.
loadReadyOrders();
}
}catch(e){
console.error('Init error:',e);
render();
}
}
init();
// If this page is already open (e.g. the sidebar "Pending Actions" bell
// linked here for a different order while the user was still on this same
// page), only the URL's hash changes — browsers don't reload the page for
// a hash-only change, so init() would never re-run and the old/empty state
// would stick around until a manual refresh. Re-check the hash and open
// the newly-linked order whenever that happens.
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)selectOrder(entry);
}
});
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>