443 lines
31 KiB
HTML
443 lines
31 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>Close Production Order — 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;--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(--amber);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(--amber);background:rgba(245,166,35,.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:600}
|
||
.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(--amber);box-shadow:0 0 0 2px rgba(245,166,35,.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-info{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;font-size:11px}
|
||
@media(max-width:680px){.order-info{grid-template-columns:1fr 1fr}}
|
||
@media(max-width:420px){.order-info{grid-template-columns:1fr}}
|
||
.oi-item{display:flex;flex-direction:column;gap:1px}
|
||
.oi-l{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
|
||
.oi-v{font-family:var(--mono);font-size:11px;color:var(--text)}
|
||
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
|
||
.btn{padding:10px 20px;border-radius:8px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.b-close{background:var(--amber);color:#fff;flex:1}.b-close:hover:not(:disabled){background:#d4891b;box-shadow:0 4px 16px rgba(245,166,35,.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(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3);flex:1}.b-new:hover{background:rgba(245,166,35,.2)}
|
||
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
|
||
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);color:#fcd34d}
|
||
.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}
|
||
.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(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25);margin:8px 0 12px}
|
||
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
|
||
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:6px}
|
||
.comp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-size:11px}
|
||
@media(max-width:420px){.comp-grid{grid-template-columns:1fr 1fr}}
|
||
.comp-fl{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3)}
|
||
.comp-fv{font-family:var(--mono);font-size:11px;color:var(--text2)}
|
||
/* Square-tile grid (matches issue/receipt) */
|
||
.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(--amber);box-shadow:0 6px 18px rgba(245,166,35,.14);transform:translateY(-2px)}
|
||
.po-card.selected{border-color:var(--amber);background:rgba(245,166,35,.10);box-shadow:0 0 0 2px rgba(245,166,35,.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(--amber);font-weight:600}
|
||
.po-icon{width:26px;height:26px;border-radius:7px;background:rgba(245,166,35,.12);color:var(--amber);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)}
|
||
.po-badge{font-size:8px;font-weight:700;padding:1px 5px;border-radius:3px}
|
||
.po-badge.done{background:rgba(13,187,138,.12);color:var(--green)}
|
||
.po-badge.part{background:rgba(245,166,35,.12);color:var(--amber)}
|
||
/* Detail / close popup */
|
||
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
|
||
.modal-bg.show{display:flex}
|
||
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:980px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
|
||
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:space-between}
|
||
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
|
||
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
</head>
|
||
<body>
|
||
<div class="toast-c" id="toast-c"></div>
|
||
<nav>
|
||
<span class="nav-logo">SAP</span>
|
||
<div class="nav-links">
|
||
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
|
||
<a href="/issue-production" class="nav-link">Issue</a>
|
||
<a href="/receipt-production" class="nav-link">Receipt</a>
|
||
<a href="/close-production" class="nav-link active">Close</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="close-detail-modal"><div class="modal" id="close-modal-inner"></div></div>
|
||
|
||
<script>
|
||
'use strict';
|
||
let _tok=null,_user=null;
|
||
const BE='/api';
|
||
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
|
||
if(!_tok)window.location.href='/';
|
||
// Fail fast (with a clear message) if the user hasn't set their own SAP login.
|
||
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_CLOSE_PO=hasStepAssigned('production_order:close');
|
||
// Admin / System Admin can close ANY order regardless of the approval-step
|
||
// assignment or the usual full-issuance/full-qty/sequencing gates — the
|
||
// server enforces the same override (routes/sap.js's isPoAdminOverride()).
|
||
const IS_PO_ADMIN_OVERRIDE=!!(_user&&['admin','system_admin'].includes(_user.role));
|
||
// Consumable Order workflow is Release → Issue → Close only (no Receipt/
|
||
// Transfer to FG), so anyone holding 'approve' on the dedicated Consumable
|
||
// Order — Close step may close it regardless of the general Close step or
|
||
// the usual full-qty/sequencing gates — mirrors routes/sap.js's
|
||
// isConsumableCloser(). No creator bypass — the creator needs this step too.
|
||
const CAN_CLOSE_CONSUMABLE=hasStepPerm('production_order:consumable_close','approve');
|
||
const CAN_CREATE_CONSUMABLE=hasStepPerm('production_order:consumable_create','add');
|
||
// A user who can act on Consumable Orders but does NOT hold the general
|
||
// Close 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.
|
||
const CONSUMABLE_ONLY_USER=CAN_CLOSE_CONSUMABLE&&!CAN_CLOSE_PO&&!IS_PO_ADMIN_OVERRIDE;
|
||
|
||
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 _order=null;
|
||
let _loadingOrder=false;
|
||
let _closing=false;
|
||
let _success=false;
|
||
let _modalOpen=false;
|
||
let _readyOrders=[];
|
||
let _readyLoading=false;
|
||
let _page=0,_pageSize=20,_total=null,_rawCount=0,_search=''; // ready-orders pagination + server search
|
||
let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
|
||
let _closeRequireFullQty=true; // admin setting (Settings → closeRequireFullQty) — fetched in init()
|
||
let _localStatusByEntry={}; // sapAbsEntry → local status (to exempt REJECTED orders from the full-qty gate)
|
||
|
||
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 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 (open) orders — the candidates to close. Shown with a receipt
|
||
// badge so it's clear which are fully received and safe to close.
|
||
async function loadReadyOrders(resetPage){
|
||
if(resetPage)_page=0;
|
||
_readyLoading=true;render();
|
||
const searchQ=_search.trim()?`&search=${encodeURIComponent(_search.trim())}`:'';
|
||
// Consumable Orders never satisfy "readyToClose" (that gate requires a
|
||
// full Receipt, which Consumable Orders skip entirely — Release → Issue →
|
||
// Close only) — a Consumable-only user instead gets a plain Released +
|
||
// Consumable list here, since the qty-matched restriction would otherwise
|
||
// hide every single one of their orders.
|
||
const readyParam=CONSUMABLE_ONLY_USER?'':'&readyToClose=1';
|
||
try{
|
||
const r=await api('GET',`/sap/production-orders?status=Released${readyParam}&top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${searchQ}${_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 loadOrder(absEntry){
|
||
_modalOpen=true;_loadingOrder=true;_order=null;_success=false;render();
|
||
try{
|
||
const r=await api('GET',`/sap/production-orders/${absEntry}?company=${encodeURIComponent(_co)}`);
|
||
_order=r.data||r;
|
||
}catch(e){toast(e.message,'err');}
|
||
_loadingOrder=false;render();
|
||
}
|
||
function selectOrder(absEntry){ loadOrder(absEntry); }
|
||
|
||
async function doClose(){
|
||
if(!_order)return;
|
||
if(!requireSapLogin())return;
|
||
_closing=true;render();
|
||
try{
|
||
await api('POST',`/sap/production-order/${_order.AbsoluteEntry}/close`,{company:_co});
|
||
_success=true;
|
||
toast('Production Order closed!');
|
||
}catch(e){toast(e.message||'Failed','err');}
|
||
_closing=false;render();
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
function render(){
|
||
try{_doRender();}catch(e){console.error(e);document.getElementById('app').innerHTML=`<div style="padding:20px;color:var(--red)">Error: ${e.message}</div>`;}
|
||
}
|
||
function closeCloseModal(){_modalOpen=false;_order=null;_success=false;render();loadReadyOrders();}
|
||
|
||
function _doRender(){
|
||
const app=document.getElementById('app');app.innerHTML='';
|
||
if(!CAN_CLOSE_PO&&!IS_PO_ADMIN_OVERRIDE&&!CAN_CLOSE_CONSUMABLE){
|
||
app.innerHTML='<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You\'re not assigned the "Close" approval step, so you can\'t close production orders. Ask an admin to grant it in User Management if you believe this is incorrect.</div></div>';
|
||
document.getElementById('close-detail-modal')?.classList.remove('show');
|
||
return;
|
||
}
|
||
|
||
// Company
|
||
const c1=mk('card');
|
||
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Close Production Order</div>
|
||
<div class="card-bd"><div class="fg"><label class="fl">Company</label>
|
||
<select class="fi" id="sel-co">${coOptions(_co)}</select></div></div>`;
|
||
app.appendChild(c1);
|
||
|
||
// Orders ready to close — square-tile grid + search + pagination.
|
||
const c2b=mk('card');
|
||
const gridBody=_readyLoading
|
||
?'<div class="empty-msg"><span class="sp" style="border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading released orders...</div>'
|
||
:!_readyOrders.length?'<div class="empty-msg">No open (released) orders on this page.</div>'
|
||
:`<div class="po-grid">${_readyOrders.map(o=>{
|
||
const badge=o._receiptStatus==='done'?'<span class="po-badge done">RECEIVED</span>':o._receiptStatus==='partial'?'<span class="po-badge part">PARTIAL</span>':'';
|
||
const consBadge=o.IsConsumable?'<span class="po-badge" style="background:rgba(159,90,253,.14);color:#9f5afd">CONSUMABLE</span>':'';
|
||
return `<div class="po-card${_order&&_order.AbsoluteEntry===o.AbsoluteEntry?' selected':''}" data-ready="${o.AbsoluteEntry}">
|
||
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span>${badge}${consBadge}</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>Done <b>${o.CompletedQuantity||0}</b> · WH <b>${esc(o.Warehouse||'-')}</b></span></div>
|
||
${(o.WoNo||o.BatchNumber)?`<div class="po-meta"><span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span></div>`:''}
|
||
</div>`;}).join('')}</div>`;
|
||
const searchRow=`<div style="display:flex;gap:6px;margin-bottom:10px">
|
||
<input class="fi fi-mono" id="rp-search" value="${esc(_search)}" placeholder="Search item code, description, document no, order no, WO no or batch no…" style="font-size:12px;flex:1"/>
|
||
<button class="btn" id="rp-load" style="padding:6px 16px;font-size:11px;background:var(--amber);color:#fff;white-space:nowrap">Search</button>
|
||
</div>`;
|
||
const typeRow=CONSUMABLE_ONLY_USER
|
||
?`<div style="font-size:11px;color:#9f5afd;margin-bottom:10px">Showing Consumable Orders only — you're not assigned the general Close approval step.</div>`
|
||
:(CAN_CREATE_CONSUMABLE||CAN_CLOSE_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:#9f5afd;border:1px solid rgba(159,90,253,.4)':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vot="${v}">${l}</button>`).join('')}
|
||
</div>`:'';
|
||
c2b.innerHTML=`<div class="card-hd"><span class="ico"></span> Orders Ready to Close<span class="right">${_readyOrders.length}</span></div>
|
||
<div class="card-bd">
|
||
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">${CONSUMABLE_ONLY_USER?'Consumable Orders — Release → Issue → Close only, no Receipt required.':'Only Released orders that are fully issued and fully received are listed here (per Admin → System Settings). An order missing either isn\'t shown — issue or receive the rest first, then it appears here.'}</div>
|
||
${searchRow}${typeRow}${gridBody}${renderPager()}</div>`;
|
||
app.appendChild(c2b);
|
||
|
||
renderModal();
|
||
|
||
// Wire (page-level controls)
|
||
setTimeout(()=>{
|
||
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;_order=null;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(el=>el.onclick=()=>selectOrder(parseInt(el.dataset.ready)));
|
||
},0);
|
||
}
|
||
|
||
// ── Detail / close popup ──────────────────────────────
|
||
function renderModal(){
|
||
const bg=document.getElementById('close-detail-modal');
|
||
const inner=document.getElementById('close-modal-inner');
|
||
if(!_modalOpen){bg.classList.remove('show');return;}
|
||
bg.classList.add('show');
|
||
|
||
// Success
|
||
if(_success){
|
||
inner.innerHTML=`<div class="modal-hd"><span>Close Production Order — Done</span><button class="modal-close" id="close-modal-x">✕</button></div>
|
||
<div class="modal-bd"><div class="suc-box">
|
||
<div class="suc-icon">✓</div>
|
||
<div class="suc-title">Order Closed!</div>
|
||
<p style="font-size:13px;color:var(--text3)">${esc(_order?.ItemNo||'')} — ${esc(_order?.ProductDescription||'')}</p>
|
||
<div class="suc-pill">Order #${esc(String(_order?.DocumentNumber||_order?.AbsoluteEntry||''))}</div>
|
||
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">
|
||
Production Order has been closed in SAP B1. No further issues or receipts can be made.
|
||
</div>
|
||
<div class="btn-bar" style="justify-content:center;margin-top:12px">
|
||
<button class="btn b-new" id="btn-done">Done</button>
|
||
</div>
|
||
</div></div>`;
|
||
setTimeout(()=>{q('#close-modal-x').onclick=closeCloseModal;q('#btn-done').onclick=closeCloseModal;},0);
|
||
return;
|
||
}
|
||
|
||
// Loading
|
||
if(_loadingOrder||!_order){
|
||
inner.innerHTML=`<div class="modal-hd"><span>Loading Order…</span><button class="modal-close" id="close-modal-x">✕</button></div>
|
||
<div class="modal-bd"><div class="empty-msg"><span class="sp" style="border-color:rgba(245,166,35,.25);border-top-color:var(--amber)"></span> Loading order details...</div></div>`;
|
||
setTimeout(()=>{q('#close-modal-x').onclick=closeCloseModal;},0);
|
||
return;
|
||
}
|
||
|
||
const d=_order;
|
||
const st=(d.ProductionOrderStatus||'').replace('bopos','');
|
||
const isClosed=st==='Closed';
|
||
const lines=d.ProductionOrderLines||[];
|
||
const linesHtml=lines.map((l,i)=>`<div class="comp-card">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||
<span style="font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600">${esc(l.ItemNo)}</span>
|
||
<span style="font-size:11px;color:var(--text2);flex:1">${esc(l.ItemName||'')}</span>
|
||
<span style="font-size:10px;color:var(--text3)">#${i+1}</span>
|
||
</div>
|
||
<div class="comp-grid">
|
||
<div><span class="comp-fl">Planned</span><div class="comp-fv">${l.PlannedQuantity}</div></div>
|
||
<div><span class="comp-fl">Issued</span><div class="comp-fv" style="color:${(l.IssuedQuantity||0)>=(l.PlannedQuantity||1)?'var(--green)':'var(--amber)'}">${l.IssuedQuantity||0}</div></div>
|
||
<div><span class="comp-fl">Warehouse</span><div class="comp-fv">${esc(l.Warehouse||'-')}</div></div>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
// Full-quantity gate (admin setting closeRequireFullQty): close only when
|
||
// Completed = Planned. Orders whose receipt was REJECTED are exempt — they
|
||
// can never reach full qty. The server enforces the same rule.
|
||
const _planned=parseFloat(d.PlannedQuantity)||0,_completed=parseFloat(d.CompletedQuantity)||0;
|
||
const _rejectedLocal=_localStatusByEntry[d.AbsoluteEntry]==='REJECTED';
|
||
// Consumable Order workflow is Release → Issue → Close only (no Receipt),
|
||
// so Completed Qty never reaches Planned Qty for one — anyone holding
|
||
// 'approve' on the dedicated Consumable Order — Close step bypasses this
|
||
// gate the same way IS_PO_ADMIN_OVERRIDE does (mirrors routes/sap.js's
|
||
// isConsumableCloser()). No creator bypass.
|
||
const consumableCloser=!!(d._localIsConsumable&&CAN_CLOSE_CONSUMABLE);
|
||
const bypassQtyGate=IS_PO_ADMIN_OVERRIDE||consumableCloser;
|
||
const qtyOk=!_closeRequireFullQty||_rejectedLocal||Math.abs(_completed-_planned)<=0.0001||bypassQtyGate;
|
||
const actionHtml=isClosed
|
||
?`<div class="alert al-green" style="margin-top:12px;margin-bottom:0">This production order is already closed.</div>`
|
||
:!qtyOk
|
||
?`<div class="alert al-red" style="margin-top:12px">Cannot close yet: <b>Completed Qty (${_completed})</b> must equal <b>Planned Qty (${_planned})</b>. Receive the remaining <b>${Math.round((_planned-_completed)*1000)/1000}</b> from production first.</div>
|
||
<div class="btn-bar">
|
||
<button class="btn b-close" disabled style="opacity:.5;cursor:not-allowed">Close Production Order</button>
|
||
<button class="btn b-cancel" id="btn-cancel">Cancel</button>
|
||
</div>`
|
||
:`<div class="alert al-amber" style="margin-top:12px">${consumableCloser?'Closing this Consumable Order is permanent — its workflow is Release → Issue → Close only, no Receipt step.':((bypassQtyGate&&Math.abs(_completed-_planned)>0.0001?'<strong>Override:</strong> quantities are NOT fully matched — closing anyway. ':'')+'Closing this production order is permanent. All open quantities will be zeroed out. Make sure Issue and Receipt are complete before closing.')}</div>
|
||
<div class="btn-bar">
|
||
<button class="btn b-close" id="btn-close" ${_closing?'disabled':''}><span id="sp-close" style="display:${_closing?'inline-block':'none'}" class="sp"></span> Close Production Order</button>
|
||
<button class="btn b-cancel" id="btn-cancel">Cancel</button>
|
||
</div>`;
|
||
|
||
inner.innerHTML=`
|
||
<div class="modal-hd">
|
||
<span style="font-family:var(--mono);color:var(--amber)">${esc(d.ItemNo)}</span>
|
||
<span style="color:var(--text3);font-size:11px">Order #${d.DocumentNumber||d.AbsoluteEntry}</span>
|
||
<button class="modal-close" id="close-modal-x" style="margin-left:auto">✕</button>
|
||
</div>
|
||
<div class="modal-bd">
|
||
<div class="order-info">
|
||
<div class="oi-item"><span class="oi-l">Product</span><span class="oi-v" style="color:var(--amber)">${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">Status</span><span class="oi-v" style="color:${st==='Closed'?'var(--text3)':st==='Released'?'var(--blue)':'var(--amber)'}">${st}</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 Qty</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>
|
||
${lines.length?`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Components (${lines.length})</div>${linesHtml}`:''}
|
||
${actionHtml}
|
||
</div>`;
|
||
|
||
setTimeout(()=>{
|
||
q('#close-modal-x').onclick=closeCloseModal;
|
||
q('#btn-cancel')&&(q('#btn-cancel').onclick=closeCloseModal);
|
||
q('#btn-close')&&(q('#btn-close').onclick=function(){if(!confirm('Are you sure you want to close this production order? This cannot be undone.'))return;doClose();});
|
||
},0);
|
||
}
|
||
|
||
async function init(){
|
||
try{
|
||
const hash=window.location.hash;
|
||
const orderMatch=hash.match(/order=(\d+)/);
|
||
const coMatch=hash.match(/company=([^&]+)/);
|
||
if(coMatch)_co=decodeURIComponent(coMatch[1]);
|
||
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
|
||
// Full-qty gate config + local statuses (for the REJECTED exemption)
|
||
try{const cfg=await api('GET','/config');if(cfg&&typeof cfg.closeRequireFullQty==='boolean')_closeRequireFullQty=cfg.closeRequireFullQty;}catch(_e){}
|
||
try{const lr=await api('GET',`/production-orders?company=${encodeURIComponent(_co)}&status=ALL`);(lr.data||[]).forEach(p=>{if(p.sapAbsEntry)_localStatusByEntry[p.sapAbsEntry]=p.status;});}catch(_e){}
|
||
render();
|
||
if(orderMatch){
|
||
const entry=parseInt(orderMatch[1]);
|
||
if(entry) await selectOrder(entry);
|
||
}else{
|
||
loadReadyOrders();
|
||
}
|
||
}catch(e){console.error('Init error:',e);render();}
|
||
}
|
||
init();
|
||
|
||
// Hash-only link changes (e.g. the sidebar "Pending Actions" bell linking
|
||
// here for a different order while already on this page) don't reload the
|
||
// page, so init() alone would never pick up the new order — re-check on
|
||
// hashchange too. See issue-production.html for the fuller explanation.
|
||
window.addEventListener('hashchange',()=>{
|
||
const hash=window.location.hash;
|
||
const orderMatch=hash.match(/order=(\d+)/);
|
||
const coMatch=hash.match(/company=([^&]+)/);
|
||
if(coMatch)_co=decodeURIComponent(coMatch[1]);
|
||
if(orderMatch){
|
||
const entry=parseInt(orderMatch[1]);
|
||
if(entry)selectOrder(entry);
|
||
}
|
||
});
|
||
</script>
|
||
<script src="/sidebar.js?v=25"></script>
|
||
</body>
|
||
</html>
|