729 lines
46 KiB
HTML
729 lines
46 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 _readyOrders=[];
|
||
let _readyLoading=false;
|
||
let _modalOpen=false;
|
||
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
|
||
// 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{
|
||
const r=await api('GET',`/sap/production-orders?status=Released&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_viewOrderType?'&orderType='+_viewOrderType:''}`);
|
||
_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&&!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></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>`;
|
||
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">${_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-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=`<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> ${d._localIsConsumable?'Issue Consumable':'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">${d._localIsConsumable?'Issue Consumable Components':'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>
|