Files
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

2415 lines
172 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>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;--purple2:#7c3aed;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--extra:#ec4899;--extrasevere:#ea580c;--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(--purple);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(--purple);background:rgba(159,90,253,.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}
/* Tabs */
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface3);border-radius:var(--radius);padding:4px}
.tab{flex:1;padding:8px;border-radius:7px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);text-align:center;transition:all .15s}
.tab:hover{color:var(--text2)}
.tab.on{background:var(--surface);color:var(--purple);border:1px solid var(--border2)}
/* Cards */
.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}
/* Fields */
.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(--purple);box-shadow:0 0 0 2px rgba(159,90,253,.15)}.fi::placeholder{color:var(--text3)}
/* Compact header card (Company/Type) — smaller than the standard card */
.card.compact .card-hd{padding:7px 12px;font-size:11px}
.card.compact .card-bd{padding:8px 12px}
.card.compact .fg{margin-bottom:0}
.card.compact .fl{font-size:9px;margin-bottom:3px}
.card.compact .fi{height:30px;padding:5px 9px;font-size:12px}
.card.compact .fi-row{margin-bottom:12px}
.card.compact .fi-row:last-child{margin-bottom:0}
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}
/* Search dropdown */
.search-wrap{position:relative}
.search-dd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1px solid var(--purple);border-radius:7px;box-shadow:0 12px 40px rgba(0,0,0,.7);z-index:300;max-height:200px;overflow-y:auto;display:none;margin-top:4px}
.sd-row{padding:10px 12px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center}.sd-row:hover{background:rgba(159,90,253,.1)}
.sd-code{font-family:var(--mono);font-size:11px;color:var(--purple);font-weight:600;min-width:90px}.sd-name{font-size:11px;color:var(--text2)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
/* Components */
.bom-msg{text-align:center;padding:22px;color:var(--text3);font-size:12px}
.comp-card{background:var(--ink2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;position:relative}
.comp-card:hover{border-color:var(--border2)}
.comp-num{position:absolute;top:8px;right:10px;font-family:var(--mono);font-size:10px;color:var(--text3);background:var(--surface3);padding:1px 6px;border-radius:4px}
.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(159,90,253,.12);color:var(--purple)}.comp-type.resource{background:rgba(245,166,35,.12);color:var(--amber)}
.comp-item{font-family:var(--mono);font-size:12px;color:var(--purple);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(--purple)}
/* Components — table layout used only in the New Production Order form
(the read-only order-detail popup still uses the .comp-card layout
above, so .comp-num/.comp-desc there are untouched — these overrides
are scoped to .comp-tbl specifically). */
.comp-tbl{width:100%;border-collapse:collapse;font-size:12px}
.comp-tbl th{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;text-align:left;padding:6px 8px}
.comp-tbl td{padding:6px 8px;vertical-align:middle}
.comp-tbl .comp-num{position:static;background:none;padding:0;font-size:11px;font-weight:600}
.comp-tbl .comp-type{margin-bottom:0;margin-right:6px}
.comp-tbl .comp-desc{margin:2px 0 0;font-size:11px;color:var(--text3)}
/* 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-planned{background:var(--purple);color:#fff;flex:1}.b-planned:hover:not(:disabled){background:var(--purple2);box-shadow:0 4px 16px rgba(159,90,253,.4)}
.b-release{background:var(--green);color:#fff;flex:1}.b-release:hover:not(:disabled){background:#09a077;box-shadow:0 4px 16px rgba(13,187,138,.4)}
.b-new{background:rgba(159,90,253,.1);color:var(--purple);border:1px solid rgba(159,90,253,.3);flex:1}.b-new:hover{background:rgba(159,90,253,.2)}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
.b-view{background:rgba(26,111,255,.08);color:var(--blue);border:1px solid rgba(26,111,255,.25)}.b-view:hover{background:rgba(26,111,255,.15)}
/* Alert */
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
.al-green{background:rgba(13,187,138,.08);border:1px solid rgba(13,187,138,.2);color:#0a8f57}
.al-red{background:rgba(229,62,62,.08);border:1px solid rgba(229,62,62,.2);color:#c81e1e}
.al-blue{background:rgba(37,99,235,.08);border:1px solid rgba(37,99,235,.2);color:#1d4ed8}
.al-amber{background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.25);color:var(--amber)}
/* Spinner / Toast */
.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}}
/* Success */
.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(159,90,253,.12);color:var(--purple);border:1px solid rgba(159,90,253,.25);margin:8px 0 12px}
/* View list */
.po-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:8px;cursor:pointer;transition:all .15s}
.po-card:hover{border-color:var(--border2);transform:translateY(-1px);box-shadow:0 4px 16px rgba(0,0,0,.3)}
.po-top{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap}
.po-code{font-family:var(--mono);font-size:11px;color:var(--purple);font-weight:600}
.po-doc{font-family:var(--mono);font-size:10px;color:var(--text3)}
.po-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase}
.po-badge.planned{background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
.po-badge.released{background:rgba(26,111,255,.12);color:var(--blue);border:1px solid rgba(26,111,255,.25)}
.po-badge.closed{background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)}
.po-name{font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px}
.po-meta{font-size:11px;color:var(--text3);display:flex;gap:8px;flex-wrap:wrap}
.po-meta b{color:var(--text2)}
/* Order list — square-tile grid, same pattern as work-order.html's .wo-grid/.wo-card */
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.po-card.tile{aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden;margin-bottom:0;padding:10px}
.po-card.tile:hover{border-color:var(--purple);box-shadow:0 6px 20px rgba(159,90,253,.14)}
.po-card.tile .po-top{align-items:flex-start;justify-content:space-between}
.po-card.tile .po-icon{width:26px;height:26px;font-size:13px;border-radius:7px;background:rgba(159,90,253,.12);color:var(--purple);display:flex;align-items:center;justify-content:center;margin:5px 0}
.po-card.tile .po-name{font-size:11px;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.po-card.tile .po-badges{display:flex;gap:3px;flex-wrap:wrap;margin-top:3px}
.po-card.tile .po-meta{margin-top:auto;padding-top:5px;border-top:1px solid var(--border);flex-direction:column;gap:2px;font-size:9.5px}
.po-card.tile .po-code{font-size:10px}
.po-card.tile .po-badge{font-size:8px;padding:1px 5px}
.po-card.tile.sel{background:rgba(159,90,253,.10);border-color:var(--purple);box-shadow:0 0 0 2px rgba(159,90,253,.25)}
.po-card.tile.sel .po-icon{background:var(--purple);color:#fff}
.po-card.tile.sel .po-code{color:var(--purple)}
/* Source Work Order picker — search bar */
.vf-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.vf-search{position:relative;flex:1;min-width:200px}
.vf-search input{width:100%;padding:8px 12px 8px 32px;border:1px solid var(--border);border-radius:8px;font-size:12.5px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none}
.vf-search input:focus{border-color:var(--purple);box-shadow:0 0 0 3px rgba(159,90,253,.15)}
.vf-search i{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none}
.vf-count{font-size:11px;color:var(--text3);white-space:nowrap}
.po-empty{text-align:center;padding:30px;color:var(--text3);font-size:13px}
/* Detail modal */
.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}
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:600px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.6);animation:mIn .2s ease}
@keyframes mIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.modal-hd-title{font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.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}
.modal-ft{padding:12px 14px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
.dt-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(28,46,72,.4);font-size:12px}
.dt-row .dt-l{color:var(--text3);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.3px;min-width:40%}
.dt-row .dt-v{color:var(--text);text-align:right;font-family:var(--mono);font-size:11px}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<div class="modal-bg" id="detail-modal"><div class="modal" id="modal-inner"></div></div>
<div class="modal-bg" id="create-modal"><div class="modal" id="create-inner" style="max-width:860px"></div></div>
<div class="modal-bg" id="dev-modal"><div class="modal" id="dev-modal-inner" style="max-width:560px"></div></div>
<div class="modal-bg" id="dev-reject-modal"><div class="modal" id="dev-reject-modal-inner" style="max-width:480px"></div></div>
<div class="modal-bg" id="prepwo-modal"><div class="modal" id="prepwo-inner" style="max-width:860px"></div></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 active">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">Close</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>
<script>
'use strict';
let _tok=null,_user=null,_wh=[];
const BACKEND='/api';
// ── Production Deviations (Shortage / Substitution / Damage) ──
let _devOrder=null; // {entry, docNum, itemNo, lines} — the order the modal is currently raising a deviation for
let _devList=[]; // deviations already raised for the order shown in openDetail()
let _devScrapWh=''; // Admin → System Settings → Deviation Scrap Warehouse (fetched once)
let _devShowRemoveOldLineBtn=true; // Admin → System Settings → Deviation "Show Remove from SAP Button" (fetched once)
let _devOpen=false;
let _devType='SHORTAGE';
let _devEnabledTypes=['SHORTAGE','SUBSTITUTION','DAMAGE','ADDITION']; // Admin → System Settings → Deviation Enabled Types (fetched once)
const _devTypeLabels={SHORTAGE:'Shortage — top up qty of the same item',SUBSTITUTION:'Substitution — issue a different item instead',DAMAGE:'Damage/Loss — write off a damaged qty',ADDITION:'Addition — add a new item/component'};
let _devItemCode='';
let _devNewItemCode='',_devNewItemName='';
let _devWarehouse='';
let _devDestWarehouse='';
let _devPostToSap=true;
let _devQty='';
let _devReason='';
let _devSubmitting=false;
let _devActiveMap={}; // itemCode -> is it active in SAP right now (validFor='Y' & frozenFor='N') — hides inactive components from the picker
let _devBatches=[]; // batches currently in stock for the relevant item+warehouse — [] means not batch-managed (or no stock found)
let _devSelectedBatches=[]; // [{BatchNumber,Quantity}] — FIFO-allocated across _devBatches to cover _devQty, editable, same pattern as Issue for Production
(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.
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 on the server — approvalSteps
// entries are either legacy plain strings (full perms) or {step,perms} objects.
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;
}
const CAN_CREATE_PO=hasStepPerm('production_order:create','add'); // from a Work Order
const CAN_MANUAL_CREATE_PO=hasStepPerm('production_order:manual_create','add'); // standalone entry
const CAN_CREATE_PWO_FROM_COMPONENT=hasStepPerm('production_order:create_from_component','add'); // "+ PWO" shortcut on a component line
const CAN_CREATE_CONSUMABLE=hasStepPerm('production_order:consumable_create','add'); // "Consumable Order" — Manual Entry restricted to Service items (Item Group 132)
// Release/Issue/Close on a Consumable Order are each their OWN independent
// approval step (not one shared "approve" bit, and not the general
// per-stage steps) — a user can hold any subset. There is no consumable
// Receipt permission at all (that workflow never reaches Receipt).
const CAN_RELEASE_CONSUMABLE=hasStepPerm('production_order:consumable_release','approve');
const CAN_ISSUE_CONSUMABLE=hasStepPerm('production_order:consumable_issue','approve');
const CAN_CLOSE_CONSUMABLE=hasStepPerm('production_order:consumable_close','approve');
const CAN_ANY_CONSUMABLE=CAN_CREATE_CONSUMABLE||CAN_RELEASE_CONSUMABLE||CAN_ISSUE_CONSUMABLE||CAN_CLOSE_CONSUMABLE;
const CONSUMABLE_ITEM_GROUP=132;
const CAN_RELEASE_PO=hasStepPerm('production_order:release','approve');
// 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');
const CAN_RECEIPT_PO=hasStepAssigned('production_order:receipt');
const CAN_TRANSFER_FG=hasStepPerm('production_order:transfer_fg','approve');
const CAN_CLOSE_PO=hasStepAssigned('production_order:close');
// A user who holds ANY Consumable Order step but NONE of the general
// production_order steps must see ONLY Consumable Orders in View Orders —
// mirrors routes/sap.js's isConsumableOnlyUser() (also enforced server-side
// there, so this is UI-only: hiding the pointless Type toggle and forcing
// the default, not the actual security boundary).
const CONSUMABLE_ONLY_USER=CAN_ANY_CONSUMABLE&&!(CAN_CREATE_PO||CAN_MANUAL_CREATE_PO||CAN_CREATE_PWO_FROM_COMPONENT||CAN_RELEASE_PO||CAN_ISSUE_PO||CAN_RECEIPT_PO||CAN_TRANSFER_FG||CAN_CLOSE_PO);
// Admin / System Admin: can Close OR Cancel a Production Order at ANY stage,
// bypassing every normal gate below (step assignment, full-issuance,
// full-qty, sequencing) — a deliberate override for these two roles only.
const IS_PO_ADMIN_OVERRIDE=!!(_user&&['admin','system_admin'].includes(_user.role));
// Previously Cancel had NO assignable step at all — admin/system_admin
// only. Now also grantable to a regular user via 'approve' on this step
// (Admin → Edit User → Approval Steps), same shape as every other
// production_order permission.
const CAN_CANCEL_PO=hasStepPerm('production_order:cancel','approve');
const CAN_RAISE_DEVIATION=hasStepPerm('production_order:deviation','add');
const CAN_APPROVE_DEVIATION=hasStepPerm('production_order:deviation','approve');
const CAN_VIEW_DEVIATION=hasStepAssigned('production_order:deviation');
// Pre-PWO Store Review (Admin → System Settings → "Pre-PWO — Store Review
// Before SAP") — optional, off by default. See services/prePwoStore.js.
const CAN_SHARE_PREPWO=hasStepPerm('production_order:prepwo_share','add');
const CAN_REVIEW_PREPWO=hasStepPerm('production_order:prepwo_review','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 _tab='create'; // 'create' | 'view'
let _hdr={itemNo:'',desc:'',qty:1,warehouse:'',type:'bopotStandard',dueDate:'',startDate:'',postingDate:'',remarks:''};
let _lines=[];
let _bomLoading=false;
let _created=null;
let _orders=[];
let _ordersEverLoaded=false;
let _viewLoading=false;
let _viewFilter='';
let _viewDateFrom='',_viewDateTo=''; // filters the loaded list by Due Date — client-side, same scope as the search box above
let _viewOrderType=CONSUMABLE_ONLY_USER?'consumable':''; // '' = all, 'consumable' = Consumable Orders only, 'regular' = everything else
let _viewSearch='';
let _page=0; // View Orders pagination (0-based)
let _pageSize=20;
let _total=null; // total matching orders (from server), null if unknown
// Create-from-Work-Order: production orders can be sourced from a WO
// (auto-fills item/qty/batch, links back for stage tracking) or entered
// manually like before — 'src' toggles which flow the Create tab shows.
// Default to whichever this user is actually allowed — a Consumable-only
// user (no CAN_MANUAL_CREATE_PO, no CAN_CREATE_PO) must NOT default to 'wo':
// that unconditionally fires loadSourceWorkOrders() (GET /work-orders,
// gated on work_order:view) on page load, which such a user was never
// assigned, surfacing a raw "not assigned view on workflow: work_order"
// error the moment they open this page at all.
let _srcMode=CAN_MANUAL_CREATE_PO?'manual':(CAN_CREATE_PO?'wo':'manual'); // 'manual' | 'wo'
let _srcFromComponent=false; // true when this form was opened via the "+ PWO" shortcut on another order's component — authorized by production_order:create_from_component instead of manual_create
let _srcConsumable=false; // true when this form was opened via "+ Consumable Order" — authorized by production_order:consumable_create instead of manual_create; Product No. search is restricted to Item Group 132 (Service)
let _manualPoGroups=[]; // this user's Manual PWO Item Groups restriction (Admin → user), [] = unrestricted; narrows the Manual Entry Product No. search, server independently re-verifies on submit
// Consumable Orders are raised by many departments — the creator picks
// theirs from the SAME OUDP/"PR Departments" list + per-user restriction
// already used for Purchase Requisition (see loadConsumableDepartments()).
// Purely an organizational tag: Release/Issue/Close stay department-
// independent (see routes/sap.js's consumableDeptRestriction()).
let _consumableDept='';
let _consumableDeptOptions=[]; // this user's own allowed department names — loaded when the Consumable Order form opens
let _refWoNo='',_refBatchNumber=''; // the "+ PWO" shortcut's parent order's WO No./Batch No. — carried onto this new order's local record purely for reference/traceability
let _srcFromComponentInfo=null; // {entry,docNum,itemNo} of the parent order the "+ PWO" shortcut was opened from — shown as a banner in the create form
let _returnToViewOnClose=false; // true when this create session was opened via the "+ PWO" shortcut FROM View Orders — closing/finishing should land back there, not on the generic Create landing
let _srcWorkOrders=[];
let _srcWOLoading=false;
let _srcWorkOrderId=null;
let _srcBatchNumber='';
let _srcWOSearch='';
let _localOrders=[]; // /api/production-orders — our local stage-tracking records, keyed by SAP AbsoluteEntry for the View tab
// Pre-PWO Store Review state
let _preWoReviewEnabled=false;
let _reqStockAvailability=false; // System Settings → "Require stock availability for PWO creation"
let _preWoList=[], _preWoListLoading=false, _preWoListLoaded=false;
let _preWoStaged=null; // just-staged record, shown on the staging-success screen
let _preWoDetail=null; // record open in the Pre-PWO detail/review modal
let _preWoEditLines=null; // working edit copy (Store's review, or Production's direct edit)
let _preWoEditRemarks='';
let _preWoEditMode=false; // true while _preWoEditLines is an active edit session
let _preWoModalOpen=false;
let _preWoSearchTimer={};
let _localByEntry={};
let _createModalOpen=false; // the New Production Order form now lives in a popup modal (opened from a WO card or the "+ New" button)
let _woExtrasLoading=false; // checking the WO's rows against the full BOM tree for hand-added extras
// Special/Disassembly kept here (not deleted) for when they're needed again —
// just not offered in the Type dropdown for now. Toggle `enabled` to bring
// one back; no other code change needed since PROD_TYPES stays the source
// of truth for the create-order payload either way.
const PROD_TYPES=[
{v:'bopotStandard', l:'Standard', enabled:true},
{v:'bopotSpecial', l:'Special', enabled:false},
{v:'bopotDisassembly', l:'Disassembly', enabled:false},
];
function today(){return new Date().toISOString().slice(0,10);}
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(BACKEND+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(),4000);}
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{}}
// Consumable Order's Department dropdown — same source + per-user
// restriction as Purchase Requisition's own Department picker (see
// purchase-request.html's loadDepartments()): live SAP OUDP list, narrowed
// to this user's own "PR Departments" allow-list if one is set. Auto-picks
// the single option when there's only one, so a single-department user
// never has to touch the dropdown at all.
async function loadConsumableDepartments(){
let allowed=[];
try{const pr=await api('GET','/auth/profile');if(Array.isArray(pr?.user?.allowedDepartments))allowed=pr.user.allowedDepartments.map(String);}catch(_e){}
try{
const r=await api('GET',`/sap/lookup/departments?company=${encodeURIComponent(_co)}`);
let depts=(r.data||[]).map(d=>d.name).filter(Boolean);
if(allowed.length)depts=depts.filter(n=>allowed.includes(n));
_consumableDeptOptions=depts;
}catch(_e){_consumableDeptOptions=[];}
if(_consumableDeptOptions.length===1)_consumableDept=_consumableDeptOptions[0];
}
async function fetchItems(v,activeOnly,itemGroup){
const groupQs=Array.isArray(itemGroup)?(itemGroup.length?'&itemGroups='+encodeURIComponent(itemGroup.join(',')):''):(itemGroup?'&itemGroup='+encodeURIComponent(itemGroup):'');
try{const r=await api('GET',`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}${activeOnly?'&activeOnly=1':''}${groupQs}`);if(r.success&&r.data?.length)return r.data;}catch{}return[];
}
async function fetchBOM(code){try{const r=await api('GET',`/sap/bom/${encodeURIComponent(code)}?company=${encodeURIComponent(_co)}`);if(r.success&&r.data)return r.data;}catch{}return null;}
async function loadOrders(resetPage){
if(resetPage)_page=0;
_viewLoading=true;render();
const searchQ=_viewSearch.trim()?`&search=${encodeURIComponent(_viewSearch.trim())}`:'';
try{const r=await api('GET',`/sap/production-orders?top=${_pageSize}&skip=${_page*_pageSize}&company=${encodeURIComponent(_co)}${_viewFilter?'&status='+_viewFilter:''}${_viewOrderType?'&orderType='+_viewOrderType:''}${searchQ}`);_orders=r.data||[];_total=(typeof r.total==='number')?r.total:null;}
catch(e){toast(e.message,'err');_orders=[];}
try{const r=await api('GET',`/production-orders?company=${encodeURIComponent(_co)}`);_localOrders=r.data||[];}
catch(e){_localOrders=[];}
_localByEntry={};_localOrders.forEach(p=>{if(p.sapAbsEntry)_localByEntry[p.sapAbsEntry]=p;});
_ordersEverLoaded=true;
_viewLoading=false;render();
}
// Work Orders eligible as a source: must be fully APPROVED (all 5 QA/
// Production sign-off steps complete) — production shouldn't start on a WO
// that hasn't finished its own approval chain yet. A Work Order may only
// ever produce ONE Production Order (its full quantity is captured the
// first time), so any WO that already has one (non-deleted) is excluded here
// too — not loaded via the View tab's _localOrders, since a user landing
// straight on Create wouldn't have that populated yet.
async function loadSourceWorkOrders(){
_srcWOLoading=true;render();
try{const r=await api('GET',`/work-orders?status=APPROVED&company=${encodeURIComponent(_co)}`);_srcWorkOrders=r.data||[];}
catch(e){toast(e.message,'err');_srcWorkOrders=[];}
try{
const r=await api('GET',`/production-orders?company=${encodeURIComponent(_co)}`);
const used=new Set((r.data||[]).filter(p=>!p.isDeleted&&p.workOrderId).map(p=>String(p.workOrderId)));
_srcWorkOrders=_srcWorkOrders.filter(w=>!used.has(String(w.id)));
}catch(_e){}
// A Work Order that already has an open (not-yet-pushed-to-SAP) Pre-PWO
// staged against it is also off the table — same "one per Work Order"
// rule, just checked one step earlier now that staging can happen first.
if(_preWoReviewEnabled){
try{
const r=await api('GET',`/pre-production-orders?company=${encodeURIComponent(_co)}`);
const staged=new Set((r.data||[]).filter(p=>!p.isDeleted&&p.status!=='CONVERTED'&&p.workOrderId).map(p=>String(p.workOrderId)));
_srcWorkOrders=_srcWorkOrders.filter(w=>!staged.has(String(w.id)));
}catch(_e){}
}
_srcWOLoading=false;render();
}
function filteredSrcWorkOrders(){
const s=_srcWOSearch.trim().toUpperCase();
if(!s)return _srcWorkOrders;
return _srcWorkOrders.filter(w=>
(w.woNo||'').toUpperCase().includes(s)||
(w.productName||'').toUpperCase().includes(s)||
(w.productCode||'').toUpperCase().includes(s)||
(w.batchNumber||'').toUpperCase().includes(s));
}
// Square-tile grid of fully-approved Work Orders to pick as a Production
// Order's source, with a search box — same pattern as the other module
// list views (work-order.html's .wo-grid, etc.).
function renderSrcWOPicker(){
if(_srcWOLoading)return '<div class="bom-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading approved work orders...</div>';
if(!_srcWorkOrders.length)return `<div class="bom-msg">No fully-approved Work Orders available to source from (Work Orders that already have a Production Order${_preWoReviewEnabled?' or an open Pre-PWO':''} are excluded).</div>`;
const list=filteredSrcWorkOrders();
const bar=`<div class="vf-bar">
<div class="vf-search"><i>🔍</i><input id="src-wo-search" placeholder="Search WO No, product, batch…" value="${esc(_srcWOSearch)}"/></div>
<span class="vf-count">${list.length} of ${_srcWorkOrders.length}</span>
</div>`;
const grid=list.length
? `<div class="po-grid">${list.map(w=>`<div class="po-card tile${_srcWorkOrderId===w.id?' sel':''}" data-wo="${w.id}">
<div class="po-top"><span class="po-code">${esc(w.woNo)}</span><span class="po-badge released">APPROVED</span></div>
<div class="po-icon">📄</div>
<div class="po-name" title="${esc(w.productName||'')}">${esc(w.productName)}</div>
<div class="po-meta"><span>Batch <b>${esc(w.batchNumber||'-')}</b></span><span>Total Units <b>${esc(w.totalUnits||'-')}</b></span></div>
</div>`).join('')}</div>`
: '<div class="bom-msg">No work orders match your search.</div>';
return bar+grid;
}
async function init(){
_hdr.dueDate=today();_hdr.startDate=today();_hdr.postingDate=today();
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
try{const pr=await api('GET','/auth/profile');if(Array.isArray(pr?.user?.manualPoItemGroups))_manualPoGroups=pr.user.manualPoItemGroups.map(String);}catch(_e){}
// Which Type options are offered is driven by PROD_ORDER_TYPES in .env
// (see server.js's /api/config) — falls back to the hardcoded `enabled`
// flags on PROD_TYPES above if the config fetch fails for any reason.
try{
const r=await api('GET','/config');
if(r?.prodOrderTypesEnabled?.length){
const enabledSet=new Set(r.prodOrderTypesEnabled);
PROD_TYPES.forEach(t=>{t.enabled=enabledSet.has(t.v);});
if(!enabledSet.has(_hdr.type))_hdr.type=PROD_TYPES.find(t=>t.enabled)?.v||_hdr.type;
}
if(r?.deviationScrapWarehouse)_devScrapWh=r.deviationScrapWarehouse;
if(r&&typeof r.deviationShowRemoveOldLineButton==='boolean')_devShowRemoveOldLineBtn=r.deviationShowRemoveOldLineButton;
if(r?.deviationEnabledTypes?.length)_devEnabledTypes=r.deviationEnabledTypes;
if(r&&typeof r.preWoStoreReviewEnabled==='boolean')_preWoReviewEnabled=r.preWoStoreReviewEnabled;
if(r&&typeof r.poRequireStockAvailability==='boolean')_reqStockAvailability=r.poRequireStockAvailability;
}catch(_e){}
await loadWH();
// Deep link from the sidebar "Pending Actions" bell (?open=<AbsoluteEntry>)
// — jump straight to View Orders and open that one order's detail popup.
const openEntry=new URLSearchParams(window.location.search).get('open');
if(openEntry){
_tab='view';
await loadOrders();
openDetail(parseInt(openEntry));
return;
}
// If Manual Entry isn't available to this user, they start straight on
// "From Work Order" (see _srcMode's default) — with no toggle button to
// click to trigger the load, so kick it off here instead. Guarded on
// CAN_CREATE_PO too — a user with neither create permission (e.g.
// Consumable-only) must never hit this gated endpoint at all.
if(_srcMode==='wo'&&CAN_CREATE_PO)loadSourceWorkOrders();
else render();
}
// ═══════════════════════════════════════
function render(){
const app=document.getElementById('app');app.innerHTML='';
if(_tab!=='create')_createModalOpen=false; // the create popup only belongs to the Create tab
if(_tab!=='prepwo')_preWoModalOpen=false; // the Pre-PWO detail popup only belongs to the Pre-PWO tab
// Tabs — "+ Create Order" only shown to users who actually hold either
// create permission; otherwise there's nothing to click into that would
// just dead-end in a "not assigned" message, so skip straight to viewing.
const canCreateAny=CAN_CREATE_PO||CAN_MANUAL_CREATE_PO||CAN_CREATE_CONSUMABLE;
// The "+ PWO" shortcut (production_order:create_from_component) drives
// _tab='create' directly, bypassing the tab bar — don't stomp on it here
// even for a user who holds neither of the two general create permissions.
if(!canCreateAny&&_tab==='create'&&!_srcFromComponent)_tab='view';
const canPrePwo=_preWoReviewEnabled&&(CAN_CREATE_PO||CAN_SHARE_PREPWO||CAN_REVIEW_PREPWO);
if(!canPrePwo&&_tab==='prepwo')_tab='view';
// Landing directly on View Orders (no create permission, so the tab
// button's own onclick — which triggers the load — never fires) used to
// leave the list empty until the user clicked Refresh. Kick the load off
// here too, same guard the tab click uses.
if(_tab==='view'&&!_ordersEverLoaded&&!_viewLoading){loadOrders();return;}
if(_tab==='prepwo'&&!_preWoListLoaded&&!_preWoListLoading){loadPrePwoList();return;}
const tb=mk('tabs');
[
...(canCreateAny?[{id:'create',l:'+ Create Order'}]:[]),
{id:'view',l:'View Orders'},
...(canPrePwo?[{id:'prepwo',l:'🧾 Pre-PWO'}]:[]),
].forEach(t=>{
const b=document.createElement('button');b.className=`tab${_tab===t.id?' on':''}`;b.textContent=t.l;
b.onclick=()=>{_tab=t.id;if(t.id==='view'&&!_orders.length)loadOrders();else if(t.id==='prepwo'&&!_preWoListLoaded)loadPrePwoList();else render();};
tb.appendChild(b);
});
app.appendChild(tb);
if(_tab==='view'){renderView(app);return;}
if(_tab==='prepwo'){renderPrePwoTab(app);return;}
// ── CREATE tab landing: choose a source (Manual, or an approved Work
// Order). The actual New Production Order form opens in a popup modal —
// same list-then-popup pattern as the Issue / Receipt / Close pages.
const bothSrcModes=CAN_MANUAL_CREATE_PO&&CAN_CREATE_PO;
const cs=mk('card');
cs.innerHTML=`<div class="card-hd"><span class="ico"></span> New Production Order
<span class="right"><select class="fi" id="sel-co-land" style="height:28px;font-size:11px;width:150px;padding:2px 22px 2px 8px">${coOptions(_co)}</select></span></div>
<div class="card-bd">
${bothSrcModes?`<div class="tabs" style="margin-bottom:12px">
<button class="tab${_srcMode==='manual'?' on':''}" id="src-manual">Manual Entry</button>
<button class="tab${_srcMode==='wo'?' on':''}" id="src-wo">From Work Order</button>
</div>`:''}
${(CAN_MANUAL_CREATE_PO||CAN_CREATE_PO)?(_srcMode==='manual'
? `<button class="btn b-planned" id="btn-new-manual" style="width:100%;padding:13px;font-size:13px">+ New Production Order</button>
<div style="font-size:11px;color:var(--text3);margin-top:8px;text-align:center">Opens a form to enter the product, components and quantities.</div>`
: renderSrcWOPicker()+`<div style="font-size:11px;color:var(--text3);margin-top:10px;text-align:center">Pick an approved Work Order to open the Production Order form.</div>`):''}
${CAN_CREATE_CONSUMABLE?`<button class="btn" id="btn-new-consumable" style="width:100%;padding:13px;font-size:13px;margin-top:${(CAN_MANUAL_CREATE_PO||CAN_CREATE_PO)?'10px':'0'};background:rgba(159,90,253,.1);color:var(--purple);border:1px solid rgba(159,90,253,.3)">+ Consumable Order</button>
<div style="font-size:11px;color:var(--text3);margin-top:8px;text-align:center">Standalone manual order for Service items (Item Group ${CONSUMABLE_ITEM_GROUP}) only.</div>`:''}
</div>`;
app.appendChild(cs);
setTimeout(()=>{
q('#sel-co-land').onchange=async function(){_co=this.value;_lines=[];_hdr.itemNo='';_hdr.desc='';_hdr.warehouse='';await loadWH();if(_srcMode==='wo')loadSourceWorkOrders();else render();};
if(q('#src-manual'))q('#src-manual').onclick=()=>{_srcMode='manual';_srcWorkOrderId=null;_srcBatchNumber='';render();};
if(q('#src-wo'))q('#src-wo').onclick=()=>{_srcMode='wo';if(!_srcWorkOrders.length)loadSourceWorkOrders();else render();};
if(q('#btn-new-manual'))q('#btn-new-manual').onclick=()=>{resetForm();_srcMode='manual';openCreateModal();};
if(q('#btn-new-consumable'))q('#btn-new-consumable').onclick=async()=>{resetForm();_srcMode='manual';_srcConsumable=true;await loadConsumableDepartments();openCreateModal();};
document.querySelectorAll('[data-wo]').forEach(c=>c.onclick=function(){selectSourceWorkOrder(parseInt(this.dataset.wo));});
if(q('#src-wo-search')){
const sEl=q('#src-wo-search');
sEl.oninput=function(){
_srcWOSearch=this.value;
const pos=this.selectionStart;
render();
const s2=q('#src-wo-search');
if(s2){s2.focus();s2.setSelectionRange(pos,pos);}
};
}
},0);
renderCreateModal();
}
// ═══════════════════════════════════════
// CREATE — POPUP MODAL (the New Production Order form)
// ═══════════════════════════════════════
function openCreateModal(){_createModalOpen=true;render();}
function closeCreateModal(){
const m=_srcMode;
// The "+ PWO" shortcut jumped here straight from View Orders — closing
// (Cancel, or Done after a successful create) should land back there
// instead of the generic Create-tab landing, so raising another "+ PWO"
// for a different component doesn't need an extra manual tab click.
const goToView=_returnToViewOnClose;
_createModalOpen=false;resetForm();_srcMode=m;_returnToViewOnClose=false;
if(goToView){_tab='view';if(!_orders.length)loadOrders();}
render();
// render()'s own create-modal refresh only fires from within the Create-tab
// landing branch — when goToView just switched _tab to 'view', that branch
// (and its call to renderCreateModal()) never runs, so the modal's DOM
// never gets its 'show' class removed and it stays stuck open. Called
// directly here too so closing always actually closes it either way.
renderCreateModal();
} // keep the source tab the user was on (unless returning to View Orders above)
// The four form cards (Company/Type, Product, Components, Actions) as an HTML
// string so the same markup can live inside the popup modal.
function formCardsHtml(){
// Users WITHOUT manual-create permission can only originate a Production
// Order FROM an approved Work Order — product/qty come from that WO and must
// NOT be manually altered (otherwise the from-WO flow would be a backdoor to
// manual creation). So lock those fields for them.
const roLock=!CAN_MANUAL_CREATE_PO&&!_srcConsumable;
const roStyle=roLock?'background:var(--ink2);color:var(--text2);cursor:not-allowed':'';
let ch='';
if(_bomLoading)ch='<div class="bom-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading BOM...</div>';
else if(!_lines.length)ch=`<div class="bom-msg">${_hdr.type==='bopotSpecial'?'Add components manually using the button below':'Select a product to auto-load BOM components'}</div>`;
else ch=`<div class="tbl-wrap"><table class="comp-tbl">
<thead><tr><th style="width:28px">#</th><th>Item</th><th style="width:100px">Base Qty</th><th style="width:110px">Planned Qty</th><th style="width:200px">Warehouse</th><th style="width:90px">Available</th><th style="width:60px"></th></tr></thead>
<tbody>${_lines.map((l,i)=>{
const isManual=!l.itemNo;
const avail=l.itemNo&&l.type!=='Resource'?stockFor(l):null;
const short=avail!=null&&avail<(parseFloat(l.plannedQty)||0)-0.0001;
const availCell=avail==null?(l.type==='Resource'?'<span style="color:var(--text3);font-size:10.5px">—</span>':'<span style="color:var(--text3);font-size:10.5px">…</span>')
:`<span style="font-family:var(--mono);font-weight:700;color:${short?'var(--red)':'var(--green)'}" title="${short?'Short of Planned Qty':'On hand ≥ Planned Qty'}">${avail}</span>`;
return`<tr>
<td class="comp-num">${i+1}</td>
<td>${isManual
?`<div class="search-wrap"><input class="fi fi-mono" data-ci="${i}" placeholder="Search item code..." value="${esc(l.itemNo)}" style="height:30px;font-size:11px" autocomplete="off"/><div class="search-dd comp-dd" id="cdd-${i}"></div></div>`
:`<span class="comp-type ${l.type==='Resource'?'resource':'item'}">${l.type}</span> <span class="comp-item">${esc(l.itemNo)}</span><div class="comp-desc">${esc(l.desc)}</div>`}</td>
<td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="baseQty" type="number" min="0" step="0.001" value="${l.baseQty}" style="width:90px"/>`:''}</td>
<td>${l.itemNo?`<input class="comp-inp" data-i="${i}" data-f="plannedQty" type="number" min="0" step="0.001" value="${l.plannedQty}" style="width:100px"/>`:''}</td>
<td>${l.type==='Resource'?'':`<select class="comp-inp" data-i="${i}" data-f="warehouse" style="width:100%;min-width:140px"><option value="" ${l.warehouse?'':'selected'}>— Select —</option>${_wh.map(w=>`<option value="${esc(w.code)}" ${l.warehouse===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('')}</select>`}</td>
<td style="text-align:right">${availCell}</td>
<td style="text-align:right"><button class="btn" data-rm="${i}" style="padding:3px 10px;font-size:10px;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);border-radius:5px">Remove</button></td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
const addBtn=`<div style="margin-top:8px"><button class="btn" id="btn-add-comp" style="width:100%;padding:8px;font-size:11px;background:rgba(159,90,253,.08);color:var(--purple);border:1px solid rgba(159,90,253,.25);border-radius:7px">+ Add Component</button></div>`;
const woHint=_woExtrasLoading?`<div style="margin-top:8px;font-size:11px;color:var(--text3);text-align:center"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Checking Work Order for extra (non-BOM) items…</div>`:'';
const stagingPrePwo=_preWoReviewEnabled&&!!_srcWorkOrderId;
const stagingBanner=stagingPrePwo?`<div class="alert al-blue" style="margin-bottom:12px;text-align:left">🧾 <strong>Pre-PWO Store Review is ON.</strong> This will be staged in the portal only — Store must review and revert it before it can be sent to SAP (hard gate, no bypass).</div>`:'';
const fromComponentBanner=(_srcFromComponent&&_srcFromComponentInfo)?`<div class="alert al-blue" style="margin-bottom:12px;text-align:left">🔗 <strong>Created via "+ PWO"</strong> — component of <span style="font-family:var(--mono);color:var(--purple)">${esc(_srcFromComponentInfo.itemNo)}</span> #${esc(String(_srcFromComponentInfo.docNum))}${_refWoNo?` · WO <strong>${esc(_refWoNo)}</strong>`:''}${_refBatchNumber?` · Batch <strong>${esc(_refBatchNumber)}</strong>`:''}</div>`:'';
// Same shortage test as doPlanned()'s own pre-check — surfaced here too so
// the button visibly reflects it instead of only erroring after a click.
const shortLines=(_reqStockAvailability&&!_srcConsumable)
?_lines.filter(l=>l.itemNo&&l.type!=='Resource').map(l=>({l,avail:stockFor(l)})).filter(x=>x.avail!=null&&x.avail<(parseFloat(x.l.plannedQty)||0)-0.0001)
:[];
const stockBlocked=shortLines.length>0;
const stockWarning=stockBlocked?`<div class="alert al-red" style="margin-top:10px;text-align:left">⚠ <strong>Insufficient stock</strong> — ${shortLines.map(x=>`${esc(x.l.itemNo)} (need ${x.l.plannedQty}, have ${x.avail})`).join('; ')}. Cannot create until this is resolved.</div>`:'';
const disableCreate=(!_lines.length&&_hdr.type!=='bopotSpecial'&&!_srcConsumable)||stockBlocked;
const createLabel=stockBlocked?'Insufficient Stock':(stagingPrePwo?'Stage Pre-PWO (not sent to SAP)':'Add as Planned');
return `
${fromComponentBanner}
${stagingBanner}
<div class="card compact"><div class="card-hd"><span class="ico"></span> Company &amp; Type</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">Type</label><select class="fi" id="sel-type">${PROD_TYPES.filter(t=>t.enabled).map(t=>`<option value="${t.v}" ${_hdr.type===t.v?'selected':''}>${t.l}</option>`).join('')}</select></div>
</div></div></div>
<div class="card compact"><div class="card-hd"><span class="ico"></span> Product</div>
<div class="card-bd">
<div class="fi-row">
<div class="fg"><label class="fl">${_srcConsumable?`<span style="color:var(--text3);font-weight:400;text-transform:none">Consumable </span>`:''}Product No. *</label><div class="search-wrap"><input class="fi fi-mono" id="inp-item" value="${esc(_hdr.itemNo)}" placeholder="${roLock?'Comes from the selected Work Order':(_srcConsumable?`Search Service item (Group ${CONSUMABLE_ITEM_GROUP})...`:'Search item...')}" autocomplete="off" ${roLock?'readonly':''} style="${roStyle}"/><div class="search-dd" id="item-dd"></div></div></div>
<div class="fg"><label class="fl">Description</label><input class="fi" id="inp-desc" value="${esc(_hdr.desc)}" readonly style="background:var(--ink2);color:var(--text2)"/></div>
</div>
${_srcConsumable?`<div class="fi-row">
<div class="fg"><label class="fl">Department *</label><select class="fi" id="inp-dept">
<option value="">— Select Department —</option>
${_consumableDeptOptions.map(n=>`<option value="${esc(n)}" ${_consumableDept===n?'selected':''}>${esc(n)}</option>`).join('')}
</select>${!_consumableDeptOptions.length?`<div style="font-size:10.5px;color:var(--text3);margin-top:4px">No departments available — ask an admin to assign at least one "PR Department" to your account.</div>`:''}</div>
</div>`:''}
<div class="fi-row">
<div class="fg"><label class="fl">Start Date</label><input class="fi" id="inp-start" type="date" value="${_hdr.startDate}"/></div>
<div class="fg"><label class="fl">Due Date</label><input class="fi" id="inp-due" type="date" value="${_hdr.dueDate}"/></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Planned Qty *</label><input class="fi fi-mono" id="inp-qty" type="number" min="1" step="1" value="${_hdr.qty}" ${roLock?'readonly':''} style="text-align:right;${roStyle}"/></div>
<div class="fg"><label class="fl">Remarks *</label><input class="fi" id="inp-remarks" value="${esc(_hdr.remarks)}" placeholder="${_srcConsumable?'e.g. Reason / Cost Center…':'e.g. Batch No…'}"/></div>
</div>
</div></div>
<div class="card"><div class="card-hd"><span class="ico"></span> Components<span class="right">${_lines.length} line${_lines.length!==1?'s':''}</span></div><div class="card-bd">${ch}${woHint}${addBtn}${stockWarning}</div></div>
<div class="card"><div class="card-bd"><div class="btn-bar">
<button class="btn b-planned" id="btn-planned" ${disableCreate?'disabled':''} title="${stockBlocked?'Resolve the stock shortage above, or ask an admin to turn off Require Stock Availability in System Settings':''}"><span id="sp-planned" style="display:none" class="sp"></span> ${createLabel}</button>
<button class="btn b-cancel" id="btn-reset">Cancel</button>
</div></div></div>`;
}
function wireForm(){
if(q('#cm-x'))q('#cm-x').onclick=closeCreateModal;
q('#sel-co').onchange=async function(){_co=this.value;_lines=[];_hdr.itemNo='';_hdr.desc='';_hdr.warehouse='';await loadWH();render();};
q('#sel-type').onchange=function(){_hdr.type=this.value;};
q('#inp-qty').onchange=function(){_hdr.qty=parseFloat(this.value)||1;recalcQtys();};
q('#inp-due').onchange=function(){_hdr.dueDate=this.value;};
q('#inp-start').onchange=function(){_hdr.startDate=this.value;};
q('#inp-remarks').oninput=function(){_hdr.remarks=this.value;};
if(q('#inp-dept'))q('#inp-dept').onchange=function(){_consumableDept=this.value;};
q('#btn-planned').onclick=doPlanned;
q('#btn-reset').onclick=closeCreateModal;
wireSearch();wireComp();
}
function renderCreateModal(){
const bg=document.getElementById('create-modal'),inner=document.getElementById('create-inner');
if(!bg||!inner)return;
if(!_createModalOpen){bg.classList.remove('show');return;}
bg.classList.add('show');
// Any field's onchange (header Qty → recalcQtys(), item search, warehouse
// change, etc.) still goes through the main render() → renderCreateModal(),
// which replaces #create-inner's innerHTML wholesale — a brand-new
// .modal-bd element always starts scrolled to the top. Preserve both
// scroll positions (the outer modal-bg, and the inner .modal-bd — the one
// that actually scrolls for a long Components table) across the rebuild.
const savedBgScroll=bg.scrollTop;
const savedBdScroll=inner.querySelector('.modal-bd')?.scrollTop||0;
if(_preWoStaged){
const p=_preWoStaged;
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">Pre-PWO Staged</div><button class="modal-close" id="cm-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon"></div>
<div class="suc-title">Staged — nothing sent to SAP yet</div>
<p style="font-size:13px;color:var(--text3)">${esc(p.itemCode)} — ${esc(p.itemName)}</p>
<div class="alert al-red" style="max-width:440px;margin:10px auto;text-align:left">🔒 This Pre-PWO lives only in the portal for now — it <strong>cannot</strong> be sent to SAP until Store completes their review.${CAN_SHARE_PREPWO?' Share it with Store to continue.':' Wait for someone with Share access to send it to Store.'}</div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
${CAN_SHARE_PREPWO?`<button class="btn" id="btn-prepwo-share-now" style="background:rgba(37,99,235,.08);color:#2563eb;border:1px solid rgba(37,99,235,.25)">Share with Store</button>`:''}
<button class="btn b-new" id="btn-new">Done</button>
</div>
</div></div>`;
setTimeout(()=>{
const goToList=()=>{closeCreateModal();_preWoStaged=null;_tab='prepwo';loadPrePwoList();};
if(q('#cm-x'))q('#cm-x').onclick=goToList;
if(q('#btn-new'))q('#btn-new').onclick=goToList;
if(q('#btn-prepwo-share-now'))q('#btn-prepwo-share-now').onclick=async()=>{await doSharePrePwo(p.id);goToList();};
},0);
return;
}
if(_created){
const ip=_created.status==='Planned';const canReleaseNow=ip&&CAN_RELEASE_PO;
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">Production Order — ${_created.status}</div><button class="modal-close" id="cm-x">✕</button></div>
<div class="modal-bd"><div class="suc-box">
<div class="suc-icon"></div>
<div class="suc-title">${ip?'Order Planned':'Order Released!'}</div>
<p style="font-size:13px;color:var(--text3)">${esc(_created.itemNo)} - ${esc(_created.desc)}</p>
<div class="suc-pill">Entry #${esc(String(_created.entry))} | Doc #${esc(String(_created.docNum))}</div>
<div class="alert al-green" style="max-width:400px;margin:10px auto;text-align:left">Type: <strong>${esc(_created.type)}</strong> | Qty: <strong>${_created.qty}</strong> | WH: <strong>${esc(_created.wh)}</strong> | Status: <strong>${_created.status}</strong></div>
<div class="btn-bar" style="justify-content:center;margin-top:12px">
${canReleaseNow?`<button class="btn b-release" id="btn-release"><span id="sp-release" style="display:none" class="sp"></span> Release Order</button>`:''}
<button class="btn b-new" id="btn-new">+ New Order</button>
</div>
</div></div>`;
setTimeout(()=>{if(q('#cm-x'))q('#cm-x').onclick=closeCreateModal;if(q('#btn-new'))q('#btn-new').onclick=closeCreateModal;if(canReleaseNow)q('#btn-release').onclick=doRelease;},0);
return;
}
const woTag=_srcWorkOrderId?` <span style="font-weight:400;color:var(--text3);font-size:11px">· from Work Order</span>`:'';
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">+ New ${_srcConsumable?'Consumable':'Production'} Order${woTag}</div><button class="modal-close" id="cm-x">✕</button></div>
<div class="modal-bd">${formCardsHtml()}</div>`;
wireForm();
bg.scrollTop=savedBgScroll;
const newBd=inner.querySelector('.modal-bd');
if(newBd)newBd.scrollTop=savedBdScroll;
}
// ═══════════════════════════════════════
// VIEW ORDERS TAB
// ═══════════════════════════════════════
function renderView(app){
const c1=mk('card');
c1.innerHTML=`<div class="card-hd"><span class="ico"></span> Production Orders
<span class="right"><select class="fi" id="v-co" style="height:28px;font-size:11px;width:140px;padding:2px 22px 2px 8px">${coOptions(_co)}</select></span></div>
<div class="card-bd">
<div class="fg" style="display:flex;gap:6px">
<input class="fi fi-mono" id="v-search" value="${esc(_viewSearch)}" placeholder="Search item code, description, order no, WO no or batch no..." style="font-size:12px;flex:1"/>
<button class="btn" id="v-load" style="padding:6px 16px;font-size:11px;background:var(--purple);color:#fff;white-space:nowrap">Load</button>
</div>
<div style="font-size:10px;color:var(--text3);margin:-6px 0 10px">Type to filter loaded orders, or press <b>Load</b> / Enter to search all production orders on the server.</div>
<div class="fg" style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
<span style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px">Due Date</span>
<input class="fi" id="v-datefrom" type="date" value="${esc(_viewDateFrom)}" style="font-size:12px;height:30px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="v-dateto" type="date" value="${esc(_viewDateTo)}" style="font-size:12px;height:30px"/>
${(_viewDateFrom||_viewDateTo)?`<button class="btn b-cancel" id="v-dateclear" style="padding:5px 10px;font-size:11px">✕ Clear</button>`:''}
</div>
<div style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap">
${['','Planned','Released','Closed'].map(s=>`<button class="btn" style="padding:6px 12px;font-size:11px;${_viewFilter===s?'background:var(--purple);color:#fff':'background:var(--surface2);color:var(--text3);border:1px solid var(--border2)'}" data-vf="${s}">${s||'All'}</button>`).join('')}
<button class="btn b-view" style="margin-left:auto;padding:6px 12px;font-size:11px" id="v-refresh">Refresh</button>
</div>
${CONSUMABLE_ONLY_USER
?`<div style="font-size:11px;color:var(--purple);margin-bottom:12px">Showing Consumable Orders only — you're not assigned any general Production Order approval step.</div>`
:CAN_ANY_CONSUMABLE?`<div style="display:flex;gap:4px;margin-bottom:12px;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>`:''}
<div id="v-list">${_viewLoading?'<div class="bom-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading...</div>':renderOrderList()}</div>
<div id="v-pager">${renderPager()}</div>
</div>`;
app.appendChild(c1);
setTimeout(()=>{
q('#v-co').onchange=function(){_co=this.value;loadOrders(true);};
q('#v-refresh').onclick=()=>loadOrders();
q('#v-search').oninput=function(){_viewSearch=this.value;const el=q('#v-list');if(el)el.innerHTML=renderOrderList();wirePoCards();};
q('#v-datefrom').onchange=function(){_viewDateFrom=this.value;render();};
q('#v-dateto').onchange=function(){_viewDateTo=this.value;render();};
if(q('#v-dateclear'))q('#v-dateclear').onclick=function(){_viewDateFrom='';_viewDateTo='';render();};
// Enter or the Load button runs a server-side search across ALL orders
// (resets to the first page), not just the ones already loaded.
q('#v-search').onkeydown=function(e){if(e.key==='Enter'){e.preventDefault();loadOrders(true);}};
q('#v-load').onclick=()=>loadOrders(true);
document.querySelectorAll('[data-vf]').forEach(b=>b.onclick=function(){_viewFilter=this.dataset.vf;loadOrders(true);});
document.querySelectorAll('[data-vot]').forEach(b=>b.onclick=function(){_viewOrderType=this.dataset.vot;loadOrders(true);});
q('#v-prev')&&(q('#v-prev').onclick=()=>{if(_page>0){_page--;loadOrders();}});
q('#v-next')&&(q('#v-next').onclick=()=>{if(pagerHasNext()){_page++;loadOrders();}});
wirePoCards();
},0);
}
// Whether a "Next page" exists: use the server total when known, else infer
// from a full page having come back (there may be more).
function pagerHasNext(){
if(_total!=null) return (_page+1)*_pageSize < _total;
return _orders.length >= _pageSize;
}
function renderPager(){
if(_viewLoading) 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} order${_total===1?'':'s'}`
: `Page ${_page+1}`;
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn" id="v-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="v-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>`;
}
function wirePoCards(){
document.querySelectorAll('[data-po]').forEach(c=>c.onclick=function(e){if(e.target.closest('[data-issue]')||e.target.closest('[data-receipt]')||e.target.closest('[data-close]')||e.target.closest('[data-transferfg]'))return;openDetail(parseInt(this.dataset.po));});
document.querySelectorAll('[data-issue]').forEach(b=>b.onclick=function(e){e.stopPropagation();window.location.href=`/issue-production#order=${this.dataset.issue}&company=${encodeURIComponent(_co)}`;});
document.querySelectorAll('[data-receipt]').forEach(b=>b.onclick=function(e){e.stopPropagation();window.location.href=`/receipt-production#order=${this.dataset.receipt}&company=${encodeURIComponent(_co)}`;});
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=function(e){
e.stopPropagation();window.location.href=`/close-production#order=${this.dataset.close}&company=${encodeURIComponent(_co)}`;
});
document.querySelectorAll('[data-transferfg]').forEach(b=>b.onclick=function(e){e.stopPropagation();openTransferFG(parseInt(this.dataset.transferfg));});
}
// ═══════════════════════════════════════
// TRANSFER TO FINISHED GOODS — new step, only for orders linked to a Work
// Order (see _localByEntry). No existing page handles this, so it's a
// small self-contained modal here rather than a whole new page.
// ═══════════════════════════════════════
function openTransferFG(entry){
const linked=_localByEntry[entry];
const o=_orders.find(x=>x.AbsoluteEntry===entry);
if(!linked||!o){toast('Order not found','err');return;}
const modal=document.getElementById('detail-modal'),inner=document.getElementById('modal-inner');
modal.classList.add('show');
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}">${esc(w.code)} - ${esc(w.name)}</option>`).join('');
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">Transfer to Finished Goods — ${esc(o.ItemNo)}</div><button class="modal-close" id="m-close">X</button></div>
<div class="modal-bd">
<div class="fg"><label class="fl">From Warehouse (production/receiving)</label><select class="fi" id="tfg-from">${whOpts}</select></div>
<div class="fg"><label class="fl">To Warehouse (finished goods)</label><select class="fi" id="tfg-to">${whOpts}</select></div>
<div class="fi-row">
<div class="fg"><label class="fl">Quantity</label><input class="fi fi-mono" id="tfg-qty" type="number" min="0" step="0.001" value="${o.CompletedQuantity||o.PlannedQuantity||0}" style="text-align:right"/></div>
<div class="fg"><label class="fl">Batch Number</label><input class="fi fi-mono" id="tfg-batch" value="${esc(linked.batchNumber||'')}"/></div>
</div>
<button class="btn b-planned" id="tfg-submit" style="width:100%"><span id="tfg-sp" style="display:none" class="sp"></span> Transfer</button>
</div>`;
q('#tfg-from').value=linked.warehouse||o.Warehouse||'';
q('#tfg-to').value=linked.fgWarehouse||'';
q('#m-close').onclick=()=>modal.classList.remove('show');
q('#tfg-submit').onclick=async()=>{
const from=q('#tfg-from').value,to=q('#tfg-to').value,qty=parseFloat(q('#tfg-qty').value)||0,batch=q('#tfg-batch').value.trim();
if(!from||!to){toast('From/To warehouse required','err');return;}
if(from===to){toast('From and To warehouse must differ','err');return;}
if(!qty){toast('Quantity required','err');return;}
if(!requireSapLogin())return;
const btn=q('#tfg-submit'),sp=q('#tfg-sp');btn.disabled=true;sp.style.display='inline-block';
try{
await api('POST','/sap/transfer-fg',{productionOrderId:linked.id,itemCode:o.ItemNo,quantity:qty,batchNumber:batch,fromWarehouse:from,toWarehouse:to,company:_co});
toast('Transferred to Finished Goods');modal.classList.remove('show');loadOrders();
}catch(e){toast(e.message,'err');btn.disabled=false;sp.style.display='none';}
};
}
function renderOrderList(){
let list=_orders;
if(_viewSearch.trim()){
const s=_viewSearch.trim().toUpperCase();
// Bidirectional on WoNo/BatchNumber: after Enter/Load triggers a SERVER
// search (which matches the fuller OWOR.Comments text, e.g. "SOL
// BB2609327 CPDA"), this same client-side re-filter runs again over the
// now-narrowed _orders — a plain field.includes(s) would immediately
// drop that just-fetched match again, since the clean BatchNumber shown
// ("BB2609327") doesn't contain the longer typed text. s.includes(field)
// covers that case while field.includes(s) still covers the normal
// "type a partial batch/WO no. to filter the loaded list" case.
const bidi=(field,s)=>{ field=(field||'').toUpperCase(); return !!field&&(field.includes(s)||s.includes(field)); };
list=list.filter(o=>(o.ItemNo||'').toUpperCase().includes(s)||(o.ProductDescription||'').toUpperCase().includes(s)||(String(o.DocumentNumber)||'').includes(s)||(String(o.AbsoluteEntry)||'').includes(s)||bidi(o.WoNo,s)||bidi(o.BatchNumber,s));
}
if(_viewDateFrom||_viewDateTo){
list=list.filter(o=>{
if(!o.DueDate)return false;
const d=o.DueDate.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
}
if(!list.length)return'<div class="po-empty">No production orders found</div>';
return `<div class="po-grid">${list.map(o=>{
const st=o.ProductionOrderStatus||'';
const cls=st.includes('Planned')?'planned':st.includes('Released')?'released':'closed';
const lbl=st.replace('bopos','');
const due=o.DueDate?new Date(o.DueDate).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'}):'';
const issueStatus=o._issueStatus||'';
const rcptDone=(o.CompletedQuantity||0)>=(o.PlannedQuantity||1);
// Badges — full action buttons (Issue/Receipt/Transfer/Close) now live
// only in the detail popup (openDetail), same click-to-open-popup
// pattern as Work Order, so the tile itself just needs to summarize.
const badges=[];
if(issueStatus==='full')badges.push('<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(6,182,212,.12);color:#06b6d4;border:1px solid rgba(6,182,212,.25)">ISSUED</span>');
else if(issueStatus==='partial')badges.push('<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25)">PARTIAL</span>');
if(rcptDone)badges.push('<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)">RECEIVED</span>');
if(o.IsConsumable)badges.push('<span style="font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;background:rgba(159,90,253,.14);color:var(--purple);border:1px solid rgba(159,90,253,.3)">CONSUMABLE</span>');
return`<div class="po-card tile" data-po="${o.AbsoluteEntry}">
<div class="po-top"><span class="po-code">${esc(o.ItemNo)}</span><span class="po-badge ${cls}">${lbl}</span></div>
<div class="po-icon">🏭</div>
<div class="po-name" title="${esc(o.ProductDescription||'')}">${esc(o.ProductDescription||'-')}</div>
<div class="po-badges">${badges.join('')}</div>
<div class="po-meta">
<span>#${o.DocumentNumber||o.AbsoluteEntry} · Qty <b>${o.PlannedQuantity}</b> / Done <b>${o.CompletedQuantity||0}</b></span>
${(o.WoNo||o.BatchNumber)?`<span>${o.WoNo?`WO <b>${esc(o.WoNo)}</b>`:''}${o.WoNo&&o.BatchNumber?' · ':''}${o.BatchNumber?`Batch <b>${esc(o.BatchNumber)}</b>`:''}</span>`:''}
${o.IsConsumable&&o.Department?`<span>Dept <b>${esc(o.Department)}</b></span>`:''}
<span>WH <b>${esc(o.Warehouse||'-')}</b>${due?` · Due <b>${due}</b>`:''}</span>
</div>
</div>`;
}).join('')}</div>`;
}
// Approved material quantities from the linked (QA-approved) Work Order, keyed
// by upper-case item code — for the QA "Approved vs Issued" comparison.
async function fetchWoApprovedMap(entry){
const link=_localByEntry[entry];
if(!link||!link.workOrderId)return null;
try{
const r=await api('GET',`/work-orders/${link.workOrderId}`);
const w=r.data||r;const map={};
(w.rawMaterials||[]).forEach(m=>{const c=(m.itemCode||'').toUpperCase();if(c)map[c]=parseFloat(m.qtyReq)||0;});
(w.packingMaterials||[]).forEach(m=>{const c=(m.itemCode||'').toUpperCase();if(c)map[c]=parseFloat(m.qtyReqUnits)||0;});
return map;
}catch(_e){return null;}
}
// A "Solution" container (e.g. SFG00001 "SOLUTION CPDA IP") is NEVER tracked
// under its own item code in a Work Order by design — work-order.html always
// explodes it into its raw ingredients (RM01905, etc.) instead, and the Work
// Order's Batch Size (Ltr) IS that item's approved plan. So its own code
// legitimately never appears as a key in `woApproved` — that's expected, not
// a sign of a manually-added extra, and must NOT be flagged.
function isSolutionItem(code,name){return /solution/i.test((name||'')+' '+(code||''));}
// QA comparison card: per component, BOM-Planned + Work-Order-Approved vs
// Issued, with a summary header, status pills and clear highlights.
// Two independent "extra" signals, both opt-in via optional params:
// - `inBom` (Set of upper-case codes from the product's full multi-level SAP
// BOM tree, /api/sap/bom-tree-codes/:code) — NOT IN BOM means the item
// isn't even a real ingredient anywhere in this product's recipe.
// - notInWo — no entry in `woApproved` at all (only meaningful when a Work
// Order is linked) — NOT IN WORK ORDER means it wasn't part of what was
// actually approved, regardless of whether it's a real BOM ingredient
// somewhere (e.g. a genuine sub-assembly component that was removed from
// the Work Order's material list before saving).
// A line can be flagged by either, both, or neither — Solution containers are
// exempted from the WO check specifically (see isSolutionItem above).
function buildQaComparison(lines,woApproved,inBom,supersededMap,sfgSet,sfgAlreadyMap){
const EPS=0.001,r3=n=>Math.round((Number(n)||0)*1000)/1000;
const items=(lines||[]).filter(l=>l.ItemType!=='pit_Resource');
// The Issued check only applies once something has been issued; before that
// Issued is 0 everywhere, so we show it neutrally without flagging.
const totalIssued=items.reduce((s,l)=>s+(Number(l.IssuedQuantity)||0),0);
const issuedPhase=totalIssued>EPS;
let issueMismatch=0,woMismatch=0,matchCount=0,notInWoCount=0,notInBomCount=0,supersededCount=0;
const rows=items.map((l,idx)=>{
const planned=Number(l.PlannedQuantity)||0,issued=Number(l.IssuedQuantity)||0;
const code=(l.ItemNo||'').toUpperCase();
const isSolution=isSolutionItem(l.ItemNo,l.ItemName);
const approved=woApproved&&woApproved[code]!=null?Number(woApproved[code]):null;
const notInWo=!!(woApproved&&code&&approved==null&&!isSolution);
const notInBom=!!(inBom&&code&&!inBom.has(code));
const flagged=notInWo||notInBom;
// Bucketed the same way the per-row badge color is: NOT IN BOM (whether
// alone or combined with NOT IN WORK ORDER) is the more severe "not a
// real ingredient at all" case, counted separately from a plain
// NOT IN WORK ORDER (a genuine BOM component, just removed from the WO).
if(notInBom)notInBomCount++; else if(notInWo)notInWoCount++;
// A component an approved Substitution deviation replaced, reduced down
// to (near) zero remaining — SAP has no way to actually delete the line
// (verified live), so instead of flagging it as SHORT/WO DIFF (which
// reads as a live problem), show it as a neutral, informational
// "superseded" note. Only once its own plan has actually reached ~0 —
// a partial substitution still leaving real remaining qty is flagged
// normally.
const supersededBy=supersededMap&&supersededMap[code];
const superseded=!!supersededBy&&Math.abs(planned)<=EPS;
if(superseded)supersededCount++;
const iDiff=issued-planned;
const issuedBad=!superseded&&issuedPhase&&Math.abs(iDiff)>EPS;if(issuedBad)issueMismatch++;
const woBad=!superseded&&approved!=null&&Math.abs(planned-approved)>EPS;if(woBad)woMismatch++;
if(!superseded&&issuedPhase&&!issuedBad&&!woBad)matchCount++;
const zebra=idx%2?'background:rgba(255,255,255,.015)':'';
const rowBg=superseded?'background:rgba(122,154,184,.05)':(issuedBad?'background:rgba(229,62,62,.07)':(woBad?'background:rgba(245,166,35,.07)':(notInBom?'background:rgba(234,88,12,.06)':(notInWo?'background:rgba(236,72,153,.06)':zebra))));
const accent=superseded?'inset 3px 0 0 var(--text3)':(issuedBad?'inset 3px 0 0 var(--red)':(woBad?'inset 3px 0 0 var(--amber)':(notInBom?'inset 3px 0 0 var(--extrasevere)':(notInWo?'inset 3px 0 0 var(--extra)':'inset 3px 0 0 transparent'))));
const issuedColor=!issuedPhase?'var(--text2)':(Math.abs(iDiff)<=EPS?'var(--green)':(iDiff<0?'var(--amber)':'var(--red)'));
const woCell=superseded?`<span style="color:var(--text3)">—</span>`:(approved!=null?(woBad?`<span style="display:inline-block;background:var(--amber);color:#1a1200;font-weight:700;padding:1px 8px;border-radius:5px;box-shadow:0 1px 4px rgba(245,166,35,.4)">${r3(approved)} <span style="color:var(--red)">⚠</span></span>`:`<span style="color:var(--text2)">${r3(approved)}</span>`)
:(notInWo?(notInBom?'<span style="display:inline-block;background:var(--extrasevere);color:#fff;font-weight:700;padding:1px 8px;border-radius:5px">EXTRA</span>'
:'<span style="display:inline-block;background:var(--extra);color:#fff;font-weight:700;padding:1px 8px;border-radius:5px">NOT IN WO</span>')
:(isSolution?'<span style="color:var(--text3)" title="Solution — its raw ingredients are approved on the Work Order instead">Solution</span>':'<span style="color:var(--text3)">—</span>')));
// per-row status pill
let pill;
if(superseded) pill=`<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(122,154,184,.15);color:var(--text2)" title="Replaced by ${esc(supersededBy)} via an approved Substitution deviation">SUPERSEDED</span>`;
else if(!issuedPhase) pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(122,154,184,.12);color:var(--text2)">PENDING</span>';
else if(issuedBad) pill=`<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(229,62,62,.13);color:#c81e1e">${iDiff<0?'SHORT':'OVER'} ${(iDiff>0?'+':'')+r3(iDiff)}</span>`;
else if(woBad) pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(245,166,35,.18);color:#9a6700">WO DIFF</span>';
else pill='<span style="font-size:8.5px;font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(13,187,138,.16);color:#0a8f57">MATCH</span>';
// Extra badge(s) are independent of (shown alongside) the status pill
// above — a hand-added item can simultaneously be a numeric MATCH and
// flagged (e.g. issued exactly as planned, but never WO-approved).
// Two severities, two colors: NOT IN BOM (alone, or combined with NOT IN
// WORK ORDER) means the item isn't even a real ingredient of this
// product — more severe, so it's red. NOT IN WORK ORDER alone means it
// IS a genuine BOM component, just removed from the WO's approved list
// — a real but lesser flag, stays pink.
const badgeLabel=notInBom&&notInWo?'⭑ EXTRA · NOT IN BOM & NOT IN WORK ORDER':notInBom?'⭑ NOT IN BOM':notInWo?'⭑ NOT IN WORK ORDER':'';
const badgeColor=notInBom?'var(--extrasevere)':'var(--extra)';
const badgeBg=notInBom?'rgba(234,88,12,.15)':'rgba(236,72,153,.15)';
const extraBadge=badgeLabel?`<div style="margin-top:2px"><span style="font-size:8px;font-weight:700;padding:1px 6px;border-radius:4px;background:${badgeBg};color:${badgeColor}">${badgeLabel}</span></div>`:'';
const supersededNote=superseded?`<div style="margin-top:2px;font-size:9.5px;color:var(--text3)">↦ Superseded by <span style="font-family:var(--mono);color:var(--purple)">${esc(supersededBy)}</span></div>`:'';
const td='padding:8px 10px;border-bottom:1px solid var(--border)';
const isSfg=!!(sfgSet&&sfgSet.has(code));
const already=sfgAlreadyMap&&sfgAlreadyMap[code];
const pwoBtn=!isSfg?'':already
?`<span style="font-size:9px;font-weight:700;padding:3px 8px;border-radius:5px;background:rgba(122,154,184,.12);color:var(--text2);white-space:nowrap" title="Already raised — ${esc(already.sapDocNum||'#'+already.sapAbsEntry)}">✓ PWO ${esc(String(already.sapDocNum||already.sapAbsEntry))}</span>`
:`<button class="btn-mini" data-create-pwo="${esc(l.ItemNo)}" data-create-pwo-name="${esc(l.ItemName||'')}" data-create-pwo-qty="${planned}" style="font-size:9.5px;padding:3px 8px;background:rgba(159,90,253,.1);color:var(--purple);border:1px solid rgba(159,90,253,.3);border-radius:5px;cursor:pointer;white-space:nowrap" title="Raise a separate Production Order for this SFG, required qty ${r3(planned)}">+ PWO</button>`;
return `<tr style="${rowBg}">
<td style="${td};box-shadow:${accent}"><div style="font-family:var(--mono);color:var(--purple);font-size:11px;font-weight:600">${esc(l.ItemNo)}</div><div style="font-size:10px;color:var(--text3);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(l.ItemName||'')}</div>${extraBadge}${supersededNote}</td>
<td style="${td};text-align:right;font-family:var(--mono);color:var(--text2)">${r3(planned)}</td>
<td style="${td};text-align:right;font-family:var(--mono)">${woCell}</td>
<td style="${td};text-align:right;font-family:var(--mono);font-weight:700;color:${issuedColor}">${r3(issued)}</td>
<td style="${td};text-align:right">${pill}</td>
${sfgSet?`<td style="${td};text-align:right">${pwoBtn}</td>`:''}
</tr>`;
}).join('');
// ── summary header chips ──
const chip=(txt,color,bg)=>`<span style="display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:999px;background:${bg};color:${color}">${txt}</span>`;
const chips=[chip(`${items.length} MATERIALS`,'var(--text2)','rgba(122,154,184,.12)')];
if(!issuedPhase) chips.push(chip('AWAITING ISSUANCE','#1d4ed8','rgba(26,111,255,.12)'));
else{ chips.push(chip(`✓ ${matchCount} MATCH`,'#0a8f57','rgba(13,187,138,.16)'));
if(issueMismatch) chips.push(chip(`✗ ${issueMismatch} ISSUE DIFF`,'#c81e1e','rgba(229,62,62,.13)')); }
if(woMismatch) chips.push(chip(`⚠ ${woMismatch} WO DIFF`,'#9a6700','rgba(245,166,35,.18)'));
if(notInWoCount) chips.push(chip(`⭑ ${notInWoCount} NOT IN WO`,'var(--extra)','rgba(236,72,153,.15)'));
if(notInBomCount) chips.push(chip(`⭑ ${notInBomCount} EXTRA`,'var(--extrasevere)','rgba(234,88,12,.15)'));
if(supersededCount) chips.push(chip(`↦ ${supersededCount} SUPERSEDED`,'var(--text2)','rgba(122,154,184,.12)'));
const extraTotal=notInWoCount+notInBomCount;
const statusLine=!issuedPhase
? 'Issued quantities populate after <b>Issue for Production</b>.'
: (issueMismatch>0
? `<b style="color:#c81e1e">${issueMismatch}</b> component(s) issued differently from plan${woMismatch?`, <b style="color:#9a6700">${woMismatch}</b> differ from the Work Order`:''} — review the highlighted rows.`
: (woMismatch>0
? `Issued matches plan; <b style="color:#9a6700">${woMismatch}</b> component(s) differ from the approved Work Order (amber ⚠).`
: 'Issued matches the approved BOM'+(woApproved?' and Work Order':'')+' on every component.'
+(extraTotal?` <b style="color:var(--extra)">${extraTotal}</b> component(s) were added directly on the Production Order — see ⭑ below.`:'')));
const barColor=!issuedPhase?'var(--blue)':(issueMismatch>0?'var(--red)':(woMismatch>0?'var(--amber)':'var(--green)'));
const header=`<div style="display:flex;align-items:flex-start;gap:10px;padding:11px 12px;border-bottom:1px solid var(--border);background:var(--surface3)">
<div style="width:3px;align-self:stretch;border-radius:3px;background:${barColor};flex-shrink:0"></div>
<div style="flex:1;min-width:0">
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:6px">${chips.join('')}</div>
<div style="font-size:11px;color:var(--text2);line-height:1.45">${statusLine}</div>
${woApproved?'':'<div style="font-size:10px;color:var(--text3);margin-top:4px">Work-Order comparison unavailable (no linked Work Order).</div>'}
</div>
</div>`;
const th='padding:7px 10px;color:var(--text3);font-size:8.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;border-bottom:1px solid var(--border2)';
const table=items.length?`<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--ink3,var(--surface2))">
<th style="${th};text-align:left">Item</th><th style="${th};text-align:right">BOM Planned</th><th style="${th};text-align:right">WO Approved</th><th style="${th};text-align:right">Issued</th><th style="${th};text-align:right">Status</th>${sfgSet?`<th style="${th};text-align:right">Action</th>`:''}
</tr></thead><tbody>${rows}</tbody></table></div>`
:'<div style="padding:16px;color:var(--text3);font-size:12px;text-align:center">No material components on this order.</div>';
const legend=`<div style="display:flex;gap:14px;flex-wrap:wrap;padding:8px 12px;border-top:1px solid var(--border);font-size:9px;color:var(--text3)">
<span><b style="color:#0a8f57">Match</b> issued = plan</span>
<span><b style="color:#c81e1e">Short/Over</b> issued ≠ plan</span>
<span><b style="color:#9a6700">WO Diff ⚠</b> plan ≠ Work Order</span>
<span><b style="color:var(--extra)">⭑ Not in WO</b> a real BOM component, removed from the Work Order's approved materials before saving</span>
<span><b style="color:var(--extrasevere)">⭑ Extra</b> not a real ingredient of this product anywhere in its BOM (Solutions exempt — their raw ingredients are approved instead)</span>
<span><b style="color:var(--text2)">↦ Superseded</b> replaced by an approved Substitution deviation — SAP can't delete the line itself, so it's kept at ~0 and shown informationally instead of flagged</span>
</div>`;
return `<div style="border:1px solid var(--border2);border-radius:12px;overflow:hidden;background:var(--ink2)">${header}${table}${legend}</div>`;
}
async function openDetail(entry){
const modal=document.getElementById('detail-modal'),inner=document.getElementById('modal-inner');
modal.classList.add('show');inner.innerHTML='<div style="padding:40px;text-align:center;color:var(--text3)"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading...</div>';
try{
const r=await api('GET',`/sap/production-orders/${entry}?company=${encodeURIComponent(_co)}`);
const d=r.data||r;
const st=(d.ProductionOrderStatus||'').replace('bopos','');
const lines=d.ProductionOrderLines||[];
// Once Released: a component that is itself an SFG (has its own SAP BOM,
// e.g. a sub-assembly Solution) may need its own separate Production
// Order raised before it can actually be issued here. Check each
// distinct component code for a BOM of its own, so the detail view can
// offer a one-click "+ PWO" shortcut, pre-filled with this line's own
// required qty. Only worth the round-trips once actually Released.
let sfgSet=null,sfgAlreadyMap={};
if(st==='Released'&&CAN_CREATE_PWO_FROM_COMPONENT){
sfgSet=new Set();
const codes=[...new Set(lines.filter(l=>l.ItemType!=='pit_Resource').map(l=>(l.ItemNo||'').toUpperCase()).filter(Boolean))];
if(codes.length){
try{
const hb=await api('GET',`/sap/lookup/has-bom?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
(hb.data||[]).forEach(c=>sfgSet.add(String(c).toUpperCase()));
}catch(_e){}
}
// At most one sub-PWO per (this order, component) — anything already
// raised via "+ PWO" for a component of THIS order shows as already
// created instead of letting a duplicate be raised for it again.
if(sfgSet.size){
try{
const byRef=await api('GET',`/production-orders/by-ref-parent/${entry}?company=${encodeURIComponent(_co)}`);
(byRef.data||[]).forEach(p=>{ sfgAlreadyMap[(p.itemCode||'').toUpperCase()]=p; });
}catch(_e){}
}
}
// Resource lines never carry a real qty, and Backflush lines are
// consumed by SAP itself (never manually issued via this portal) — both
// excluded, or an order with either would show as stuck PARTIAL/never
// "FULLY ISSUED" even once every actually-issuable line is done.
const issuableLines=lines.filter(l=>l.ItemType!=='pit_Resource'&&l.ProductionOrderIssueType!=='im_Backflush');
// No issuable lines at all (e.g. a Consumable Order whose only component
// is a Resource — labor/capacity, never actually issued via GenExit) is
// vacuously "fully issued": there's nothing to issue, so Receipt must
// still be reachable instead of permanently blocked by an empty array's
// falsy .length.
const fullyIssued=issuableLines.length===0||issuableLines.every(l=>(l.IssuedQuantity||0)>=(l.PlannedQuantity||1));
// Consumable Order: Release/Issue/Close each use their OWN independent
// approval step, completely separate from the general per-stage steps —
// holding production_order:release/issuance/close does NOT by itself
// grant access to a Consumable Order, and vice versa. No creator
// bypass — even the order's own creator needs the matching step.
// Mirrors routes/sap.js's canActOnProductionOrder(). There is no
// consumable Receipt at all (workflow is Release → Issue → Close only).
const canReleaseConsumable=d._localIsConsumable&&CAN_RELEASE_CONSUMABLE;
const canIssueConsumable=d._localIsConsumable&&CAN_ISSUE_CONSUMABLE;
const canCloseConsumable=d._localIsConsumable&&CAN_CLOSE_CONSUMABLE;
// QA review: BOM-Planned + Work-Order-Approved vs Issued, with highlights.
const woApproved=await fetchWoApprovedMap(entry);
// Full multi-level BOM item-code set — same lookup appendWoExtraItems()
// uses at PO-creation time — for the independent "not a real BOM
// ingredient anywhere" check (see buildQaComparison's doc comment).
let inBom=null;
try{
const bomR=await api('GET',`/sap/bom-tree-codes/${encodeURIComponent(d.ItemNo)}?company=${encodeURIComponent(_co)}`);
if(bomR.success&&Array.isArray(bomR.data))inBom=new Set(bomR.data.map(c=>(c||'').toUpperCase()));
}catch(_e){}
// Production Deviations (Shortage/Substitution/Damage) — raised AFTER
// Issue for Production, without stopping the run. Keep the order's own
// data around so the Raise Deviation modal can pick components from it.
_devOrder={entry,docNum:d.DocumentNumber||entry,itemNo:d.ItemNo,warehouse:d.Warehouse,lines};
const anyIssued=lines.some(l=>(l.IssuedQuantity||0)>0);
if(CAN_VIEW_DEVIATION)await loadDevList(entry); else _devList=[];
// Approved Substitutions → old item code (upper-case) → new item code,
// so the comparison table can show a zeroed-out replaced item as
// "Superseded" instead of flagging it as SHORT/WO DIFF.
const supersededMap={};
_devList.filter(dv=>dv.type==='SUBSTITUTION'&&dv.qaStatus==='APPROVED').forEach(dv=>{
if(dv.itemCode)supersededMap[dv.itemCode.toUpperCase()]=dv.newItemCode;
});
const compHtml=buildQaComparison(lines,woApproved,inBom,supersededMap,sfgSet,sfgAlreadyMap);
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title"><span class="po-badge ${st.toLowerCase()}">${st}</span><span style="font-family:var(--mono);color:var(--purple)">${esc(d.ItemNo)}</span><span style="color:var(--text3);font-size:11px">#${d.DocumentNumber||d.AbsoluteEntry}</span>${(st==='Released'&&fullyIssued)?'<span style="font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px;background:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)">FULLY ISSUED</span>':''}</div><button class="modal-close" id="m-close">X</button></div>
<div class="modal-bd">
<div id="tab-detail">
<div class="dt-row"><span class="dt-l">Order No</span><span class="dt-v" style="font-family:var(--mono)">${esc(String(d.DocumentNumber||'-'))}</span></div>
<div class="dt-row"><span class="dt-l">Doc Entry</span><span class="dt-v" style="font-family:var(--mono)">${esc(String(d.AbsoluteEntry||'-'))}</span></div>
<div class="dt-row"><span class="dt-l">Description</span><span class="dt-v" style="font-family:var(--font)">${esc(d.ProductDescription||'-')}</span></div>
<div class="dt-row"><span class="dt-l">Type</span><span class="dt-v">${esc((d.ProductionOrderType||'').replace('bopot',''))}</span></div>
<div class="dt-row"><span class="dt-l">Planned Qty</span><span class="dt-v">${d.PlannedQuantity}</span></div>
<div class="dt-row"><span class="dt-l">Completed</span><span class="dt-v">${d.CompletedQuantity||0}</span></div>
<div class="dt-row"><span class="dt-l">Warehouse</span><span class="dt-v">${esc(d.Warehouse||'-')}</span></div>
<div class="dt-row"><span class="dt-l">UoM</span><span class="dt-v">${esc(d.InventoryUOM||'-')}</span></div>
<div class="dt-row"><span class="dt-l">Due Date</span><span class="dt-v">${d.DueDate?new Date(d.DueDate).toLocaleDateString('en-IN'):'-'}</span></div>
<div class="dt-row"><span class="dt-l">Start Date</span><span class="dt-v">${d.StartDate?new Date(d.StartDate).toLocaleDateString('en-IN'):'-'}</span></div>
<div class="dt-row"><span class="dt-l">Remarks</span><span class="dt-v" style="font-family:var(--font)">${esc(d.Remarks||'-')}</span></div>
${d._localRefParentEntry?`<div class="dt-row"><span class="dt-l">Parent PWO</span><span class="dt-v"><a href="#" id="m-open-parent" style="font-family:var(--mono);color:var(--purple);text-decoration:underline" title="Created via the '+ PWO' shortcut off this order — click to open it">${esc(d._localRefParentItemNo||'')} #${esc(String(d._localRefParentDocNum||d._localRefParentEntry))}</a></span></div>`:''}
${(d._localRefWoNo||d._localRefBatchNumber)?`<div class="dt-row"><span class="dt-l">Ref. WO / Batch</span><span class="dt-v" style="font-family:var(--mono)" title="Created via the '+ PWO' shortcut off this Work Order/Batch — reference only, not a real link">${esc(d._localRefWoNo||'-')} / ${esc(d._localRefBatchNumber||'-')}</span></div>`:''}
${d._localIsConsumable&&d._localDepartment?`<div class="dt-row"><span class="dt-l">Department</span><span class="dt-v">${esc(d._localDepartment)}</span></div>`:''}
${d._localIsConsumable?`
<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:8px">Components</div>
${lines.length?`<div style="border:1px solid var(--border2);border-radius:12px;overflow:hidden"><table style="width:100%;border-collapse:collapse;font-size:11px">
<thead><tr style="background:var(--ink3,var(--surface2))"><th style="padding:7px 10px;text-align:left;color:var(--text3);font-size:8.5px;text-transform:uppercase">Item</th><th style="padding:7px 10px;text-align:left;color:var(--text3);font-size:8.5px;text-transform:uppercase">Type</th><th style="padding:7px 10px;text-align:right;color:var(--text3);font-size:8.5px;text-transform:uppercase">Qty</th></tr></thead>
<tbody>${lines.map(l=>`<tr><td style="padding:8px 10px;border-top:1px solid var(--border)"><div style="font-family:var(--mono);color:var(--purple);font-size:11px;font-weight:600">${esc(l.ItemNo)}</div><div style="font-size:10px;color:var(--text3)">${esc(l.ItemName||'')}</div></td><td style="padding:8px 10px;border-top:1px solid var(--border);color:var(--text2)">${l.ItemType==='pit_Resource'?'Resource':'Item'}</td><td style="padding:8px 10px;border-top:1px solid var(--border);text-align:right;font-family:var(--mono)">${l.PlannedQuantity}</td></tr>`).join('')}</tbody>
</table></div>`:'<div style="padding:16px;color:var(--text3);font-size:12px;text-align:center;border:1px solid var(--border2);border-radius:12px">No components on this order.</div>'}
`:`
<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:8px">QA — Approved vs Issued</div>
${compHtml}
`}
<div id="dev-section" style="margin-top:14px">${renderDevSection()}</div>
${anyIssued&&CAN_RAISE_DEVIATION?`<div style="margin-top:10px"><button class="btn" style="width:100%;background:rgba(236,72,153,.1);color:var(--extra);border:1px solid rgba(236,72,153,.3)" id="m-raise-dev">⚠ Raise Deviation</button></div>`:''}
${(st==='Planned'||(_localByEntry[entry]&&_localByEntry[entry].stage===0))&&(d._localIsConsumable?canReleaseConsumable:CAN_RELEASE_PO)?`<div style="margin-top:14px">${st!=='Planned'?`<div class="alert al-amber" style="margin-bottom:8px;font-size:11px">This order shows Released in SAP, but was never Released through this portal — click below to record the approval here too before continuing.</div>`:''}<button class="btn b-release" style="width:100%" id="m-release"><span id="m-rel-sp" style="display:none" class="sp"></span> Release This Order</button></div>`:''}
${(st==='Released'||st==='Closed')&&fullyIssued&&_localByEntry[entry]&&_localByEntry[entry].stage<2&&(d._localIsConsumable?canIssueConsumable:CAN_ISSUE_PO)?`<div style="margin-top:14px"><div class="alert al-amber" style="margin-bottom:8px;font-size:11px">This order shows fully issued in SAP${st==='Closed'?' (and already Closed)':''}, but Issuance was never recorded through this portal — click below to catch it up so it appears in Verify Work Order.</div><button class="btn" style="width:100%;background:rgba(6,182,212,.1);color:#06b6d4;border:1px solid rgba(6,182,212,.3)" id="m-catchup-issue"><span id="m-catchup-sp" style="display:none" class="sp"></span> Catch Up Issuance</button></div>`:''}
${st==='Released'&&!fullyIssued&&(d._localIsConsumable?canIssueConsumable:CAN_ISSUE_PO)?`<div style="margin-top:14px"><button class="btn" style="width:100%;background:rgba(6,182,212,.1);color:#06b6d4;border:1px solid rgba(6,182,212,.3)" id="m-issue">${d._localIsConsumable?'Issue Consumable':'Issue for Production'}</button></div>`:''}
${st==='Released'&&!d._localIsConsumable&&fullyIssued&&(d.CompletedQuantity||0)<(d.PlannedQuantity||1)&&CAN_RECEIPT_PO?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.3)" id="m-receipt">Receipt from Production</button></div>`:''}
${st==='Released'&&(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&_localByEntry[entry]&&_localByEntry[entry].stage===3&&CAN_TRANSFER_FG?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(159,90,253,.1);color:var(--purple);border:1px solid rgba(159,90,253,.3)" id="m-transferfg">Transfer to Finished Goods</button></div>`:''}
${(()=>{
const normalCloseOk=(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)&&(!_localByEntry[entry]||_localByEntry[entry].stage>=4)&&CAN_CLOSE_PO;
// Consumable Order workflow is Release → Issue → Close only —
// Receipt/Transfer to FG never happen, so "stage>=4"/"fully
// received" can NEVER be true for one. Anyone holding 'approve' on
// the dedicated Consumable Order — Close step can close it at
// whatever stage it's actually at (mirrors routes/sap.js's
// isConsumableCloser()). No creator bypass.
const consumableCloseOk=d._localIsConsumable&&canCloseConsumable;
if(st!=='Released'||!(normalCloseOk||consumableCloseOk||IS_PO_ADMIN_OVERRIDE))return'';
const isOverride=!normalCloseOk&&(consumableCloseOk||IS_PO_ADMIN_OVERRIDE);
return `<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.3)" id="m-close-order">Close Order${isOverride?' (Override)':''}</button></div>`;
})()}
${(st==='Planned'||st==='Released')&&(IS_PO_ADMIN_OVERRIDE||CAN_CANCEL_PO)?`<div style="margin-top:8px"><button class="btn" style="width:100%;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25)" id="m-cancel-order">✕ Cancel Order${IS_PO_ADMIN_OVERRIDE?' (Admin override)':''}</button></div>`:''}
${(d.CompletedQuantity||0)>=(d.PlannedQuantity||1)?`<div class="alert al-green" style="margin-top:8px">Receipt complete — all ${d.PlannedQuantity} qty received.</div>`:''}
</div>
</div>`;
q('#m-close').onclick=()=>modal.classList.remove('show');
if(q('#m-open-parent')){
q('#m-open-parent').onclick=(e)=>{e.preventDefault();openDetail(d._localRefParentEntry);};
}
if(st==='Released'&&!fullyIssued&&q('#m-issue')){
q('#m-issue').onclick=()=>{window.location.href=`/issue-production#order=${entry}&company=${encodeURIComponent(_co)}`;};
}
if(q('#m-catchup-issue')){
q('#m-catchup-issue').onclick=async function(){
if(!requireSapLogin())return;
const btn=this,sp=q('#m-catchup-sp');btn.disabled=true;if(sp)sp.style.display='inline-block';
try{
await api('POST',`/sap/production-order/${entry}/catchup-issuance`,{company:_co});
toast('Issuance caught up');
openDetail(entry);
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;if(sp)sp.style.display='none';}
};
}
if(q('#m-receipt')){
q('#m-receipt').onclick=()=>{window.location.href=`/receipt-production#order=${entry}&company=${encodeURIComponent(_co)}`;};
}
if(q('#m-close-order')){
q('#m-close-order').onclick=function(){
window.location.href=`/close-production#order=${entry}&company=${encodeURIComponent(_co)}`;
};
}
if(q('#m-cancel-order')){
q('#m-cancel-order').onclick=async function(){
if(!requireSapLogin())return;
if(!confirm(`Cancel Production Order ${d.ItemNo} #${d.DocumentNumber||entry}? This bypasses all normal Close checks and cannot be undone.`))return;
const remarks=prompt('Remarks (optional):')||'';
const btn=this;btn.disabled=true;
try{
await api('POST',`/sap/production-order/${entry}/cancel`,{company:_co,remarks});
toast('Production Order cancelled');
modal.classList.remove('show');
loadOrders();
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;}
};
}
if(q('#m-transferfg')){
q('#m-transferfg').onclick=function(){ modal.classList.remove('show'); openTransferFG(entry); };
}
if(q('#m-raise-dev')){
q('#m-raise-dev').onclick=openDevModal;
}
document.querySelectorAll('[data-create-pwo]').forEach(b=>{
// Batch No. isn't always tracked in our own local table (an order
// created directly in SAP, or from before local tracking existed, has
// no ZPRODUCTION_ORDERS row at all) — but the portal always writes the
// Batch No. into the SAP order's own Remarks field at creation (no
// dedicated Batch No. field exists on Production Orders), so fall back
// to that whenever the local lookup comes up empty.
const refBatch=d._localBatchNumber||d.Remarks||'';
b.onclick=()=>createPwoForComponent(b.dataset.createPwo,b.dataset.createPwoName,b.dataset.createPwoQty,d._localWoNo||'',refBatch,entry,d.DocumentNumber||entry,d.ItemNo||'');
});
wireDevSection();
if(q('#m-release')){
q('#m-release').onclick=async()=>{
if(!requireSapLogin())return;
const btn=q('#m-release'),sp=q('#m-rel-sp');btn.disabled=true;sp.style.display='inline-block';
try{await api('POST',`/sap/production-order/${entry}/release`,{company:_co});toast('Released!');modal.classList.remove('show');loadOrders();}
catch(e){toast(e.message,'err');btn.disabled=false;sp.style.display='none';}
};
}
}catch(e){inner.innerHTML=`<div style="padding:30px;text-align:center;color:var(--red)">${esc(e.message)}</div>`;}
}
// ═══════════════════════════════════════
// PRODUCTION DEVIATIONS — Shortage / Substitution / Damage, raised AFTER
// Issue for Production without stopping the run. The SAP posting (if any)
// happens immediately on raise; QA approval (admin-configurable) is a
// post-hoc sign-off on the record, never a gate on the goods movement.
// ═══════════════════════════════════════
async function loadDevList(entry){
try{const r=await api('GET',`/sap/deviations?sapAbsEntry=${entry}&company=${encodeURIComponent(_co)}`);_devList=r.data||[];}
catch(_e){_devList=[];}
}
function devTypeLabel(t){return t==='SHORTAGE'?'Shortage':t==='SUBSTITUTION'?'Substitution':t==='DAMAGE'?'Damage/Loss':t==='ADDITION'?'Addition':t;}
function devTypeColor(t){return t==='SHORTAGE'?'var(--amber)':t==='SUBSTITUTION'?'var(--extra)':t==='ADDITION'?'var(--green)':'var(--extrasevere)';}
function fmtDevDate(v){if(!v)return'—';try{return new Date(v).toLocaleString('en-IN',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return v;}}
function renderDevSection(){
if(!CAN_VIEW_DEVIATION||!_devList.length)return '';
const rows=_devList.map(dv=>{
const badge=`<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(159,90,253,.08);color:${devTypeColor(dv.type)};border:1px solid currentColor">${devTypeLabel(dv.type)}</span>`;
const itemTxt=dv.type==='SUBSTITUTION'?`${esc(dv.itemCode)} → <b>${esc(dv.newItemCode)}</b>`:esc(dv.itemCode);
const qaBadge=dv.qaStatus==='N_A'?'':(
dv.qaStatus==='PENDING'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(245,166,35,.15);color:var(--amber)">QA PENDING</span>'
:dv.qaStatus==='APPROVED'?'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(13,187,138,.15);color:var(--green)">QA APPROVED</span>'
:'<span style="font-size:8.5px;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(229,62,62,.15);color:var(--red)">QA REJECTED</span>');
const deferrable=dv.type==='SHORTAGE'||dv.type==='SUBSTITUTION'||dv.type==='ADDITION';
let postedTxt;
if(dv.postedToSap) postedTxt=`<span style="color:var(--green)">✓ Posted — Doc #${esc(String(dv.sapDocNumPosted||dv.sapDocEntry||''))}</span>`;
else if(deferrable&&dv.qaStatus==='PENDING') postedTxt=`<span style="color:var(--text3)">⏳ Pending QA approval — nothing added to the Production Order yet</span>`;
else if(deferrable&&dv.qaStatus==='APPROVED'&&dv.lineApplied) postedTxt=`<span style="color:var(--text3)">✓ Added to the Production Order — issue it via Issue for Production when ready</span>`;
else if(deferrable&&dv.qaStatus==='APPROVED'&&!dv.lineApplied&&dv.lineApplyError) postedTxt=`<span style="color:var(--red)">⚠ Approved, but could not be added to the Production Order — see below</span>`;
else if(dv.type==='DAMAGE'&&dv.postIntended&&dv.qaStatus==='APPROVED') postedTxt=`<span style="color:var(--text3)">Approved — not yet posted, use "Post to SAP" below</span>`;
else postedTxt='<span style="color:var(--text3)">Not posted to SAP</span>';
const approveBtns=(dv.qaStatus==='PENDING'&&CAN_APPROVE_DEVIATION)?`
<div style="margin-top:6px;display:flex;gap:6px">
<button class="btn-sm" data-devappr="${dv.id}" style="border-color:rgba(13,187,138,.3);color:var(--green)">✓ Approve</button>
<button class="btn-sm" data-devrej="${dv.id}" style="border-color:rgba(229,62,62,.3)">✗ Reject</button>
</div>`:'';
const postDamageBtn=(dv.type==='DAMAGE'&&dv.postIntended&&!dv.postedToSap&&(dv.qaStatus==='APPROVED'||dv.qaStatus==='N_A')&&CAN_RAISE_DEVIATION)?`
<div style="margin-top:6px"><button class="btn-sm" data-devpost="${dv.id}" style="border-color:rgba(13,187,138,.3);color:var(--green)">↑ Post to SAP (write-off)</button></div>`:'';
const retryReversalBtn=(dv.qaStatus==='REJECTED'&&!dv.reversed&&dv.reversalError&&CAN_APPROVE_DEVIATION)?`
<div style="margin-top:6px"><button class="btn-sm" data-devretry="${dv.id}" style="border-color:rgba(245,166,35,.3);color:var(--amber)">↻ Retry Reversal</button></div>`:'';
const retryApplyBtn=(deferrable&&dv.qaStatus==='APPROVED'&&!dv.lineApplied&&dv.lineApplyError&&CAN_APPROVE_DEVIATION)?`
<div style="margin-top:6px"><button class="btn-sm" data-devretryapply="${dv.id}" style="border-color:rgba(245,166,35,.3);color:var(--amber)">↻ Retry Adding to Production Order</button></div>`:'';
// Physically deleting the replaced item's line from SAP (DI API — Service
// Layer can't do this at all) is irreversible, so it's a separate,
// deliberate action from approval, gated to the higher 'approve' bar.
// The deviation record — and its "SUPERSEDED" badge in the QA comparison
// table — keeps showing regardless, for audit. Button only shown when
// Admin → System Settings → "Deviation — Show 'Remove from SAP' Button"
// is ON; when OFF, the server removes it automatically instead (right
// after the substitution is applied, whenever it's actually safe to) —
// no button needed, nothing for the user to do here.
const removeOldLineBtn=(_devShowRemoveOldLineBtn&&dv.type==='SUBSTITUTION'&&dv.qaStatus==='APPROVED'&&dv.lineApplied&&dv.oldLineNum!=null&&!dv.oldLineRemoved&&CAN_APPROVE_DEVIATION)?`
<div style="margin-top:6px"><button class="btn-sm" data-devremoveoldline="${dv.id}" style="border-color:rgba(229,62,62,.3);color:var(--red)" title="Permanently deletes ${esc(dv.itemCode)}'s line from the Production Order in SAP via DI API — cannot be undone. This deviation's own record still shows here regardless.">🗑 Remove ${esc(dv.itemCode)} from SAP</button></div>`:'';
const oldLineRemovedNote=(dv.type==='SUBSTITUTION'&&dv.oldLineRemoved)?`<div style="margin-top:3px;font-size:10px;color:var(--text3)">🗑 ${esc(dv.itemCode)}'s line was permanently removed from the Production Order in SAP${_devShowRemoveOldLineBtn?'':' (automatic)'}</div>`:'';
return `<div style="padding:9px 10px;border-bottom:1px solid var(--border);font-size:11.5px">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">${badge}<span style="font-family:var(--mono)">${itemTxt}</span><span style="color:var(--text3)">qty ${esc(String(dv.quantity))}</span>${(dv.batchNumbers&&dv.batchNumbers.length)?`<span style="color:var(--text3);font-family:var(--mono)">batch ${esc(dv.batchNumbers.map(b=>b.BatchNumber).join(', '))}</span>`:''}${qaBadge}</div>
<div style="margin-top:3px;color:var(--text2)">${esc(dv.reason)}</div>
<div style="margin-top:3px;font-size:10px;color:var(--text3)">${postedTxt} · by ${esc(dv.raisedName||dv.raisedBy)} · ${fmtDevDate(dv.raisedAt)}</div>
${(dv.qaStatus==='APPROVED'||dv.qaStatus==='REJECTED')?`<div style="margin-top:3px;font-size:10px;color:var(--text3)">QA ${dv.qaStatus.toLowerCase()} by ${esc(dv.qaName||dv.qaBy)}${dv.qaRemarks?` — ${esc(dv.qaRemarks)}`:''}</div>`:''}
${deferrable&&dv.qaStatus==='APPROVED'&&!dv.lineApplied&&dv.lineApplyError?`<div style="margin-top:3px;font-size:10px;color:var(--red)">⚠ ${esc(dv.lineApplyError)}</div>`:''}
${dv.qaStatus==='REJECTED'&&dv.postedToSap?(
dv.reversed?`<div style="margin-top:3px;font-size:10px;color:var(--green)">↩ Reverted${dv.reversalDocNum||dv.reversalDocEntry?` — Doc #${esc(String(dv.reversalDocNum||dv.reversalDocEntry))}`:''}</div>`
:dv.reversalError?`<div style="margin-top:3px;font-size:10px;color:var(--red)">⚠ Auto-reversal failed: ${esc(dv.reversalError)} — adjust stock manually in SAP</div>`
:''
):''}
${oldLineRemovedNote}
${approveBtns}
${postDamageBtn}
${retryReversalBtn}
${retryApplyBtn}
${removeOldLineBtn}
</div>`;
}).join('');
return `<div style="border:1px solid var(--border2);border-radius:10px;overflow:hidden">
<div style="padding:8px 10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);background:var(--ink3)">Deviations</div>
${rows}
</div>`;
}
function wireDevSection(){
document.querySelectorAll('[data-devappr]').forEach(b=>b.onclick=()=>actOnDeviation(+b.dataset.devappr,true));
document.querySelectorAll('[data-devrej]').forEach(b=>b.onclick=()=>openDevRejectModal(+b.dataset.devrej));
document.querySelectorAll('[data-devpost]').forEach(b=>b.onclick=()=>postDamageDeviation(+b.dataset.devpost,b));
document.querySelectorAll('[data-devretry]').forEach(b=>b.onclick=()=>retryDeviationReversal(+b.dataset.devretry,b));
document.querySelectorAll('[data-devretryapply]').forEach(b=>b.onclick=()=>retryDeviationApply(+b.dataset.devretryapply,b));
document.querySelectorAll('[data-devremoveoldline]').forEach(b=>b.onclick=()=>removeOldLine(+b.dataset.devremoveoldline,b));
}
async function removeOldLine(id,btn){
if(!requireSapLogin())return;
if(!confirm('Permanently delete this replaced item\'s line from the Production Order in SAP?\n\nThis cannot be undone — it removes the actual document line via DI API, not just its quantity. The deviation record itself will still show here for audit either way.'))return;
btn.disabled=true;btn.textContent='Removing…';
try{
await api('POST',`/sap/deviations/${id}/remove-old-line`,{company:_co});
toast('Line removed from SAP');
if(_devOrder)await loadDevList(_devOrder.entry);
const sec=q('#dev-section');if(sec){sec.innerHTML=renderDevSection();wireDevSection();}
}catch(e){toast(e.message,'err');btn.disabled=false;btn.textContent='🗑 Remove from SAP';}
}
async function retryDeviationApply(id,btn){
if(!requireSapLogin())return;
btn.disabled=true;btn.textContent='Retrying…';
try{
const r=await api('POST',`/sap/deviations/${id}/retry-apply`,{company:_co});
if(r.applyResult&&r.applyResult.success) toast('Added to the Production Order');
else toast(`Retry failed: ${r.applyResult?.message||'Unknown error'}`,'err');
if(_devOrder)await loadDevList(_devOrder.entry);
const sec=q('#dev-section');if(sec){sec.innerHTML=renderDevSection();wireDevSection();}
}catch(e){toast(e.message,'err');btn.disabled=false;btn.textContent='↻ Retry Adding to Production Order';}
}
async function retryDeviationReversal(id,btn){
if(!requireSapLogin())return;
btn.disabled=true;btn.textContent='Retrying…';
try{
const r=await api('POST',`/sap/deviations/${id}/retry-reversal`,{company:_co});
if(r.reversal&&r.reversal.success) toast(`Reversed${r.reversal.docNum?` — Doc #${r.reversal.docNum}`:''}`);
else toast(`Retry failed: ${r.reversal?.message||'Unknown error'}`,'err');
if(_devOrder)await loadDevList(_devOrder.entry);
const sec=q('#dev-section');if(sec){sec.innerHTML=renderDevSection();wireDevSection();}
}catch(e){toast(e.message,'err');btn.disabled=false;btn.textContent='↻ Retry Reversal';}
}
async function postDamageDeviation(id,btn){
if(!requireSapLogin())return;
btn.disabled=true;btn.textContent='Posting…';
try{
await api('POST',`/sap/deviations/${id}/post-damage`,{company:_co});
toast('Write-off posted to SAP');
if(_devOrder)await loadDevList(_devOrder.entry);
const sec=q('#dev-section');if(sec){sec.innerHTML=renderDevSection();wireDevSection();}
}catch(e){toast(e.message,'err');btn.disabled=false;btn.textContent='↑ Post to SAP (write-off)';}
}
async function actOnDeviation(id,approve,remarks){
try{
const r=await api('PATCH',`/sap/deviations/${id}/approve`,{approve,remarks:remarks||'',company:_co});
if(r.reversal&&r.reversal.success){
toast(`Deviation rejected — qty reversed (Doc #${r.reversal.docNum})`);
}else if(r.reversal&&!r.reversal.success){
toast(`Deviation rejected, but auto-reversal FAILED: ${r.reversal.message} — adjust stock manually in SAP`,'err');
}else if(r.applyResult&&r.applyResult.success){
toast('Deviation approved — added to the Production Order');
}else if(r.applyResult&&!r.applyResult.success){
toast(`Deviation approved, but could not be added to the Production Order: ${r.applyResult.message}`,'err');
}else{
toast(`Deviation ${approve?'approved':'rejected'}`);
}
if(_devOrder)await loadDevList(_devOrder.entry);
const sec=q('#dev-section');if(sec){sec.innerHTML=renderDevSection();wireDevSection();}
}catch(e){toast(e.message,'err');}
}
// ── Reject Deviation modal (mandatory reason, replaces the browser's native prompt()) ──
let _devRejectTargetId=null,_devRejectReasonVal='';
function openDevRejectModal(id){
_devRejectTargetId=id;_devRejectReasonVal='';
renderDevRejectModal();
const modal=document.getElementById('dev-reject-modal');
modal.classList.add('show');
setTimeout(()=>document.getElementById('dev-reject-reason-inp')?.focus(),50);
}
function closeDevRejectModal(){document.getElementById('dev-reject-modal').classList.remove('show');_devRejectTargetId=null;}
function renderDevRejectModal(){
const inner=document.getElementById('dev-reject-modal-inner');
const blank=!_devRejectReasonVal.trim();
inner.innerHTML=`
<div class="modal-hd"><div class="modal-hd-title">✗ Reject Deviation</div><button class="modal-close" id="dev-reject-modal-x">✕</button></div>
<div class="modal-bd">
<label class="fl" style="display:block;margin-bottom:6px">Reason for rejection <span style="color:var(--red)">*</span></label>
<textarea id="dev-reject-reason-inp" class="fi" rows="4" style="width:100%;height:auto;resize:vertical;font-family:inherit;padding:8px 10px" placeholder="Explain why this deviation is not accepted…">${esc(_devRejectReasonVal)}</textarea>
<div style="font-size:11px;color:var(--text3);margin-top:6px">Recorded on the deviation and shown to whoever raised it.</div>
</div>
<div class="modal-ft">
<button class="btn" style="background:var(--surface2);color:var(--text2)" id="dev-reject-modal-cancel">Cancel</button>
<button class="btn" style="background:var(--red);color:#fff" id="dev-reject-modal-confirm" ${blank?'disabled':''}>✗ Reject Deviation</button>
</div>`;
if(window.lucide&&lucide.createIcons)lucide.createIcons();
document.getElementById('dev-reject-modal-x').onclick=closeDevRejectModal;
document.getElementById('dev-reject-modal-cancel').onclick=closeDevRejectModal;
const ta=document.getElementById('dev-reject-reason-inp');
ta.oninput=function(){
_devRejectReasonVal=this.value;
document.getElementById('dev-reject-modal-confirm').disabled=!this.value.trim();
};
document.getElementById('dev-reject-modal-confirm').onclick=()=>{
if(!_devRejectReasonVal.trim())return;
const id=_devRejectTargetId;
const remarks=_devRejectReasonVal.trim();
closeDevRejectModal();
actOnDeviation(id,false,remarks);
};
}
// ── Raise Deviation modal ──
async function openDevModal(){
if(!_devOrder)return;
_devType=_devEnabledTypes.includes('SHORTAGE')?'SHORTAGE':(_devEnabledTypes[0]||'SHORTAGE');
_devItemCode='';_devNewItemCode='';_devNewItemName='';
_devWarehouse=_devOrder.warehouse||(_wh[0]?.code||'');_devDestWarehouse=_devScrapWh||'';
_devPostToSap=true;_devQty='';_devReason='';_devSubmitting=false;_devActiveMap={};
_devBatches=[];_devSelectedBatches=[];
renderDevModal();
document.getElementById('dev-modal').classList.add('show');
// Inactive/frozen SAP items reject an Issue/Transfer with "-10 Item X is
// inactive" — hide them from the component picker rather than let the
// user pick one and hit that error on submit.
const codes=[...new Set((_devOrder.lines||[]).filter(l=>l.ItemType!=='pit_Resource').map(l=>l.ItemNo).filter(Boolean))];
if(codes.length){
try{
const r=await api('GET',`/sap/lookup/item-active-map?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
_devActiveMap=r.data||{};
}catch(_e){_devActiveMap={};}
if(document.getElementById('dev-modal').classList.contains('show'))renderDevModal();
}
}
function closeDevModal(){document.getElementById('dev-modal').classList.remove('show');}
// Looks up which batch(es) of the relevant item are currently in the chosen
// warehouse — same idea AND same UX as issue-production.html's multi-batch
// flow: 0 results = not batch-managed (or no stock there) → no batch field,
// nothing sent to SAP. Otherwise every batch found is shown as its own row
// (editable quantity), FIFO-auto-allocated to cover the required Quantity —
// a single batch might not have enough on its own, so the deviation can draw
// from more than one, exactly like a normal Issue for Production. Re-run
// whenever the item, substitute item, warehouse, or quantity changes — SAP
// rejects a batch-managed item's issue/transfer with -4014 "Cannot add row
// without complete selection of batch/serial numbers" if none is supplied.
// Substitution and Addition both work off the SAME "search and pick a not-
// currently-a-component item" widget (_devNewItemCode/_devNewItemName) —
// Substitution just also has an "Affected Component" being replaced,
// Addition doesn't. Kept as one helper so every place that needs to branch
// on this stays in sync.
function devUsesNewItemPicker(){ return _devType==='SUBSTITUTION'||_devType==='ADDITION'; }
async function loadDevBatches(){
const itemCode=devUsesNewItemPicker()?_devNewItemCode:_devItemCode;
_devBatches=[];_devSelectedBatches=[];
if(!itemCode||!_devWarehouse)return;
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(itemCode)}&warehouse=${encodeURIComponent(_devWarehouse)}&company=${encodeURIComponent(_co)}`);
_devBatches=r.data||[];
if(_devBatches.length)autoSelectDevBatches();
}catch(_e){/* item not batch-managed, or lookup failed — leave no batch field */}
}
function autoSelectDevBatches(){
let rem=parseFloat(_devQty)||0;
_devSelectedBatches=[];
for(const b of _devBatches){
if(rem<=0)break;
const take=Math.min(b.Quantity,rem);
_devSelectedBatches.push({BatchNumber:b.BatchNumber,Quantity:take});
rem-=take;
}
}
function devComponentOptions(sel){
const lines=(_devOrder?.lines||[]).filter(l=>l.ItemType!=='pit_Resource'&&_devActiveMap[l.ItemNo]!==false);
return `<option value="">— select component —</option>` + lines.map(l=>{
const remaining=Math.max(0,(l.PlannedQuantity||0)-(l.IssuedQuantity||0));
return `<option value="${esc(l.ItemNo)}" data-linenum="${l.LineNumber}" data-wh="${esc(l.Warehouse||'')}" data-remaining="${remaining}" ${sel===l.ItemNo?'selected':''}>${esc(l.ItemNo)} — ${esc(l.ItemName||'')} (planned ${l.PlannedQuantity}, issued ${l.IssuedQuantity||0})</option>`;
}).join('');
}
function renderDevModal(){
const inner=document.getElementById('dev-modal-inner');
const whOpts=_wh.map(w=>`<option value="${esc(w.code)}">${esc(w.code)} — ${esc(w.name||'')}</option>`).join('');
inner.innerHTML=`
<div class="modal-hd"><div class="modal-hd-title">⚠ Raise Deviation — PO #${esc(String(_devOrder?.docNum||''))}</div><button class="modal-close" id="dev-modal-x">✕</button></div>
<div class="modal-bd">
<div class="fg"><label class="fl">Type</label>
<select class="fi" id="dev-type">
${_devEnabledTypes.map(t=>`<option value="${t}" ${_devType===t?'selected':''}>${esc(_devTypeLabels[t]||t)}</option>`).join('')}
</select>
</div>
${_devType==='ADDITION'?'':`
<div class="fg"><label class="fl">${_devType==='DAMAGE'?'Damaged Component':'Affected Component'}</label>
<select class="fi" id="dev-item">${devComponentOptions(_devItemCode)}</select>
</div>`}
${devUsesNewItemPicker()?`
<div class="fg" style="position:relative"><label class="fl">${_devType==='ADDITION'?'New Item':'Substitute Item'}</label>
<input class="fi" id="dev-newitem-search" placeholder="Search item code / name…" value="${_devNewItemCode?esc(_devNewItemCode+' — '+_devNewItemName):''}" autocomplete="off"/>
<div id="dev-newitem-dd" class="sd" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:20;max-height:200px;overflow-y:auto;background:var(--ink2);border:1px solid var(--border2);border-radius:8px;margin-top:2px"></div>
</div>`:''}
<div class="fg"><label class="fl">Warehouse${_devType==='DAMAGE'?' (where the damaged stock currently sits)':''}</label>
<select class="fi" id="dev-warehouse">${whOpts}</select>
</div>
<div class="fg"><label class="fl">Quantity</label><input class="fi" id="dev-qty" type="number" min="0.000001" step="any" value="${esc(_devQty)}"/></div>
${_devBatches.length?(()=>{
const selTotal=_devSelectedBatches.reduce((s,x)=>s+(x.Quantity||0),0);
const needed=parseFloat(_devQty)||0;
const matches=Math.abs(selTotal-needed)<=0.001;
return `<div class="fg"><label class="fl">Batch Number${_devBatches.length>1?'s':''} <span style="color:var(--red)">*</span> <span style="font-weight:400;text-transform:none;color:var(--text3)">(auto-selected — edit if needed)</span></label>
<div id="dev-batch-rows" style="border:1px solid var(--border2);border-radius:8px;overflow:hidden">
${_devBatches.map((b,bi)=>{
const sel=_devSelectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;${bi>0?'border-top:1px solid var(--border)':''}">
<span style="font-family:var(--mono);font-size:11px;flex:1">${esc(b.BatchNumber)}</span>
<span style="font-size:10px;color:var(--text3)">avail ${b.Quantity}</span>
<input class="dev-batch-inp" data-bn="${esc(b.BatchNumber)}" type="number" min="0" max="${b.Quantity}" step="0.001" value="${qty||''}" style="width:90px;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:11px;background:var(--surface2);color:var(--text);text-align:right"/>
</div>`;
}).join('')}
</div>
<div id="dev-batch-total" style="margin-top:4px;font-size:10.5px;color:${matches?'var(--green)':'var(--red)'}">Selected: ${selTotal} of ${needed} required${matches?' ✓':''}</div>
</div>`;
})():''}
${_devType==='DAMAGE'?`
<div class="fg"><label class="fl">Scrap / Destination Warehouse</label><select class="fi" id="dev-dest-warehouse">${_wh.map(w=>`<option value="${esc(w.code)}" ${_devDestWarehouse===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('')}</select></div>
`:''}
<div class="fg"><label class="fl">Reason <span style="color:var(--red)">*</span></label><textarea class="fi" id="dev-reason" rows="3" style="height:auto;resize:vertical;font-family:inherit;padding:8px 10px" placeholder="Explain what happened…">${esc(_devReason)}</textarea></div>
</div>
<div class="modal-ft">
<button class="btn" style="background:var(--surface2);color:var(--text2)" id="dev-cancel">Cancel</button>
<button class="btn" style="background:var(--extra);color:#fff" id="dev-submit" ${_devSubmitting?'disabled':''}>${_devSubmitting?'Submitting…':'Raise Deviation'}</button>
</div>`;
if(window.lucide&&lucide.createIcons)lucide.createIcons();
wireDevModal();
}
function refreshDevBatchUI(){
// Patches only the batch rows' input values + the summary line, leaving
// #dev-qty (and everything else) untouched — see feedback memory on
// avoiding full render() from an input handler.
const rows=document.getElementById('dev-batch-rows');
if(rows){
rows.querySelectorAll('.dev-batch-inp').forEach(el=>{
const sel=_devSelectedBatches.find(s=>s.BatchNumber===el.dataset.bn);
el.value=sel?(sel.Quantity||''):'';
});
}
const total=document.getElementById('dev-batch-total');
if(total){
const selTotal=_devSelectedBatches.reduce((s,x)=>s+(x.Quantity||0),0);
const needed=parseFloat(_devQty)||0;
const matches=Math.abs(selTotal-needed)<=0.001;
total.style.color=matches?'var(--green)':'var(--red)';
total.textContent=`Selected: ${selTotal} of ${needed} required${matches?' ✓':''}`;
}
}
function wireDevModal(){
document.getElementById('dev-modal-x').onclick=closeDevModal;
document.getElementById('dev-cancel').onclick=closeDevModal;
document.getElementById('dev-type').onchange=async function(){
_devType=this.value;
// Switching type changes which item field(s) are even shown — stale
// picks from the previous type would otherwise silently carry over
// (e.g. an "Affected Component" picked under Shortage staying set, but
// invisible, after switching to Addition).
_devItemCode='';_devNewItemCode='';_devNewItemName='';_devQty='';
await loadDevBatches();
renderDevModal();
};
const itemSel=document.getElementById('dev-item');
if(itemSel)itemSel.onchange=async function(){
_devItemCode=this.value;
const opt=this.options[this.selectedIndex];
if(opt&&opt.dataset.wh)_devWarehouse=opt.dataset.wh;
// Substitution replaces an item that isn't available anymore — default
// the quantity to exactly what's still remaining (unissued) on it,
// since that's the amount that needs to move to the substitute. User can
// still override if only part of it is being replaced.
if(_devType==='SUBSTITUTION'&&opt&&opt.dataset.remaining)_devQty=opt.dataset.remaining;
await loadDevBatches();
renderDevModal();
};
document.getElementById('dev-warehouse').value=_devWarehouse;
document.getElementById('dev-warehouse').onchange=async function(){_devWarehouse=this.value;await loadDevBatches();renderDevModal();};
document.getElementById('dev-qty').oninput=function(){
_devQty=this.value;
if(_devBatches.length){autoSelectDevBatches();refreshDevBatchUI();} // surgical patch only — a full renderDevModal() here would steal focus from this input mid-keystroke
};
document.getElementById('dev-reason').oninput=function(){_devReason=this.value;};
const destEl=document.getElementById('dev-dest-warehouse');
if(destEl)destEl.onchange=function(){_devDestWarehouse=this.value;};
document.querySelectorAll('.dev-batch-inp').forEach(el=>{
el.onchange=function(){
const bn=this.dataset.bn;
const qty=parseFloat(this.value)||0;
const existing=_devSelectedBatches.find(x=>x.BatchNumber===bn);
if(existing)existing.Quantity=qty;
else if(qty>0)_devSelectedBatches.push({BatchNumber:bn,Quantity:qty});
_devSelectedBatches=_devSelectedBatches.filter(x=>x.Quantity>0);
renderDevModal();
};
});
const newItemInp=document.getElementById('dev-newitem-search');
if(newItemInp){
let t=null;
newItemInp.oninput=function(){
_devNewItemCode='';_devNewItemName='';
const v=this.value.trim();const dd=document.getElementById('dev-newitem-dd');
if(v.length<2){dd.style.display='none';return;}
clearTimeout(t);
t=setTimeout(async()=>{
const items=await fetchItems(v,true); // activeOnly — a substitute must actually be usable in SAP
if(!items.length){dd.innerHTML='<div class="sd-msg">No items found</div>';dd.style.display='block';return;}
dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';row.style.cssText='padding:6px 10px;cursor:pointer';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span> <span class="sd-name">${esc(it.ItemName)}</span>`;
row.onmousedown=async e=>{e.preventDefault();_devNewItemCode=it.ItemCode;_devNewItemName=it.ItemName;newItemInp.value=`${it.ItemCode} — ${it.ItemName}`;dd.style.display='none';await loadDevBatches();renderDevModal();};
dd.appendChild(row);
});
dd.style.display='block';
},250);
};
newItemInp.onblur=()=>setTimeout(()=>{const dd=document.getElementById('dev-newitem-dd');if(dd)dd.style.display='none';},150);
}
document.getElementById('dev-submit').onclick=submitDeviation;
}
async function submitDeviation(){
if(!_devOrder)return;
// Addition has no "Affected Component" at all — it's a brand-new item, so
// its own itemCode comes from the New Item search picker instead (see
// devUsesNewItemPicker()/the payload building below), never this dropdown.
if(_devType!=='ADDITION'&&!_devItemCode){toast('Select the affected component','err');return;}
const qty=parseFloat(_devQty)||0;
if(qty<=0){toast('Quantity must be > 0','err');return;}
if(!_devWarehouse){toast('Select a warehouse','err');return;}
if(!_devReason.trim()){toast('A reason is required','err');return;}
if(devUsesNewItemPicker()&&!_devNewItemCode){toast(_devType==='ADDITION'?'Search and pick the new item':'Search and pick a substitute item','err');return;}
if(_devType==='DAMAGE'&&!_devDestWarehouse){toast('Select a scrap/destination warehouse','err');return;}
// Item is batch-managed — the selected batches must add up to the full
// quantity being posted (same rule as Issue for Production).
if(_devBatches.length){
const selTotal=_devSelectedBatches.reduce((s,x)=>s+(x.Quantity||0),0);
if(Math.abs(selTotal-qty)>0.001){toast(`Batch quantities (${selTotal}) must add up to the quantity (${qty})`,'err');return;}
}
const itemOpt=document.getElementById('dev-item');
const selOpt=itemOpt?itemOpt.options[itemOpt.selectedIndex]:null;
const lineNum=selOpt?selOpt.dataset.linenum:null;
// Addition's own item IS the New Item picker's pick — there's no existing
// Production Order line to read a name off of, unlike every other type.
const itemCode=_devType==='ADDITION'?_devNewItemCode:_devItemCode;
const itemName=_devType==='ADDITION'?_devNewItemName:((_devOrder.lines||[]).find(l=>l.ItemNo===_devItemCode)?.ItemName||'');
if(!requireSapLogin())return;
_devSubmitting=true;renderDevModal();
try{
const payload={
company:_co, branchId:2,
sapAbsEntry:_devOrder.entry, sapDocNum:_devOrder.docNum,
type:_devType, itemCode, itemName, lineNum,
newItemCode:_devType==='SUBSTITUTION'?_devNewItemCode:undefined,
newItemName:_devType==='SUBSTITUTION'?_devNewItemName:undefined,
quantity:qty, warehouse:_devWarehouse,
destWarehouse:_devType==='DAMAGE'?_devDestWarehouse:undefined,
postToSap:_devType==='DAMAGE'?_devPostToSap:undefined,
batchNumbers:_devSelectedBatches.length?_devSelectedBatches:undefined,
reason:_devReason.trim(),
};
await api('POST','/sap/deviations',payload);
toast('Deviation raised');
closeDevModal();
await openDetail(_devOrder.entry); // refresh the order detail + deviation list
}catch(e){_devSubmitting=false;renderDevModal();toast(e.message,'err');}
}
// ═══════════════════════════════════════
// SEARCH + BOM AUTO-LOAD
// ═══════════════════════════════════════
let _hst=null;
function wireSearch(){
const inp=q('#inp-item'),dd=q('#item-dd');if(!inp||!dd)return;
if(!CAN_MANUAL_CREATE_PO&&!_srcConsumable)return; // product entry is locked to Work-Order selection for these users
inp.oninput=function(){
_hdr.itemNo=this.value.toUpperCase();const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_hst);
_hst=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v,true,_srcConsumable?CONSUMABLE_ITEM_GROUP:(_manualPoGroups.length?_manualPoGroups:null));
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row';
r.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
r.onclick=()=>{selectProduct(it.ItemCode,it.ItemName);dd.style.display='none';};dd.appendChild(r);
});
},250);
};
document.addEventListener('click',e=>{if(!inp.parentElement.contains(e.target))dd.style.display='none';});
}
// "+ PWO" shortcut from a Released order's component table (an SFG line that
// has its own BOM) — jumps straight to Manual Entry, pre-loads that item's
// own BOM, and pre-fills the header Qty with THIS line's required qty, so
// the sub-assembly can be produced as its own standalone Production Order.
async function createPwoForComponent(itemCode,itemName,qty,refWoNo,refBatchNumber,parentEntry,parentDocNum,parentItemNo){
if(!CAN_CREATE_PWO_FROM_COMPONENT){toast('You are not permitted to create a Production Order from a component.','err');return;}
const dm=document.getElementById('detail-modal');if(dm)dm.classList.remove('show');
resetForm();
_tab='create';
_srcMode='manual';
_srcFromComponent=true;
_srcFromComponentInfo={entry:parentEntry,docNum:parentDocNum||parentEntry,itemNo:parentItemNo||''};
_returnToViewOnClose=true;
_refWoNo=refWoNo||'';
_refBatchNumber=refBatchNumber||'';
_hdr.qty=parseFloat(qty)||1;
// Same Batch No. as the parent order (Remarks has no dedicated Batch No.
// field — same convention selectSourceWorkOrder() uses for the WO-linked
// flow), so it's carried forward here too instead of left blank.
if(_refBatchNumber)_hdr.remarks=_refBatchNumber;
openCreateModal();
await selectProduct(itemCode,itemName||'');
}
async function selectSourceWorkOrder(id){
const w=_srcWorkOrders.find(x=>x.id===id);if(!w)return;
_srcWorkOrderId=id;
_srcBatchNumber=w.batchNumber||'';
_hdr.qty=parseFloat(w.totalUnits)||1;
// Batch No. has no dedicated field on the Production Order form — carry
// it into Remarks (which is now required) so it's never lost/invisible.
// Just the value, no label prefix.
if(_srcBatchNumber)_hdr.remarks=_srcBatchNumber;
_createModalOpen=true; // open the form in the popup
await selectProduct(w.productCode||'',w.productName||''); // auto-fills item + triggers BOM load (then render() → modal)
applyWoApprovedQty(w); // items in BOTH BOM and WO → use the WO's approved qty
await appendWoExtraItems(w); // + hand-added WO items not in the SAP BOM
}
// Items present BOTH in this product's SAP BOM (already loaded into _lines by
// selectProduct) AND in the Work Order's approved material list get their
// Planned Qty OVERRIDDEN to the Work Order's exact approved value, instead of
// SAP's plain BOM-ratio calculation (baseQty × header Qty) — the WO's qty
// already reflects real Ovg%/measured values approved for this specific
// batch, which can legitimately differ from a straight ratio scale. Marked
// woFixed so recalcQtys() never overwrites it again on a header Qty edit,
// same treatment as appendWoExtraItems()'s hand-added extras.
function applyWoApprovedQty(w){
const approved={};
(w.rawMaterials||[]).forEach(r=>{
const c=(r.itemCode||'').trim().toUpperCase();
const q=parseFloat(r.qtyReq);
if(c&&!isNaN(q))approved[c]=q;
});
(w.packingMaterials||[]).forEach(r=>{
const c=(r.itemCode||'').trim().toUpperCase();
const q=parseFloat(r.qtyReqUnits);
if(c&&!isNaN(q))approved[c]=q;
});
let overridden=0;
_lines.forEach(l=>{
const c=(l.itemNo||'').trim().toUpperCase();
if(!c||approved[c]==null)return;
l.plannedQty=approved[c];
l.woFixed=true;
overridden++;
});
if(overridden)toast(`${overridden} component${overridden>1?'s':''} set to the Work Order's approved quantity`);
}
// WO line items that are NOT found anywhere in the product's SAP BOM (checked
// against the FULL multi-level tree, so Solution ingredients nested inside an
// SFG's own BOM are correctly treated as "in the BOM" and skipped) are the
// user's hand-added extras — carry them into the Production Order with the
// SAME quantity as entered on the WO. Approved 2026-07-22. woFixed lines keep
// that exact quantity: recalcQtys() never rescales them with Planned Qty.
async function appendWoExtraItems(w){
if(_hdr.type==='bopotSpecial')return;
const rows=[...(w.rawMaterials||[]),...(w.packingMaterials||[])];
const cand=[];const seen=new Set();
rows.forEach(r=>{
const code=(r.itemCode||'').trim().toUpperCase();
if(!code||seen.has(code))return;seen.add(code);
const qtyRaw=(r.qtyReq!==undefined&&r.qtyReq!=='')?r.qtyReq:r.qtyReqUnits;
const qty=parseFloat(qtyRaw);
cand.push({code,name:(r.rawMaterial||r.packingMaterial||'').trim(),qty:isNaN(qty)?0:qty});
});
if(!cand.length)return;
_woExtrasLoading=true;render(); // visible "checking…" hint in the Components card
// Fast path: ONE server query walks the whole BOM tree (ITT1 recursive CTE).
// Fallback: the old per-node Service-Layer walk if the endpoint fails.
let inBom=null;
try{
const r=await api('GET',`/sap/bom-tree-codes/${encodeURIComponent(_hdr.itemNo)}?company=${encodeURIComponent(_co)}`);
if(r.success&&Array.isArray(r.data))inBom=new Set(r.data.map(c=>(c||'').toUpperCase()));
}catch(_e){}
if(!inBom){inBom=new Set();await collectBomCodes(_hdr.itemNo,inBom,0,new Set());}
const have=new Set(_lines.map(l=>(l.itemNo||'').toUpperCase()));
let added=0;
cand.forEach(c=>{
if(inBom.has(c.code)||have.has(c.code)||c.qty<=0)return;
_lines.push({type:'Item',itemType:'pit_Item',itemNo:c.code,desc:c.name,
baseQty:Math.round(c.qty/(_hdr.qty||1)*1e6)/1e6,plannedQty:c.qty,
warehouse:_hdr.warehouse||'',issueMethod:'Manual',distrRule:'',woFixed:true});
added++;
});
_woExtrasLoading=false;
if(added)toast(`Added ${added} extra item${added>1?'s':''} from the Work Order (not in SAP BOM)`);
render();
}
// Collect every item code found anywhere in a product's multi-level BOM tree
// (recursing into each line's own BOM; leaves return no tree and stop).
async function collectBomCodes(code,set,depth,visited){
code=(code||'').toUpperCase();
if(!code||depth>6||visited.has(code))return;
visited.add(code);
const bom=await fetchBOM(code);
if(!bom?.ProductTreeLines)return;
const next=[];
for(const l of bom.ProductTreeLines){
const cc=(l.ItemCode||'').toUpperCase();
if(!cc)continue;
set.add(cc);next.push(cc);
}
await Promise.all(next.map(cc=>collectBomCodes(cc,set,depth+1,visited)));
}
async function selectProduct(code,name){
_hdr.itemNo=code;_hdr.desc=name;
// Skip BOM fetch for Special type — user adds components manually
if(_hdr.type==='bopotSpecial'){
_lines=[];render();
toast('Special type — add components manually');
return;
}
_bomLoading=true;_lines=[];render();
const bom=await fetchBOM(code);_bomLoading=false;
if(bom?.ProductTreeLines?.length){
if(bom.Warehouse&&!_hdr.warehouse)_hdr.warehouse=bom.Warehouse;
// Req qty as per actual BOM calculation (same as SAP B1 itself): a BOM can
// be defined for a base qty ≠ 1 (ProductTrees.Quantity, e.g. per 100 units),
// so per-unit consumption = line qty ÷ BOM base qty. baseQty is stored
// normalized per 1 unit, so plannedQty = baseQty × Planned Qty everywhere
// (incl. recalcQtys on qty change). Approved 2026-07-22 — PO creation only,
// the Work Order page's own tables/calculations are intentionally untouched.
const bomBase=parseFloat(bom.Quantity)||1;
_lines=bom.ProductTreeLines.map(l=>({
type:l.ItemType==='pit_Resource'?'Resource':'Item',itemType:l.ItemType||'pit_Item',
itemNo:l.ItemCode||'',desc:l.ItemName||l.ItemDescription||'',
baseQty:(l.Quantity||1)/bomBase,
plannedQty:Math.round(((l.Quantity||1)/bomBase)*(_hdr.qty||1)*1000)/1000,
warehouse:l.Warehouse||bom.Warehouse||'',
issueMethod:l.IssueMethod==='im_Backflush'?'Backflush':'Manual',
distrRule:l.DistributionRule||''
}));
toast(`Loaded ${_lines.length} components from BOM`);
}else{_lines=[];toast('No BOM found for '+code,'err');}
render();
loadStockForLines();
}
// "Available Qty" shown next to each component — on-hand stock (OITW),
// grouped/fetched per distinct warehouse among the current lines (usually
// just one call). Non-blocking: patches _stockMap then re-renders; never
// gates anything by itself — System Settings → "Require stock availability
// for PWO creation" controls whether a shortage actually blocks Save, and
// the server independently re-verifies regardless (see POST /production-order).
let _stockMap={}; // `${itemCode}|${warehouse}` -> on-hand qty
async function loadStockForLines(){
const groups={};
_lines.forEach(l=>{
if(!l.itemNo||l.type==='Resource')return;
const wh=l.warehouse||_hdr.warehouse||'';
(groups[wh]=groups[wh]||new Set()).add(l.itemNo.trim().toUpperCase());
});
const entries=Object.entries(groups);
if(!entries.length)return;
await Promise.all(entries.map(async([wh,codesSet])=>{
const codes=[...codesSet];
try{
const r=await api('GET',`/sap/lookup/item-stock?codes=${encodeURIComponent(codes.join(','))}${wh?'&warehouse='+encodeURIComponent(wh):''}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data)codes.forEach(c=>{_stockMap[`${c}|${wh}`]=r.data[c]||0;});
}catch(_e){/* non-fatal — Available Qty just stays blank for these */}
}));
render();
}
function stockFor(l){
const wh=l.warehouse||_hdr.warehouse||'';
const key=`${(l.itemNo||'').trim().toUpperCase()}|${wh}`;
return key in _stockMap ? _stockMap[key] : null;
}
function recalcQtys(){_lines.forEach(l=>{if(l.woFixed)return;/* hand-added WO extras keep their exact WO quantity */l.plannedQty=Math.round(l.baseQty*(_hdr.qty||1)*1000)/1000;});render();}
function mkBlankLine(){return{type:'Item',itemType:'pit_Item',itemNo:'',desc:'',baseQty:1,plannedQty:_hdr.qty||1,warehouse:_hdr.warehouse||'',issueMethod:'Manual',distrRule:''};}
function wireComp(){
// Base Qty ↔ Planned Qty is a two-way pair (Planned Qty = Base Qty × header
// Planned Qty): editing either one recalculates the other, instead of only
// Base Qty → Planned Qty working (via recalcQtys() on a header Qty change).
// Surgically patch the sibling field's DOM value instead of calling the
// full render() — a full re-render destroys/recreates every input on the
// page, which drops focus and made the browser jump the scroll position
// back to the top on every edit (confusing on a long component list).
document.querySelectorAll('.comp-inp').forEach(el=>{el.onchange=function(){
const i=parseInt(this.dataset.i),f=this.dataset.f;
if(f==='warehouse'){_lines[i][f]=this.value;loadStockForLines();return;}
const val=parseFloat(this.value)||0;
_lines[i][f]=val;
const hq=_hdr.qty||1;
let otherField=null;
if(f==='baseQty'){_lines[i].plannedQty=Math.round(val*hq*1000)/1000;otherField='plannedQty';}
else if(f==='plannedQty'){_lines[i].baseQty=Math.round((val/hq)*1e6)/1e6;otherField='baseQty';}
if(otherField){
const other=document.querySelector(`.comp-inp[data-i="${i}"][data-f="${otherField}"]`);
if(other)other.value=_lines[i][otherField];
}
};});
// Add component button
const ab=document.getElementById('btn-add-comp');
if(ab) ab.onclick=()=>{_lines.push(mkBlankLine());render();};
// Remove buttons
document.querySelectorAll('[data-rm]').forEach(b=>{b.onclick=function(e){e.stopPropagation();const i=parseInt(this.dataset.rm),l=_lines[i];if(l&&(l.itemNo||'').trim()&&!confirm('Remove this component?'))return;_lines.splice(i,1);render();};});
// Component item search
let _cst={};
document.querySelectorAll('[data-ci]').forEach(inp=>{
inp.oninput=function(){
const i=parseInt(this.dataset.ci),v=this.value.trim();
_lines[i].itemNo=this.value.toUpperCase();
const dd=document.getElementById('cdd-'+i);if(!dd)return;
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_cst[i]);
_cst[i]=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v,true);
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row';
r.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
r.onclick=()=>{_lines[i].itemNo=it.ItemCode;_lines[i].desc=it.ItemName;_lines[i].baseQty=1;_lines[i].plannedQty=_hdr.qty||1;dd.style.display='none';render();loadStockForLines();};
dd.appendChild(r);
});
},250);
};
});
document.addEventListener('click',e=>{if(!e.target.closest('.search-wrap'))document.querySelectorAll('.comp-dd').forEach(d=>d.style.display='none');});
}
// ═══════════════════════════════════════
// SUBMIT PLANNED
// ═══════════════════════════════════════
async function doPlanned(){
// From-WO-only users (no manual-create permission) must originate the order
// from a selected approved Work Order — never a manual/standalone entry.
if(!CAN_MANUAL_CREATE_PO&&!_srcWorkOrderId&&!_srcFromComponent&&!_srcConsumable){toast('You can only create a Production Order from an approved Work Order — select one above.','err');return;}
if(_srcConsumable&&!CAN_CREATE_CONSUMABLE){toast('You are not permitted to create a Consumable Order.','err');return;}
if(_srcConsumable&&!_consumableDept){toast('Department required','err');return;}
if(!_hdr.itemNo.trim()){toast('Product No. required','err');return;}
if(!_hdr.remarks.trim()){toast('Remarks required','err');return;}
// Consumable Orders are standalone Service items with no BOM of their own
// — zero components is the normal, expected case, not an error.
if(!_lines.length&&_hdr.type!=='bopotSpecial'&&!_srcConsumable){toast('No components','err');return;}
// Client-side pre-check using whatever's already loaded in _stockMap — a
// friendlier, earlier message than waiting on the round trip. Never
// authoritative by itself (stock can move between now and Save, and this
// only reflects the last fetch) — the server independently re-verifies
// against OITW regardless (see POST /sap/production-order).
if(_reqStockAvailability&&!_srcConsumable){
const short=_lines.filter(l=>l.itemNo&&l.type!=='Resource').map(l=>({l,avail:stockFor(l)})).filter(x=>x.avail!=null&&x.avail<(parseFloat(x.l.plannedQty)||0)-0.0001);
if(short.length){
toast(`Insufficient stock for: ${short.map(x=>`${x.l.itemNo} (need ${x.l.plannedQty}, have ${x.avail})`).join('; ')}`,'err');
return;
}
}
// Pre-PWO Store Review (only applies to from-WO orders, per the feature's
// scope — manual/standalone orders are unaffected): stage in the portal
// instead of writing to SAP immediately.
if(_preWoReviewEnabled&&_srcWorkOrderId){await doStagePrePwo();return;}
if(!requireSapLogin())return;
const btn=q('#btn-planned'),sp=q('#sp-planned');btn.disabled=true;sp.style.display='inline-block';
// Captured ONCE and reused for both calls below (the SAP create, then the
// local stage-tracking link) — _co is a live global the user can change
// from the Company dropdown at any time, including during the first
// call's network round trip. Reading it fresh for the second call let the
// two writes land under DIFFERENT companies if the user switched company
// mid-submit: the real SAP order under the old company, the local
// ZPRODUCTION_ORDERS tracking row under the new one — invisible to every
// company-scoped list/lookup afterward even though creation itself
// "succeeded" (confirmed live: a Consumable Order whose local row ended up
// in a different database than its real SAP document).
const submitCo=_co;
const payload={company:submitCo,ItemNo:_hdr.itemNo.trim().toUpperCase(),PlannedQuantity:parseFloat(_hdr.qty)||1,ProductionOrderStatus:'boposPlanned',ProductionOrderType:_hdr.type};
// Tells the server which of the two Create permissions applies (from-WO
// vs standalone) — stripped before the payload is forwarded to SAP.
if(_srcWorkOrderId)payload.workOrderId=_srcWorkOrderId;
if(_srcFromComponent){payload.fromComponent=true;if(_srcFromComponentInfo?.entry!=null)payload.refParentEntry=_srcFromComponentInfo.entry;}
if(_srcConsumable)payload.fromConsumable=true;
if(_hdr.warehouse)payload.Warehouse=_hdr.warehouse;
if(_hdr.dueDate)payload.DueDate=_hdr.dueDate;
if(_hdr.startDate)payload.StartDate=_hdr.startDate;
if(_hdr.postingDate)payload.PostingDate=_hdr.postingDate;
if(_hdr.remarks.trim())payload.Remarks=_hdr.remarks.trim();
payload.ProductionOrderLines=_lines.filter(l=>l.itemNo.trim()).map((l,idx)=>{
const line={ItemNo:l.itemNo.trim().toUpperCase(),BaseQuantity:parseFloat(l.baseQty)||1,PlannedQuantity:parseFloat(l.plannedQty)||0,ProductionOrderIssueType:l.issueMethod==='Backflush'?'im_Backflush':'im_Manual',ItemType:l.itemType||'pit_Item',VisualOrder:idx};
if(l.warehouse)line.Warehouse=l.warehouse;if(l.distrRule)line.DistributionRule=l.distrRule;
return line;
});
try{
const r=await api('POST','/sap/production-order',payload);
_created={entry:r.data?.AbsoluteEntry||'',docNum:r.data?.DocumentNumber||'',status:'Planned',itemNo:_hdr.itemNo,desc:_hdr.desc,qty:_hdr.qty,wh:_hdr.warehouse,type:PROD_TYPES.find(t=>t.v===_hdr.type)?.l||'Standard'};
// Register the local tracking record so this order's stage (Release →
// Issuance → Receipt → Transfer to FG → Close) gets tracked — a plain
// SAP OWOR knows nothing about this, only our own table does. Done for
// BOTH origins (from-WO AND manual): without it, manual orders' later
// stages never show in the pending-actions bell.
if(_created.entry){
try{
await api('POST','/production-orders',{
workOrderId:_srcWorkOrderId||null, sapAbsEntry:_created.entry, sapDocNum:_created.docNum,
itemCode:_hdr.itemNo, itemName:_hdr.desc, plannedQty:_hdr.qty,
batchNumber:_srcBatchNumber, warehouse:_hdr.warehouse, company:submitCo,
refWoNo:_refWoNo, refBatchNumber:_refBatchNumber,
fromComponent:_srcFromComponent, refParentEntry:_srcFromComponentInfo?.entry||null,
fromConsumable:_srcConsumable, department:_srcConsumable?_consumableDept:'',
});
}catch(e){ toast('Order created in SAP, but stage-tracking registration failed: '+e.message,'err'); }
}
toast('Production Order created (Planned)');render();
}catch(e){toast(e.message||'Failed','err');btn.disabled=false;sp.style.display='none';}
}
// ═══════════════════════════════════════
// PRE-PWO — STORE REVIEW BEFORE SAP
// ═══════════════════════════════════════
async function doStagePrePwo(){
const btn=q('#btn-planned'),sp=q('#sp-planned');if(btn)btn.disabled=true;if(sp)sp.style.display='inline-block';
const lines=_lines.filter(l=>l.itemNo.trim()).map(l=>({
type:l.type||'Item', itemType:l.itemType||'pit_Item', itemNo:l.itemNo.trim().toUpperCase(), desc:l.desc||'',
baseQty:parseFloat(l.baseQty)||1, plannedQty:parseFloat(l.plannedQty)||0,
warehouse:l.warehouse||'', issueMethod:l.issueMethod||'Manual', distrRule:l.distrRule||'',
}));
try{
const r=await api('POST','/pre-production-orders',{
workOrderId:_srcWorkOrderId, itemCode:_hdr.itemNo.trim().toUpperCase(), itemName:_hdr.desc,
plannedQty:_hdr.qty, batchNumber:_srcBatchNumber, warehouse:_hdr.warehouse,
lines, remarks:_hdr.remarks.trim(), company:_co,
});
_preWoStaged=r.data;
toast('Pre-PWO staged — nothing sent to SAP yet');render();
}catch(e){toast(e.message||'Failed','err');if(btn)btn.disabled=false;if(sp)sp.style.display='none';}
}
async function doSharePrePwo(id){
try{ await api('POST',`/pre-production-orders/${id}/share-with-store`,{}); toast('Shared with Store'); }
catch(e){ toast(e.message,'err'); }
}
// Builds the exact same SAP payload doPlanned() would, but sourced from a
// staged Pre-PWO's current lines (whatever Store reverted, or Production's
// own edits) instead of the live create form. Reuses the SAME two existing
// endpoints (/sap/production-order, /production-orders) unchanged, then
// locks the Pre-PWO via mark-converted.
async function doPushPrePwoToSap(id){
if(!requireSapLogin())return;
let pre;
try{ const r=await api('GET',`/pre-production-orders/${id}`); pre=r.data; }
catch(e){ toast(e.message,'err'); return; }
if(!pre||pre.status==='CONVERTED'){ toast('Already pushed to SAP','err'); await loadPrePwoList(); return; }
if(_preWoReviewEnabled&&pre.reviewStage!==2){ toast('Store Review is required before this can be sent to SAP','err'); return; }
const payload={
company:pre.company||_co, ItemNo:(pre.itemCode||'').trim().toUpperCase(),
PlannedQuantity:parseFloat(pre.plannedQty)||1, ProductionOrderStatus:'boposPlanned',
ProductionOrderType:'bopotStandard', workOrderId:pre.workOrderId,
};
if(pre.warehouse)payload.Warehouse=pre.warehouse;
if(pre.remarks)payload.Remarks=pre.remarks;
payload.ProductionOrderLines=(pre.lines||[]).filter(l=>l.itemNo&&l.itemNo.trim()).map((l,idx)=>{
const line={ItemNo:l.itemNo.trim().toUpperCase(),BaseQuantity:parseFloat(l.baseQty)||1,PlannedQuantity:parseFloat(l.plannedQty)||0,ProductionOrderIssueType:l.issueMethod==='Backflush'?'im_Backflush':'im_Manual',ItemType:l.itemType||'pit_Item',VisualOrder:idx};
if(l.warehouse)line.Warehouse=l.warehouse;if(l.distrRule)line.DistributionRule=l.distrRule;
return line;
});
try{
const r=await api('POST','/sap/production-order',payload);
const entry=r.data?.AbsoluteEntry||'',docNum=r.data?.DocumentNumber||'';
let localId=null;
if(entry){
try{
const linkRes=await api('POST','/production-orders',{
workOrderId:pre.workOrderId, sapAbsEntry:entry, sapDocNum:docNum,
itemCode:pre.itemCode, itemName:pre.itemName, plannedQty:pre.plannedQty,
batchNumber:pre.batchNumber, warehouse:pre.warehouse, company:pre.company||_co,
});
localId=linkRes.data?.id;
}catch(e){ toast('Order created in SAP, but stage-tracking registration failed: '+e.message,'err'); }
}
if(localId){
try{ await api('POST',`/pre-production-orders/${id}/mark-converted`,{convertedPoId:localId}); }
catch(e){ toast('SAP order created, but Pre-PWO lock failed: '+e.message,'err'); }
}
toast(`Production Order created in SAP (Doc #${docNum})`);
closePrePwoDetail();_tab='view';await loadOrders();
}catch(e){ toast(e.message||'Failed','err'); }
}
async function loadPrePwoList(){
_preWoListLoading=true;render();
try{ const r=await api('GET','/pre-production-orders'); _preWoList=r.data||[]; }
catch(e){ toast(e.message,'err'); _preWoList=[]; }
_preWoListLoading=false;_preWoListLoaded=true;render();
}
function prePwoStageLabel(p){
if(p.status==='CONVERTED')return '✓ Pushed to SAP';
if(p.reviewStage===1)return '⇄ With Store';
if(p.reviewStage===2)return '✓ Store Reviewed';
return 'Staged';
}
function renderPrePwoTab(app){
const cs=mk('card');
const rows=_preWoList;
const body=_preWoListLoading
? `<div class="empty-msg"><span class="sp"></span> Loading…</div>`
: !rows.length
? `<div class="empty-msg">No Pre-PWOs staged. Create a Production Order from an approved Work Order to stage one.</div>`
: `<div class="tbl-wrap"><table class="comp-tbl">
<thead><tr><th>Item</th><th style="width:100px">Planned Qty</th><th style="width:140px">Stage</th><th>Created By</th><th style="width:80px"></th></tr></thead>
<tbody>${rows.map(p=>`
<tr>
<td><span class="comp-item">${esc(p.itemCode)}</span><div class="comp-desc">${esc(p.itemName)}</div></td>
<td>${esc(p.plannedQty)}</td>
<td>${prePwoStageLabel(p)}</td>
<td>${esc(p.createdByName||p.createdBy)}</td>
<td style="text-align:right"><button class="btn" data-prepwo-open="${p.id}" style="padding:4px 12px;font-size:11px">Open</button></td>
</tr>`).join('')}</tbody>
</table></div>`;
cs.innerHTML=`<div class="card-hd"><span class="ico"></span> Pre-PWO Staging<span class="right"><button class="btn" id="btn-prepwo-refresh" style="padding:4px 12px;font-size:11px">Refresh</button></span></div>
<div class="card-bd">${body}</div>`;
app.appendChild(cs);
setTimeout(()=>{
if(q('#btn-prepwo-refresh'))q('#btn-prepwo-refresh').onclick=loadPrePwoList;
document.querySelectorAll('[data-prepwo-open]').forEach(b=>b.onclick=()=>openPrePwoDetail(parseInt(b.dataset.prepwoOpen)));
},0);
renderPrePwoModal();
}
async function openPrePwoDetail(id){
try{ const r=await api('GET',`/pre-production-orders/${id}`); _preWoDetail=r.data; }
catch(e){ toast(e.message,'err'); return; }
_preWoEditMode=false;_preWoEditLines=null;_preWoEditRemarks='';
_preWoModalOpen=true;
render();
}
function closePrePwoDetail(){_preWoModalOpen=false;_preWoDetail=null;_preWoEditMode=false;_preWoEditLines=null;render();}
function prePwoLineRowsHtml(lines,editable){
return lines.map((l,i)=>`
<tr>
<td class="comp-num">${i+1}</td>
<td>${editable
?`<div class="search-wrap"><input class="fi fi-mono" data-pwci="${i}" placeholder="Search item code..." value="${esc(l.itemNo)}" style="height:30px;font-size:11px" autocomplete="off"/><div class="search-dd comp-dd" id="pwcdd-${i}"></div></div>`
:`<span class="comp-type ${l.type==='Resource'?'resource':'item'}">${l.type||'Item'}</span> <span class="comp-item">${esc(l.itemNo)}</span><div class="comp-desc">${esc(l.desc)}</div>`}</td>
<td>${editable?`<input class="comp-inp" data-pwi="${i}" data-f="baseQty" type="number" min="0" step="0.001" value="${l.baseQty}" style="width:90px"/>`:esc(l.baseQty)}</td>
<td>${editable?`<input class="comp-inp" data-pwi="${i}" data-f="plannedQty" type="number" min="0" step="0.001" value="${l.plannedQty}" style="width:100px"/>`:esc(l.plannedQty)}</td>
${editable?`<td style="text-align:right"><button class="btn" data-pwrm="${i}" style="padding:3px 10px;font-size:10px;background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);border-radius:5px">Remove</button></td>`:''}
</tr>`).join('');
}
function renderPrePwoModal(){
const bg=document.getElementById('prepwo-modal'),inner=document.getElementById('prepwo-inner');
if(!bg||!inner)return;
if(!_preWoModalOpen||!_preWoDetail){bg.classList.remove('show');return;}
bg.classList.add('show');
const p=_preWoDetail;
const locked=p.status==='CONVERTED';
const editable=_preWoEditMode;
const lines=editable?_preWoEditLines:p.lines;
const canShare=!locked&&p.reviewStage===0&&CAN_SHARE_PREPWO;
const canReview=!locked&&p.reviewStage===1&&CAN_REVIEW_PREPWO;
// Hard gate: whenever Pre-PWO Store Review is enabled, Store must have
// actually completed their review (reviewStage 2) before this can reach
// SAP — no bypass. Same rule enforced server-side in routes/sap.js.
const canPush=!locked&&CAN_CREATE_PO&&p.reviewStage===2;
const canEditDirect=!locked&&!editable&&CAN_CREATE_PO&&!canReview;
const canDelete=!locked&&CAN_CREATE_PO;
let beforeAfter='';
if(!editable&&p.reviewStage===2&&p.originalLines){
const beforeMap={};(p.originalLines||[]).forEach(l=>beforeMap[l.itemNo]=l);
const after=p.lines||[];
// Only rows Store actually touched: new items, or an existing item whose
// qty changed. Untouched lines (same qty, still present) are omitted —
// this is a change log, not a full re-listing of the components table.
const rows=after.filter(l=>{
const b=beforeMap[l.itemNo];
return !b||Number(b.plannedQty)!==Number(l.plannedQty);
}).map(l=>{
const b=beforeMap[l.itemNo];
return `<tr>
<td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(l.itemNo)}</td>
<td>${esc(l.desc)}</td>
<td>${b?esc(b.plannedQty):'<span style="color:#16a34a;font-weight:700">new</span>'}</td>
<td style="color:#c2410c;font-weight:700">${esc(l.plannedQty)}</td>
</tr>`;
}).join('');
const removed=(p.originalLines||[]).filter(b=>!after.some(a=>a.itemNo===b.itemNo));
const removedRows=removed.map(b=>`<tr><td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(b.itemNo)}</td><td>${esc(b.desc)}</td><td>${esc(b.plannedQty)}</td><td style="color:#dc2626;text-decoration:line-through">removed</td></tr>`).join('');
const body=(rows+removedRows)
? `<div class="tbl-wrap"><table class="comp-tbl"><thead><tr><th>Code</th><th>Item</th><th>Before</th><th>After</th></tr></thead><tbody>${rows}${removedRows}</tbody></table></div>`
: `<div style="font-size:12px;color:var(--text3)">No changes — Store reverted without modifying any component or quantity.</div>`;
beforeAfter=`<div class="card compact" style="margin-top:12px"><div class="card-hd"><span class="ico"></span> Store's Changes — Before / After</div><div class="card-bd">
<div style="font-size:11px;color:var(--text3);margin-bottom:8px">Reviewed by ${esc(p.reviewedByName||p.reviewedBy||'—')}${p.reviewRemarks?' — "'+esc(p.reviewRemarks)+'"':''}</div>
${body}
</div></div>`;
}
const lineTable=`<div class="tbl-wrap"><table class="comp-tbl">
<thead><tr><th style="width:28px">#</th><th>Item</th><th style="width:100px">Base Qty</th><th style="width:110px">Planned Qty</th>${editable?'<th style="width:60px"></th>':''}</tr></thead>
<tbody>${prePwoLineRowsHtml(lines,editable)}</tbody>
</table></div>`;
const addBtn=editable?`<div style="margin-top:8px"><button class="btn" id="btn-pw-add-comp" style="width:100%;padding:8px;font-size:11px;background:rgba(159,90,253,.08);color:var(--purple);border:1px solid rgba(159,90,253,.25);border-radius:7px">+ Add Component</button></div>`:'';
const woTag=` <span style="font-weight:400;color:var(--text3);font-size:11px">· from Work Order</span>`;
inner.innerHTML=`<div class="modal-hd"><div class="modal-hd-title">Pre-PWO — ${esc(p.itemCode)}${woTag}</div><button class="modal-close" id="pw-x">✕</button></div>
<div class="modal-bd">
<div class="card compact"><div class="card-hd"><span class="ico"></span> Summary</div><div class="card-bd">
<div class="alert ${locked?'al-green':'al-blue'}" style="text-align:left">
<strong>${esc(p.itemName)}</strong> · Planned Qty: <strong>${esc(p.plannedQty)}</strong> · Status: <strong>${prePwoStageLabel(p)}</strong>
${locked?` · Converted to Doc — see View Orders`:''}
</div>
${p.remarks?`<div style="margin-top:8px;font-size:12px;color:var(--text3)"><strong>Remarks:</strong> ${esc(p.remarks)}</div>`:''}
${!locked&&p.reviewStage!==2?`<div class="alert al-red" style="margin-top:8px;text-align:left">🔒 Store Review is required before this can be sent to SAP. ${p.reviewStage===0?'Share it with Store to continue.':'Awaiting Store to complete their review.'}</div>`:''}
</div></div>
<div class="card"><div class="card-hd"><span class="ico"></span> Components${editable?' — editing':''}<span class="right">${lines.length} line${lines.length!==1?'s':''}</span></div>
<div class="card-bd">${lineTable}${addBtn}
${editable?`<div class="fg" style="margin-top:12px"><label class="fl">Remarks for Production (optional)</label><input class="fi" id="pw-review-remarks" value="${esc(_preWoEditRemarks)}"/></div>`:''}
</div></div>
${beforeAfter}
<div class="card"><div class="card-bd"><div class="btn-bar">
${editable
?`<button class="btn b-planned" id="btn-pw-submit-review"><span id="sp-pw-review" style="display:none" class="sp"></span> Revert to Production</button>
<button class="btn b-cancel" id="btn-pw-cancel-edit">Cancel Edit</button>`
:`${canShare?`<button class="btn" id="btn-pw-share" style="background:rgba(37,99,235,.08);color:#2563eb;border:1px solid rgba(37,99,235,.25)">Share with Store</button>`:''}
${canReview?`<button class="btn" id="btn-pw-start-review" style="background:rgba(37,99,235,.08);color:#2563eb;border:1px solid rgba(37,99,235,.25)">Edit Lines (Store Review)</button>`:''}
${canEditDirect?`<button class="btn" id="btn-pw-start-edit" style="background:rgba(159,90,253,.08);color:var(--purple);border:1px solid rgba(159,90,253,.25)">Edit Lines</button>`:''}
${canPush?`<button class="btn b-planned" id="btn-pw-push"><span id="sp-pw-push" style="display:none" class="sp"></span> Push to SAP Now</button>`:''}
${canDelete?`<button class="btn" id="btn-pw-delete" style="background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25)">Delete</button>`:''}`
}
</div></div></div>
</div>`;
setTimeout(()=>wirePrePwoModal(),0);
}
function wirePrePwoModal(){
const p=_preWoDetail;if(!p)return;
if(q('#pw-x'))q('#pw-x').onclick=closePrePwoDetail;
if(q('#btn-pw-share'))q('#btn-pw-share').onclick=async()=>{await doSharePrePwo(p.id);closePrePwoDetail();await loadPrePwoList();};
if(q('#btn-pw-push'))q('#btn-pw-push').onclick=()=>{const btn=q('#btn-pw-push'),sp=q('#sp-pw-push');if(btn)btn.disabled=true;if(sp)sp.style.display='inline-block';doPushPrePwoToSap(p.id);};
if(q('#btn-pw-delete'))q('#btn-pw-delete').onclick=async()=>{
if(!confirm('Delete this Pre-PWO? It has not been sent to SAP.'))return;
try{ await api('DELETE',`/pre-production-orders/${p.id}`); toast('Pre-PWO deleted'); closePrePwoDetail(); await loadPrePwoList(); }
catch(e){ toast(e.message,'err'); }
};
const startEdit=()=>{_preWoEditMode=true;_preWoEditLines=(p.lines||[]).map(l=>({...l}));_preWoEditRemarks='';render();};
if(q('#btn-pw-start-review'))q('#btn-pw-start-review').onclick=startEdit;
if(q('#btn-pw-start-edit'))q('#btn-pw-start-edit').onclick=startEdit;
if(q('#btn-pw-cancel-edit'))q('#btn-pw-cancel-edit').onclick=()=>{_preWoEditMode=false;_preWoEditLines=null;render();};
if(q('#pw-review-remarks'))q('#pw-review-remarks').oninput=function(){_preWoEditRemarks=this.value;};
if(_preWoEditMode&&_preWoEditLines){
document.querySelectorAll('[data-pwi]').forEach(inp=>{
inp.oninput=function(){const i=parseInt(this.dataset.pwi),f=this.dataset.f;_preWoEditLines[i][f]=this.value;};
});
document.querySelectorAll('[data-pwrm]').forEach(b=>{
b.onclick=function(){const i=parseInt(this.dataset.pwrm);_preWoEditLines.splice(i,1);render();};
});
if(q('#btn-pw-add-comp'))q('#btn-pw-add-comp').onclick=()=>{_preWoEditLines.push({type:'Item',itemType:'pit_Item',itemNo:'',desc:'',baseQty:1,plannedQty:1,warehouse:'',issueMethod:'Manual'});render();};
document.querySelectorAll('[data-pwci]').forEach(inp=>{
const i=parseInt(inp.dataset.pwci);
inp.oninput=function(){
const v=this.value.trim();_preWoEditLines[i].itemNo=this.value.toUpperCase();
const dd=document.getElementById('pwcdd-'+i);if(!dd)return;
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_preWoSearchTimer[i]);
_preWoSearchTimer[i]=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span></div>';dd.style.display='block';
const items=await fetchItems(v,true);
if(!items.length){dd.innerHTML='<div class="sd-msg">No items</div>';return;}
dd.innerHTML='';items.forEach(it=>{
const r=document.createElement('div');r.className='sd-row';
r.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
r.onclick=()=>{_preWoEditLines[i].itemNo=it.ItemCode;_preWoEditLines[i].desc=it.ItemName;_preWoEditLines[i].baseQty=1;_preWoEditLines[i].plannedQty=_preWoEditLines[i].plannedQty||1;dd.style.display='none';render();};
dd.appendChild(r);
});
},250);
};
});
document.addEventListener('click',e=>{if(!e.target.closest('.search-wrap'))document.querySelectorAll('.comp-dd').forEach(d=>d.style.display='none');});
if(q('#btn-pw-submit-review'))q('#btn-pw-submit-review').onclick=async()=>{
const lines=_preWoEditLines.filter(l=>(l.itemNo||'').trim()).map(l=>({...l,itemNo:l.itemNo.trim().toUpperCase(),baseQty:parseFloat(l.baseQty)||1,plannedQty:parseFloat(l.plannedQty)||0}));
if(!lines.length){toast('At least one component line is required','err');return;}
const btn=q('#btn-pw-submit-review'),sp=q('#sp-pw-review');if(btn)btn.disabled=true;if(sp)sp.style.display='inline-block';
try{
// Store's review path (reviewStage 1) reverts to Production; a direct
// Production edit (no Store involved) just saves the lines in place.
if(p.reviewStage===1&&CAN_REVIEW_PREPWO){
await api('POST',`/pre-production-orders/${p.id}/revert-to-production`,{lines,remarks:_preWoEditRemarks});
toast('Reverted to Production');
}else{
await api('PUT',`/pre-production-orders/${p.id}`,{lines,itemCode:p.itemCode,itemName:p.itemName,plannedQty:p.plannedQty,batchNumber:p.batchNumber,warehouse:p.warehouse,fgWarehouse:p.fgWarehouse,mfgDate:p.mfgDate,expDate:p.expDate,remarks:p.remarks});
toast('Pre-PWO updated');
}
closePrePwoDetail();await loadPrePwoList();
}catch(e){toast(e.message,'err');if(btn)btn.disabled=false;if(sp)sp.style.display='none';}
};
}
}
// ═══════════════════════════════════════
// RELEASE FROM SUCCESS
// ═══════════════════════════════════════
async function doRelease(){
if(!_created?.entry)return;
if(!requireSapLogin())return;
const btn=q('#btn-release'),sp=q('#sp-release');btn.disabled=true;sp.style.display='inline-block';
try{await api('POST',`/sap/production-order/${_created.entry}/release`,{company:_co});_created.status='Released';toast('Released!');render();}
catch(e){toast(e.message,'err');btn.disabled=false;sp.style.display='none';}
}
// Success is now shown inside the create popup (see renderCreateModal).
function resetForm(){_created=null;_preWoStaged=null;_lines=[];_hdr={itemNo:'',desc:'',qty:1,warehouse:'',type:'bopotStandard',dueDate:today(),startDate:today(),postingDate:today(),remarks:''};_srcMode=CAN_MANUAL_CREATE_PO?'manual':(CAN_CREATE_PO?'wo':'manual');_srcWorkOrderId=null;_srcBatchNumber='';_srcFromComponent=false;_srcFromComponentInfo=null;_refWoNo='';_refBatchNumber='';_srcConsumable=false;_consumableDept='';_consumableDeptOptions=[];}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>