860 lines
55 KiB
HTML
860 lines
55 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>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');
|
||
// Consumable Orders use their OWN independent "Issue" approval step, not the
|
||
// general production_order:issuance step (and vice versa) — no creator
|
||
// bypass either, so this is a plain static permission check.
|
||
const CAN_ISSUE_CONSUMABLE=hasStepPerm('production_order:consumable_issue','approve');
|
||
const CAN_CREATE_CONSUMABLE=hasStepPerm('production_order:consumable_create','add');
|
||
|
||
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 _remarks=''; // free-text note for this Issue document — saved to SAP as its Comments
|
||
let _readyOrders=[];
|
||
let _readyLoading=false;
|
||
let _modalOpen=false;
|
||
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
|
||
let _dateFrom='',_dateTo=''; // Due Date range, sent to the server (see loadReadyOrders())
|
||
// A user who can act on Consumable Orders but does NOT hold the general
|
||
// Issuance approval step must never see (or be able to submit for) a normal
|
||
// PWO here — force the list to Consumable-only for them, with no toggle
|
||
// (there's nothing else they're allowed to browse to anyway).
|
||
const CONSUMABLE_ONLY_USER=CAN_ISSUE_CONSUMABLE&&!CAN_ISSUE_PO;
|
||
let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
|
||
|
||
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 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{
|
||
// readyToIssue=1 tells the server to filter OUT already-fully-issued
|
||
// orders itself, BEFORE paginating — it used to be filtered here
|
||
// client-side, AFTER the server had already handed back one fixed page
|
||
// of raw "Released" orders, which meant a page whose raw orders all
|
||
// happened to be already fully issued rendered completely empty while
|
||
// the pager still counted every Released order regardless — real data
|
||
// ended up scattered across seemingly random pages with long gaps.
|
||
const dateQ=(_dateFrom?`&dateFrom=${_dateFrom}`:'')+(_dateTo?`&dateTo=${_dateTo}`:'');
|
||
const r=await api('GET',`/sap/production-orders?status=Released&readyToIssue=1&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${dateQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
|
||
_rawCount=(r.data||[]).length;
|
||
_readyOrders=r.data||[];
|
||
_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=[];_remarks='';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();
|
||
loadStockForLines();
|
||
}
|
||
|
||
// "Available" on-hand stock (OITW) shown next to each component — same
|
||
// pattern/endpoint as New Production Order's own Available Qty column
|
||
// (public/production.html's loadStockForLines()/stockFor()), grouped/
|
||
// fetched per distinct warehouse among the current lines (usually just one
|
||
// call). Non-blocking: patches _stockMap then patches just the modal, never
|
||
// gates anything — this is informational only, not a hard stock check.
|
||
let _stockMap={}; // `${itemCode}|${warehouse}` -> on-hand qty
|
||
async function loadStockForLines(){
|
||
const groups={};
|
||
_lines.forEach(l=>{
|
||
if(!l.itemCode||l.itemType==='pit_Resource')return;
|
||
const wh=l.warehouse||'';
|
||
(groups[wh]=groups[wh]||new Set()).add(l.itemCode.trim().toUpperCase());
|
||
});
|
||
const entries=Object.entries(groups);
|
||
if(!entries.length)return;
|
||
await Promise.all(entries.map(async([wh,codesSet])=>{
|
||
const codes=[...codesSet];
|
||
try{
|
||
const r=await api('GET',`/sap/lookup/item-stock?codes=${encodeURIComponent(codes.join(','))}${wh?'&warehouse='+encodeURIComponent(wh):''}&company=${encodeURIComponent(_co)}`);
|
||
if(r.success&&r.data)codes.forEach(c=>{_stockMap[`${c}|${wh}`]=r.data[c]||0;});
|
||
}catch(_e){/* non-fatal — Available just stays blank for these */}
|
||
}));
|
||
if(_modalOpen&&_selectedOrder)renderModal();
|
||
}
|
||
function stockFor(l){
|
||
const wh=l.warehouse||'';
|
||
const key=`${(l.itemCode||'').trim().toUpperCase()}|${wh}`;
|
||
return key in _stockMap ? _stockMap[key] : null;
|
||
}
|
||
|
||
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 remarks=(_remarks||'').trim();
|
||
const payload={
|
||
company:_co,
|
||
branchId:bplId,
|
||
};
|
||
if(postDate){payload.DocDate=postDate;payload.DocDueDate=postDate;}
|
||
if(remarks){payload.Comments=remarks;}
|
||
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();
|
||
}
|
||
|
||
// Order is already 100% issued in SAP (confirmed live, not just assumed from
|
||
// what's displayed) but the portal's own approval chain never recorded it —
|
||
// see the long comment on POST /sap/confirm-issued-outside-portal for why
|
||
// this can't just silently auto-advance and why it's not a repost.
|
||
async function doConfirmIssued(){
|
||
if(!_selectedOrder)return;
|
||
if(!requireSapLogin())return;
|
||
_submitting=true;render();
|
||
try{
|
||
await api('POST','/sap/confirm-issued-outside-portal',{absEntry:_selectedOrder.AbsoluteEntry,company:_co});
|
||
_success={confirmed:true};
|
||
toast('Issuance confirmed — Receipt is now unblocked');
|
||
}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;_remarks='';render();loadReadyOrders();}
|
||
|
||
function _doRender(){
|
||
const app=document.getElementById('app');app.innerHTML='';
|
||
if(!CAN_ISSUE_PO&&!CAN_ISSUE_CONSUMABLE){
|
||
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>':''}${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>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b></span><span>WH <b>${esc(o.Warehouse||'-')}</b>${o.DueDate?` · Due <b>${new Date(o.DueDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</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;flex-wrap:wrap">
|
||
<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;min-width:220px"/>
|
||
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;align-self:center">Due</span>
|
||
<input class="fi" id="rp-datefrom" type="date" value="${esc(_dateFrom)}" style="font-size:12px"/>
|
||
<span style="font-size:11px;color:var(--text3);align-self:center">to</span>
|
||
<input class="fi" id="rp-dateto" type="date" value="${esc(_dateTo)}" style="font-size:12px"/>
|
||
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--teal);color:#fff;white-space:nowrap">Search</button>
|
||
</div>`;
|
||
const typeRow=CONSUMABLE_ONLY_USER
|
||
?`<div style="font-size:11px;color:var(--purple);margin-bottom:10px">Showing Consumable Orders only — you're not assigned the general Issuance approval step.</div>`
|
||
:(CAN_CREATE_CONSUMABLE||CAN_ISSUE_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 for Issuance<span class="right">${_total!=null?_total:_readyOrders.length}</span></div><div class="card-bd">${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()]);loadReadyOrders(true);});
|
||
q('#rp-search')&&(q('#rp-search').oninput=function(){_search=this.value;});
|
||
q('#rp-datefrom')&&(q('#rp-datefrom').onchange=function(){_dateFrom=this.value;loadReadyOrders(true);});
|
||
q('#rp-dateto')&&(q('#rp-dateto').onchange=function(){_dateTo=this.value;loadReadyOrders(true);});
|
||
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=()=>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=_success.confirmed
|
||
?`<div class="modal-hd"><span>Issuance Confirmed</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">Recorded in the Portal</div>
|
||
<p style="font-size:13px;color:var(--text3)">Production Order #${esc(String(_selectedOrder?.DocumentNumber||''))} — Issuance approval recorded. Nothing new was posted to SAP (it was already fully issued there); Receipt is now unblocked.</p>
|
||
<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>`
|
||
:`<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){
|
||
const batchTotal0=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
|
||
const batchOk0=Math.abs(batchTotal0-l.issueQty)<=0.001;
|
||
batchHtml=`<div class="batch-sec"><div class="batch-title">Batches <span class="batch-auto">(auto-selected)</span></div>
|
||
<div class="batch-total-row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:6px;border:1px solid ${batchOk0?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)'};background:${batchOk0?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)'}">
|
||
<span style="font-size:11px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}">Total Qty</span>
|
||
<span id="batch-total-${i}" data-required="${l.issueQty}" style="font-size:12px;font-weight:700;color:${batchOk0?'var(--green)':'#dc2626'}" title="${batchOk0?'':`Must equal Issue Qty (${l.issueQty}) — this item's available stock in this warehouse may be short`}">${batchTotal0}${batchOk0?'':` / ${l.issueQty} required`}</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>
|
||
${(()=>{const avail=stockFor(l);const short=avail!=null&&avail<l.remainingQty-0.0001;return `<div class="comp-field"><span class="comp-fl">Available</span><span class="comp-fv" style="color:${avail==null?'var(--text3)':short?'var(--red)':'var(--green)'}" title="${avail==null?'Loading…':short?'Short of Remaining qty':'On hand ≥ Remaining qty'}">${avail==null?'…':avail}</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.
|
||
// All lines fully issued in SAP, but the portal's own approval chain never
|
||
// recorded it (stage still 1) — this order was issued directly in SAP B1,
|
||
// bypassing the portal entirely. Nothing left to post (re-posting would
|
||
// double-issue real stock), so offer a "Confirm" action that just records
|
||
// the approval + advances the local stage — see /sap/confirm-issued-outside-portal.
|
||
const needsConfirm=allIssued&&!_hasHiddenPending&&d._localStage===1;
|
||
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>`
|
||
:needsConfirm
|
||
?`<div class="alert al-green" style="margin:0">All components have been fully issued for this production order in SAP — but this wasn't recorded as an Issuance approval in the portal, so Receipt is still blocked. Someone assigned to this order's Issuance step needs to confirm it before Receipt can proceed.</div>
|
||
<div class="btn-bar" style="margin-top:8px">
|
||
<button class="btn b-issue" id="btn-confirm-issued" ${_submitting?'disabled':''}><span id="sp-confirm" style="display:${_submitting?'inline-block':'none'}" class="sp"></span> Confirm Issued in SAP</button>
|
||
<button class="btn b-cancel" id="btn-clear">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> ${d._localIsConsumable?'Issue Consumable':'Issue to Production'}</button>
|
||
<button class="btn b-cancel" id="btn-clear">Clear</button>
|
||
</div>`;
|
||
|
||
// Preserve the modal's own scroll position across a rebuild — e.g.
|
||
// changing a component's Warehouse re-renders this whole block (batches
|
||
// depend on it), and without this the modal would jump back to the top
|
||
// every time, losing your place among a long component list.
|
||
const savedBdScroll=inner.querySelector('.modal-bd')?.scrollTop||0;
|
||
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">${d._localIsConsumable?'Issue Consumable Components':'Issue Components'} (${_lines.length})</div>
|
||
${removedStrip}
|
||
${toolbar}
|
||
${_lines.length?linesHtml:'<div class="empty-msg">No component lines</div>'}
|
||
${!allIssued?`<div class="fg" style="margin-top:10px"><label class="fl">Remarks</label><input class="fi" id="inp-remarks" type="text" maxlength="254" placeholder="Optional note for this Issue document" value="${esc(_remarks)}"/></div>`:''}
|
||
${footer}
|
||
</div>`;
|
||
|
||
const newBd=inner.querySelector('.modal-bd');
|
||
if(newBd)newBd.scrollTop=savedBdScroll;
|
||
|
||
setTimeout(()=>{
|
||
q('#iss-modal-x').onclick=closeIssModal;
|
||
wireLineInputs();
|
||
wireBatchInputs();
|
||
wireSelectionControls();
|
||
q('#btn-issue')&&(q('#btn-issue').onclick=doIssue);
|
||
q('#btn-confirm-issued')&&(q('#btn-confirm-issued').onclick=doConfirmIssued);
|
||
q('#btn-clear')&&(q('#btn-clear').onclick=closeIssModal);
|
||
q('#inp-remarks')&&(q('#inp-remarks').oninput=function(){_remarks=this.value;});
|
||
},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;
|
||
}
|
||
renderModal();
|
||
};
|
||
});
|
||
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);
|
||
renderModal();
|
||
};
|
||
});
|
||
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); } });
|
||
renderModal();
|
||
});
|
||
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; } });
|
||
renderModal();
|
||
});
|
||
q('#btn-restore-removed')&&(q('#btn-restore-removed').onclick=()=>{
|
||
_lines.push(..._removedLines);
|
||
_removedLines=[];
|
||
renderModal();
|
||
});
|
||
}
|
||
|
||
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=[];
|
||
// renderModal() only — NOT the full page render(), which rebuilds
|
||
// the "Orders Ready" grid too and resets window scroll on top of
|
||
// the modal's own scroll, doubling the jump-to-top effect.
|
||
loadBatches(_lines[i]).then(()=>renderModal());
|
||
loadStockForLines();
|
||
}else if(f==='issueQty'){
|
||
_lines[i].issueQty=parseFloat(this.value)||0;
|
||
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
|
||
renderModal();
|
||
}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);
|
||
// Patch the Total Qty badge directly instead of a full render() — same
|
||
// convention as every other live input in this app (avoids losing
|
||
// focus/scroll position mid-edit). See the sibling batch-total-row
|
||
// markup this mirrors.
|
||
const badge=document.getElementById(`batch-total-${li}`);
|
||
if(badge){
|
||
const total=line.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
|
||
const required=parseFloat(badge.dataset.required)||0;
|
||
const ok=Math.abs(total-required)<=0.001;
|
||
const color=ok?'var(--green)':'#dc2626';
|
||
badge.textContent=ok?String(total):`${total} / ${required} required`;
|
||
badge.style.color=color;
|
||
badge.title=ok?'':`Must equal Issue Qty (${required}) — this item's available stock in this warehouse may be short`;
|
||
const row=badge.closest('.batch-total-row');
|
||
if(row){
|
||
row.style.borderColor=ok?'rgba(13,187,138,.35)':'rgba(220,38,38,.4)';
|
||
row.style.background=ok?'rgba(13,187,138,.08)':'rgba(220,38,38,.08)';
|
||
const label=row.querySelector('span');
|
||
if(label) label.style.color=color;
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
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=31"></script>
|
||
</body>
|
||
</html>
|