2415 lines
172 KiB
HTML
2415 lines
172 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function q(s){return document.querySelector(s);}
|
||
function mk(c){const d=document.createElement('div');d.className=c;return d;}
|
||
|
||
async function api(m,p,b=null){
|
||
const h={'Content-Type':'application/json'};if(_tok)h['Authorization']='Bearer '+_tok;
|
||
const o={method:m.toUpperCase(),headers:h};if(b)o.body=JSON.stringify(b);
|
||
let r,d;try{r=await fetch(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 & 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&¬InWo?'⭑ 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>
|