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

443 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function mk(c){const d=document.createElement('div');d.className=c;return d;}
async function api(m,p,b=null){
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
let r,d;try{r=await fetch(BE+p,o);d=await r.json();}catch(_e){throw new Error('Cannot reach server');}
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
if(!r.ok)throw new Error(d?.message||'HTTP '+r.status);return d;
}
function toast(msg,type='ok'){const tc=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
// Whether a next page of Released orders exists — from the server total when
// known, else from whether a full 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>