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

2118 lines
135 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>Work Order — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
<script src="/lucide.min.js?v=399"></script>
<style>
/* Uniquely-prefixed tokens — theme.css redeclares :root with the SAME
generic names (--surface/--text/--border/...) for its dark theme, and
since it loads after this block it wins the cascade. Prefixing with
--wo- keeps this page's own light, professional palette immune to that. */
:root{
--wo-blue:#2563eb;--wo-blue2:#1d4ed8;--wo-blue-light:#eff6ff;
--wo-text:#0f172a;--wo-text2:#334155;--wo-text3:#64748b;--wo-text4:#94a3b8;
--wo-bg:#f4f6f9;--wo-surface:#fff;--wo-surface2:#f8fafc;--wo-surface3:#eef1f5;--wo-border:#e2e8f0;--wo-border2:#cbd5e1;
--wo-font:'Space Grotesk',sans-serif;--wo-mono:'JetBrains Mono',ui-monospace,monospace;
--wo-red:#dc2626;--wo-green:#16a34a;--wo-amber:#d97706;--wo-radius:10px;
--wo-shadow:0 1px 3px rgba(15,23,42,.06),0 1px 2px rgba(15,23,42,.04);
--wo-shadow-lg:0 10px 30px rgba(15,23,42,.10),0 2px 8px rgba(15,23,42,.06);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--wo-font);background:var(--wo-bg);color:var(--wo-text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:24px;max-width:1250px;margin:0 auto}
.page-head{margin-bottom:18px}
.page-title{font-size:22px;font-weight:800;display:flex;align-items:center;gap:10px}
.page-sub{font-size:13px;color:var(--wo-text3);margin-top:3px}
.tabs{display:inline-flex;gap:4px;background:var(--wo-surface3);padding:4px;border-radius:10px;margin-bottom:16px}
.tab{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:700;color:var(--wo-text3);cursor:pointer;border:none;background:none;font-family:var(--wo-font)}
.tab.on{background:var(--wo-surface);color:var(--wo-blue);box-shadow:var(--wo-shadow)}
.card{background:var(--wo-surface);border:1px solid var(--wo-border);border-radius:var(--wo-radius);box-shadow:var(--wo-shadow);margin-bottom:16px;overflow:visible}
.card-hd{padding:12px 18px;border-bottom:1px solid var(--wo-border);font-size:14px;font-weight:700;background:var(--wo-surface2);border-radius:10px 10px 0 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.card-bd{padding:18px}
.ref-pill{font-family:var(--wo-mono);font-size:13px;font-weight:700;color:var(--wo-blue);background:var(--wo-blue-light);border:1px solid #bfdbfe;padding:5px 12px;border-radius:8px}
.unit-pill{font-size:10.5px;font-weight:800;color:var(--wo-blue);background:var(--wo-blue-light);border:1px solid #bfdbfe;padding:1px 6px;border-radius:6px;flex-shrink:0;white-space:nowrap}
.unit-pill.converted{color:#b45309;background:#fef3c7;border-color:#fde68a}
.sec-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--wo-text3);margin:18px 0 10px;display:flex;align-items:center;gap:6px}
.sol-group{margin-bottom:16px;border:1px solid var(--wo-border);border-radius:10px;padding:12px;background:var(--wo-surface2)}
.sol-hd{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.sol-name{font-size:12.5px;font-weight:700;color:var(--wo-text2);display:flex;align-items:center;gap:6px}
.sol-bs{display:flex;align-items:center;gap:8px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.fg{display:flex;flex-direction:column;gap:5px}
.fg.wide{grid-column:span 2}
.fl{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--wo-text3)}
.fi{width:100%;padding:8px 11px;border:1.5px solid var(--wo-border);border-radius:8px;font-size:13px;font-family:var(--wo-font);background:var(--wo-surface);color:var(--wo-text);outline:none;height:36px}
.fi:focus{border-color:var(--wo-blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.fi::placeholder{color:var(--wo-text4)}
.tbl-wrap{overflow-x:auto;border:1px solid var(--wo-border);border-radius:10px}
table.wo-tbl{width:100%;border-collapse:collapse;font-size:12px;min-width:820px}
.wo-tbl th{background:#eef0f3;color:var(--wo-text3);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;text-align:left;padding:8px 8px;white-space:nowrap}
.wo-tbl td{padding:4px 6px;border-bottom:1px solid var(--wo-border);vertical-align:middle}
.wo-tbl .fi{height:32px;font-size:12px;padding:5px 8px}
.td-act{width:38px;text-align:center}
.search-wrap{position:relative}
.search-dd{position:fixed;background:var(--wo-surface);border:1px solid var(--wo-blue);border-radius:8px;box-shadow:0 8px 28px rgba(15,23,42,.18);z-index:9000;max-height:260px;overflow-y:auto;display:none}
.sd-row{padding:8px 10px;cursor:pointer;border-bottom:1px solid #eef0f3;display:flex;flex-direction:column;gap:2px}
.sd-row:hover{background:var(--wo-blue-light)}
.sd-code{font-family:var(--wo-mono);font-size:11px;font-weight:700;color:var(--wo-blue)}
.sd-name{font-size:12px;color:var(--wo-text2)}
.sd-msg{padding:12px;text-align:center;color:var(--wo-text3);font-size:12px}
.b-del{background:rgba(220,38,38,.06);color:var(--wo-red);border:1px solid rgba(220,38,38,.2);border-radius:7px;width:28px;height:28px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
.b-reset{background:rgba(217,119,6,.08);color:#d97706;border:1px solid rgba(217,119,6,.25);border-radius:7px;width:24px;height:24px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
.btn{padding:9px 18px;border-radius:9px;border:none;font-family:var(--wo-font);font-size:13px;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:7px;justify-content:center;transition:all .15s}
.btn:disabled{opacity:.5;cursor:not-allowed}
.b-add{background:var(--wo-blue-light);color:var(--wo-blue);border:1px solid #bfdbfe;margin-top:8px;font-size:12px;padding:6px 12px}
.b-add:hover{background:#dbeafe}
.b-primary{background:var(--wo-blue);color:#fff;box-shadow:var(--wo-shadow)}
.b-primary:hover:not(:disabled){background:var(--wo-blue2)}
.b-cancel{background:var(--wo-surface);color:var(--wo-text2);border:1px solid var(--wo-border)}
.b-ok{background:var(--wo-green);color:#fff}
.b-rej{background:var(--wo-red);color:#fff}
.footer-row{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--wo-border)}
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
/* Batch picker */
.pick-row{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--wo-border);border-radius:8px;margin-bottom:6px;cursor:pointer;flex-wrap:wrap}
.pick-row:hover{border-color:var(--wo-blue);background:var(--wo-blue-light)}
.pick-row.sel{border-color:#16a34a;background:#e6f5e6}
.pick-row.sel:hover{border-color:#16a34a;background:#d9f0d9}
.pick-row.used{cursor:not-allowed;opacity:.55}
.pick-row.used:hover{border-color:var(--wo-border);background:none}
.pick-code{font-family:var(--wo-mono);font-size:12px;font-weight:700;color:var(--wo-blue)}
/* Workflow stepper */
.wf{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}
.wf-step{flex:1;min-width:150px;border:1px solid var(--wo-border);border-radius:8px;padding:8px 10px;background:var(--wo-surface2)}
.wf-step.done{border-color:#b3dab3;background:#e6f5e6}
.wf-step.cur{border-color:var(--wo-blue);background:var(--wo-blue-light)}
.wf-num{font-size:10px;font-weight:800;color:var(--wo-text4)}
.wf-step.done .wf-num{color:var(--wo-green)}
.wf-step.cur .wf-num{color:var(--wo-blue)}
.wf-lbl{font-size:11px;font-weight:700;color:var(--wo-text2);margin-top:2px}
.wf-who{font-size:10px;color:var(--wo-text3);margin-top:2px}
/* View list — search & filter bar */
.vf-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
.vf-search{position:relative;flex:1;min-width:220px}
.vf-search input{width:100%;padding:9px 12px 9px 34px;border:1.5px solid var(--wo-border);border-radius:9px;font-size:13px;font-family:var(--wo-font);background:var(--wo-surface);color:var(--wo-text);outline:none}
.vf-search input:focus{border-color:var(--wo-blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.vf-search i{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--wo-text4);pointer-events:none}
.vf-chips{display:flex;gap:6px;flex-wrap:wrap}
.vf-chip{padding:8px 14px;border-radius:20px;border:1.5px solid var(--wo-border);background:var(--wo-surface);color:var(--wo-text3);font-size:12px;font-weight:700;font-family:var(--wo-font);cursor:pointer;transition:all .12s;white-space:nowrap}
.vf-chip:hover{border-color:var(--wo-blue);color:var(--wo-blue2)}
.vf-chip.on{background:var(--wo-blue);border-color:var(--wo-blue);color:#fff}
.vf-count{font-size:12px;color:var(--wo-text3);white-space:nowrap}
/* View list — compact card, click opens the detail/action popup */
.wo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
.wo-card{background:var(--wo-surface);border:1px solid var(--wo-border);border-radius:14px;box-shadow:var(--wo-shadow);padding:16px;cursor:pointer;transition:box-shadow .15s,transform .15s,border-color .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.wo-card:hover{box-shadow:var(--wo-shadow-lg);transform:translateY(-2px);border-color:var(--wo-blue)}
.wc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
.wc-no{font-family:var(--wo-mono);font-size:14px;font-weight:700;color:var(--wo-blue2)}
.status-badge{font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.st-prog{background:var(--wo-blue-light);color:#1d4ed8;border:1px solid #bfdbfe}
.st-appr{background:#e6f5e6;color:#15803d;border:1px solid #b3dab3}
.st-rej{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca}
.wc-btn{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;padding:6px 12px;border-radius:7px;cursor:pointer;background:var(--wo-surface);border:1px solid var(--wo-border);color:var(--wo-text2);transition:all .12s}
.wc-btn:hover{border-color:var(--wo-blue);color:var(--wo-blue);background:var(--wo-blue-light)}
.wc-btn.del:hover{border-color:var(--wo-red);color:var(--wo-red);background:rgba(220,38,38,.06)}
.wc-icon{width:40px;height:40px;border-radius:10px;background:var(--wo-blue-light);color:var(--wo-blue2);display:flex;align-items:center;justify-content:center;margin:10px 0}
.wc-prod{font-size:13px;font-weight:700;color:var(--wo-text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.wc-code{font-size:11px;color:var(--wo-text3);margin-top:2px;font-family:var(--wo-mono)}
.wc-meta{font-size:11.5px;color:var(--wo-text3);margin-top:auto;padding-top:8px;border-top:1px solid var(--wo-border);display:flex;flex-direction:column;gap:3px}
.wc-meta b{color:var(--wo-text2);font-weight:600}
.wc-meta span{display:flex;align-items:center;gap:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kv{display:grid;grid-template-columns:repeat(3,1fr);gap:10px 16px;margin-bottom:12px;background:var(--wo-surface2);border:1px solid var(--wo-border);border-radius:10px;padding:12px 14px}
.kv div{font-size:12.5px;color:var(--wo-text)}.kv .k{color:var(--wo-text3);font-size:10px;text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px}
.dv-tbl{width:100%;border-collapse:collapse;font-size:11.5px;margin:4px 0 10px}
.dv-tbl th{background:var(--wo-surface3);color:var(--wo-text3);font-size:10px;font-weight:700;text-transform:uppercase;text-align:left;padding:6px 8px}
.dv-tbl td{padding:6px 8px;border-bottom:1px solid var(--wo-border)}
.empty-msg{text-align:center;padding:40px;color:var(--wo-text3);font-size:13px}
/* Detail popup */
.wo-modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.6);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:28px 20px;overflow-y:auto}
.wo-modal-bg.show{display:flex}
.wo-modal{background:var(--wo-surface);border-radius:16px;width:100%;max-width:960px;margin:auto;box-shadow:0 30px 70px rgba(15,23,42,.35);max-height:92vh;display:flex;flex-direction:column;animation:woModalIn .18s ease;color:var(--wo-text)}
@keyframes woModalIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.wo-modal-hd{padding:16px 22px;border-bottom:1px solid var(--wo-border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--wo-surface2);border-radius:16px 16px 0 0}
.wo-modal-close{margin-left:auto;width:32px;height:32px;border-radius:9px;border:1px solid var(--wo-border);background:var(--wo-surface);color:var(--wo-text3);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .12s}
.wo-modal-close:hover{background:var(--wo-red);color:#fff;border-color:var(--wo-red)}
.wo-modal-bd{padding:20px 22px;overflow-y:auto}
.wo-modal-ft{padding:14px 22px;border-top:1px solid var(--wo-border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;background:var(--wo-surface2);border-radius:0 0 16px 16px}
.suc-box{text-align:center;padding:36px 20px}
.suc-icon{width:56px;height:56px;border-radius:50%;background:#e6f5e6;color:#16a34a;display:inline-flex;align-items:center;justify-content:center;margin-bottom:14px}
.suc-title{font-size:19px;font-weight:800;color:#16a34a;margin-bottom:6px}
.suc-sub{font-size:13px;color:var(--wo-text3);margin-bottom:16px}
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast{padding:11px 16px;border-radius:9px;font-size:12px;font-weight:600;box-shadow:0 4px 16px rgba(15,23,42,.18);color:#fff;max-width:340px}
.t-ok{background:#16a34a}.t-err{background:#dc2626}
@media(max-width:768px){main{padding:14px 14px 80px}.grid,.kv{grid-template-columns:1fr}.wo-modal{max-height:100vh;border-radius:0}.wo-modal-bg{padding:0}}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
<style>
/* theme.css loads after this block and redeclares :root + broad selectors
(body, table, th, td, input, select, h1-h6, .card, .modal, etc.) for its
dark theme. All of this page's own rules above already use --wo-* vars,
so those are unaffected. This final block re-pins the few remaining
shared, non-namespaced surfaces theme.css targets broadly, so the page
keeps its professional light look regardless of cascade order. */
body{background:var(--wo-bg)!important}
.wo-card,.wo-modal,.card,.kv,.dv-tbl,.wf-step,.pick-row,.sol-group{background:var(--wo-surface);color:var(--wo-text)}
.wo-modal-hd,.wo-modal-ft,.card-hd,.wc-meta{background:var(--wo-surface2)}
.wo-tbl,.dv-tbl,table.wo-tbl{background:var(--wo-surface)}
.wo-tbl th,.dv-tbl th{background:#eef0f3!important;color:var(--wo-text3)!important}
.wo-tbl td,.dv-tbl td{color:var(--wo-text)!important;border-color:var(--wo-border)!important}
.fi,input.fi,select.fi,textarea.fi{background:var(--wo-surface)!important;color:var(--wo-text)!important;border-color:var(--wo-border)!important}
.btn,.b-primary,.b-cancel,.b-ok,.b-rej,.b-add,.wc-btn{border-radius:inherit}
</style>
</head>
<body>
<nav></nav>
<main id="app"></main>
<div class="toast-c" id="toast-c"></div>
<div class="wo-modal-bg" id="wo-detail-modal"><div class="wo-modal" id="wo-modal-inner"></div></div>
<div class="wo-modal-bg" id="reject-modal"><div class="wo-modal" id="reject-modal-inner" style="max-width:480px"></div></div>
<script>
'use strict';
let _tok=null,_user=null;
const BACKEND='/api';
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
if(!_tok)window.location.href='/';
const STEPS=['Prepared By QA','Checked By QC','Checked By Store In-Charge','Checked By (Manager Production)','Approved By (Manager QA)'];
// Index-aligned with STEPS above and routes/workOrders.js's WORK_ORDER_STEP_KEYS —
// if that 5-step chain ever changes, update both.
// A step whose display name changed later (e.g. "Checked By Production" →
// "Checked By Store In-Charge") — older Work Orders' workflowLog entries
// were recorded with the OLD text at the time (an immutable historical
// record), so stepper()'s lookup below must still match it.
const STEP_ALIASES={'Checked By Store In-Charge':['Checked By Production']};
const STEP_KEYS=['prepared_qa','checked_qc','checked_production','checked_mgr_production','approved_mgr_qa'];
// 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_WO=hasStepPerm('work_order:prepared_qa','add');
// "Duplicate Work Order" also creates a Batch Intimation along the way (see
// duplicateWO() below), which lives behind the Batch Issuance MODULE, not an
// approval step — mirrors auth-guard.js's own module-access check.
function hasBatchIssuanceModule(){
if(_user&&(_user.role==='admin'||_user.role==='sap_adder'))return true;
const mods=Array.isArray(_user&&_user.modules)?_user.modules:[];
return mods.includes('production-batch-issuance')||mods.includes('production');
}
const CAN_DUPLICATE_WO=CAN_CREATE_WO&&hasBatchIssuanceModule();
let _co=window.__DEFAULT_COMPANY__;
let _tab='create';
let _intimations=[], _srcId='', _pickedKey='';
// Batch Size (Volume, Ltr) entered once on the Batch Intimation — when a WO
// is created from a picked (product, batch), this seeds every Solution
// group's Batch Size instead of the usual BOM-derived value (see
// fetchBOMForWO()). Still subject to the same Blood Bag/PD Ltr lock as any
// other Batch Size value — only the SOURCE of the starting number differs.
let _srcBatchVolume='';
// True when the picked source Intimation has no Requirement Ref No (i.e. it
// came from Batch Issuance's "Without Intimation" tab) — informational only
// now, doesn't change how the BOM is displayed on its own (see
// _forceComponentsOnly below, the user-facing checkbox for that).
let _srcNoRequirement=false;
// Manual "show as one flat Components table" override — a checkbox on the
// form, off by default for every source (including Without Intimation).
// When on, fetchBOMForWO() skips the normal Raw Material/Packing Material
// split entirely and lists the product's direct BOM lines as-is.
let _forceComponentsOnly=false;
let _wo=blankHeader();
let _raw=[rawBlank()], _pack=[packBlank()];
let _intimationId=null, _editId=null, _editingRejected=false, _rejectReason='';
// New-Work-Order rule (Blood Bag & PD/CAPD finished goods): Solution Batch Size
// editable only when the (auto) value is BELOW the limit for that product type,
// else locked to the auto value. Separate Blood Bag / PD limits come from Admin
// → System Settings. _soluType ('BB'|'PD'|null) is set on BOM fetch.
let _soluBatchMaxBB=200, _soluBatchMaxPD=200, _soluRoundBB=10, _soluRoundPD=50, _soluType=null;
// True when the WO's OWN product (not a nested BOM item) is tagged COMPONENT
// (e.g. Item Group 128 "SEMI FINISHED") by Item Group Rules — re-derived on
// every BOM fetch, like _soluType. Confirmed convention: for these, header
// Batch Size (Ltr) just equals Total Units 1:1 (see recalcMaterials()) —
// kept separate from the Solution-explosion branch in fetchBOMForWO() (which
// ALSO requires the product's name to read like a Solution) so a non-liquid
// group-128 item's raw-material table structure (flat "Other Raw Materials")
// stays exactly as-is; only the header Batch Size gets this auto-fill.
let _topLevelComponentGroup=false;
let _newItemCode=''; // SAP UDF U_NewItemCode for the header Product Code — re-fetched on every BOM fetch, shown as "(code)" after the Product Code
let _woHideSfgFromPacking=false; // Admin → System Settings — drop SFG items from a real Packing Material table (not the combined Component table)
let _woShowWfiInRawMaterial=true; // Admin → System Settings — stop explodeToLeaves() at WFI (SFG00090) instead of recursing through its own BOM, so it shows as its own Raw Material row
let _woHideStdQtyUnitPicker=false; // Admin → System Settings — hide Packing/Components' manual Std. Qty/Unit unit dropdown (the read-only UOM column covers it instead)
// Admin → System Settings → "Raw Material — Ovg% Override" (services/rmOvgSettingsStore.js).
// _rmOvgMap is {ITEMCODE: ovgPercent} — resolved fresh after every BOM load,
// for raw (non-directRaw) items only, item-code match winning over item-group
// match. A row present in this map is "governed": its Ovg. cell gets
// pre-filled (once, if still blank) from here, and its "Qty Req./100 ml" is
// computed as (BOM value) − (BOM value × Ovg%/100) instead of the plain BOM
// figure, with Qty Req. then = that reduced /100ml × batch ratio, no further
// Ovg% multiplication. See recalcMaterials() below.
let _rmOvgOverrideEnabled=false;
let _rmOvgMap={};
let _woQtyIssuedSource='issue_for_production'; // Admin → System Settings — 'issue_for_production' (live SAP-derived) or 'mark_issued' (persisted, set on the one-click Mark Issued stamp) — applies to every material table's Qty Issued column
let _balanceIds=[]; // Admin → System Settings — allowed Weighing Balance IDs; empty = free text
let _customQtyUnits=[]; // Admin → System Settings — extra plain (no-conversion) Std. Qty/Unit options, alongside "No."/"Units"
let _rawPotencyFactors={};// Admin → System Settings — {itemCode: percent} cosmetic display factor for Raw Material's "Qty Req./100 ml" ONLY (see scaleGrams/potencyFactor below)
function soluMaxEdit(){return _soluType==='PD'?_soluBatchMaxPD:_soluBatchMaxBB;}
function soluRoundStep(){return _soluType==='PD'?_soluRoundPD:_soluRoundBB;}
// true once a BOM fetch (or a loaded saved record) has determined whether the
// product has any raw material — drives the single "Components" table layout
// below when it turns out there's none. Never flips a still-blank form.
let _bomFetched=false;
let _saving=false, _created=null, _bomLoading=false;
let _list=[], _listLoading=false;
let _viewSearch='', _viewStatus='';
let _searchTimer={}, _ddInput=null;
function blankHeader(){return{reference:'',productName:'',productDesc:'',genericName:'',productCode:'',batchNumber:'',batchSize:'',totalUnits:'',mfgDate:'',expDate:'',packSize:'',type:'',market:''};}
// solCode/solName group a row to its source Solution. solBatchSize is that
// Solution's batch size (Ltr) — AUTO-COMPUTED as solPerUnitLitres × Total
// Units (solPerUnitLitres = solution's own BOM qty per 1 finished unit).
// solBSManual=true once the user types over the computed value directly.
function rawBlank(){return{itemCode:'',rawMaterial:'',spec:'',stdQty:'',uom:'',ovg:'',qtyReq:'',weighingBalanceId:'',arNo:'',solCode:'',solName:'',solBatchSize:'',solPerUnitLitres:'',solBSManual:false,directRaw:false};}
function packBlank(){return{itemCode:'',packingMaterial:'',artworkNo:'',stdQtyPerUnit:'',stdQtyUnit:'',ovgPercent:'',qtyReqUnits:'',uom:'',qtyUnitLabel:'',arNo:''};}
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function fmtDate(d){if(!d)return'—';try{return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'});}catch{return d;}}
function fmtDT(d){if(!d)return'';try{return new Date(d).toLocaleString('en-IN');}catch{return d;}}
// MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is
// picker-only (see hfDate()/openDateFieldPicker() below) — the field itself
// is read-only, so a malformed date can never be typed at all; these
// regexes exist to (a) validate historical free-typed data from before this
// redesign, and (b) let parseMEDate() work on whatever string is already
// saved, regardless of which of the 6 formats it's in. Same format set as
// Batch Issuance (public/batch-issuance.html) — kept in sync there.
const MONTH_ABBR=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function pad2(n){return String(n).padStart(2,'0');}
const DATE_FORMATS=[
{id:'DMY', label:'DD-MMM-YYYY', kind:'full', re:/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/},
{id:'DMYn',label:'DD-MM-YYYY', kind:'full', re:/^(\d{1,2})-(\d{1,2})-(\d{4})$/},
{id:'MY', label:'MMM-YYYY', kind:'month', re:/^([A-Za-z]{3})[-/](\d{4})$/},
{id:'MYn', label:'MM-YYYY', kind:'month', re:/^(\d{1,2})-(\d{4})$/},
{id:'YM', label:'YYYY-MMM', kind:'month', re:/^(\d{4})-([A-Za-z]{3})$/},
{id:'YMn', label:'YYYY-MM', kind:'month', re:/^(\d{4})-(\d{1,2})$/},
];
function fmtById(id){return DATE_FORMATS.find(f=>f.id===id)||DATE_FORMATS[0];}
function detectFormat(v){
const t=(v||'').trim();
if(!t)return null;
for(const f of DATE_FORMATS) if(f.re.test(t)) return f.id;
return null;
}
function formatByPicked(iso,fmtId){
const parts=iso.split('-').map(Number);
const y=parts[0],mo=parts[1],d=parts[2];
const mon3=MONTH_ABBR[mo-1];
switch(fmtId){
case 'DMY': return `${pad2(d)}-${mon3}-${y}`;
case 'DMYn':return `${pad2(d)}-${pad2(mo)}-${y}`;
case 'MY': return `${mon3}-${y}`;
case 'MYn': return `${pad2(mo)}-${y}`;
case 'YM': return `${y}-${mon3}`;
case 'YMn': return `${y}-${pad2(mo)}`;
default: return `${pad2(d)}-${mon3}-${y}`;
}
}
function parseMEDate(v){
const t=(v||'').trim();
const fmtId=detectFormat(t);
if(!fmtId)return null;
const f=fmtById(fmtId),m=t.match(f.re);
const monFromName=s=>MONTH_ABBR.findIndex(x=>x.toLowerCase()===s.toLowerCase());
let y,mon,d=1;
switch(fmtId){
case 'DMY': d=parseInt(m[1]);mon=monFromName(m[2]);y=parseInt(m[3]);break;
case 'DMYn':d=parseInt(m[1]);mon=parseInt(m[2])-1;y=parseInt(m[3]);break;
case 'MY': mon=monFromName(m[1]);y=parseInt(m[2]);break;
case 'MYn': mon=parseInt(m[1])-1;y=parseInt(m[2]);break;
case 'YM': y=parseInt(m[1]);mon=monFromName(m[2]);break;
case 'YMn': y=parseInt(m[1]);mon=parseInt(m[2])-1;break;
}
if(mon==null||mon<0||mon>11)return null;
return new Date(y,mon,d);
}
function validMfgExp(v){return!v||!v.trim()||!!parseMEDate(v);}
// MFG and EXP always share ONE format — changing either field's dropdown
// changes both. Reset to null (→ redetected from the loaded record) at
// every point _wo gets replaced wholesale: pickBatch(), startEdit(), newWO().
let _woDateFmt=null;
function woDateFmt(){
if(!_woDateFmt)_woDateFmt=detectFormat(_wo.mfgDate)||detectFormat(_wo.expDate)||'DMY';
return _woDateFmt;
}
// Re-express an already-set date string in a different one of the 6
// formats. Unparseable/empty input is left as-is.
function reformatDate(value,newFmtId){
const dt=parseMEDate(value);
if(!dt)return value;
const iso=`${dt.getFullYear()}-${pad2(dt.getMonth()+1)}-${pad2(dt.getDate())}`;
return formatByPicked(iso,newFmtId);
}
// Header MFG/EXP field — same picker-only pattern as Batch Issuance: a
// format dropdown + a read-only text box set exclusively via the native
// date/month picker opened by the calendar button, so a malformed date can
// never be typed.
function hfDate(label,key){
const value=_wo[key];
const fmtId=woDateFmt();
return `<div class="fg">
<label class="fl">${label}</label>
<div style="display:flex;gap:4px;align-items:center">
<select class="fi" data-hdfmt="1" style="width:104px;flex-shrink:0;font-size:11px;padding:0 18px 0 6px">
${DATE_FORMATS.map(f=>`<option value="${f.id}" ${fmtId===f.id?'selected':''}>${f.label}</option>`).join('')}
</select>
<input class="fi" data-h="${key}" value="${esc(value)}" placeholder="Pick a date" readonly style="flex:1;min-width:0;cursor:pointer;background:var(--wo-surface2)" data-hdpopen="${key}"/>
<button type="button" class="date-pick-btn" data-hdp="${key}" title="Pick date" style="flex-shrink:0;width:36px;height:36px;border:1.5px solid var(--wo-border);border-radius:8px;background:var(--wo-surface);color:var(--wo-text3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><i data-lucide="calendar" style="width:14px;height:14px"></i></button>
</div>
</div>`;
}
function getDatePickerEl(){
let el=document.getElementById('inline-date-input');
if(!el){
el=document.createElement('input');el.id='inline-date-input';
el.style.cssText='position:fixed;z-index:9000;width:130px;height:30px;opacity:.01;border:none';
document.body.appendChild(el);
}
return el;
}
function openDateFieldPicker(key,btn){
const fmtId=woDateFmt();
const kind=fmtById(fmtId).kind;
const el=getDatePickerEl();
el.type=kind==='full'?'date':'month';
el.min='';
// Exp can't be older than Mfg — same "To > From" style constraint as
// Batch Issuance's picker.
if(key==='expDate'){
const md=parseMEDate(_wo.mfgDate);
if(md){
const pad=n=>String(n).padStart(2,'0');
if(kind==='full'){
const nd=new Date(Date.UTC(md.getFullYear(),md.getMonth(),md.getDate()+1));
el.min=`${nd.getUTCFullYear()}-${pad(nd.getUTCMonth()+1)}-${pad(nd.getUTCDate())}`;
}else{
const nm=new Date(Date.UTC(md.getFullYear(),md.getMonth()+1,1));
el.min=`${nm.getUTCFullYear()}-${pad(nm.getUTCMonth()+1)}`;
}
}
}
const r=btn.getBoundingClientRect();
const z=parseFloat(getComputedStyle(document.body).zoom)||1;
el.style.left=(r.left/z)+'px';
el.style.top=((r.bottom+4)/z)+'px';
el.value='';
el.onchange=()=>{
if(!el.value)return;
_wo[key]=formatByPicked(el.value,fmtId);
render();
};
el.focus();
if(el.showPicker)el.showPicker();
}
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=q('#toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=msg;tc.appendChild(el);setTimeout(()=>el.remove(),4200);}
// Server-generated Work Order PDF — fetched as a blob (Bearer auth) then opened.
async function downloadWoPdf(id,woNo){
try{
toast('Generating PDF…');
const r=await fetch(`/api/work-orders/${id}/pdf?company=${encodeURIComponent(_co)}`,{headers:{'Authorization':'Bearer '+_tok}});
if(!r.ok){let m='PDF generation failed';try{m=(await r.json()).message||m;}catch{}throw new Error(m);}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;a.download=`${woNo||('WO-'+id)}.pdf`;
document.body.appendChild(a);a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(url),5000);
}catch(e){toast(e.message||'PDF failed','err');}
}
window.downloadWoPdf=downloadWoPdf;
async function fetchItems(v){try{const r=await api('GET',`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch{}return[];}
async function loadIntimations(){try{const r=await api('GET',`/batch-intimations?company=${encodeURIComponent(_co)}`);_intimations=r.data||[];}catch(e){_intimations=[];}render();}
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;}
function round(n){return Math.round((Number(n)||0)*10000)/10000;}
function round3(n){return Math.round((Number(n)||0)*1000)/1000;}
function round5(n){return Math.round((Number(n)||0)*100000000)/100000000;}
// Auto Solution Batch Size is rounded to the nearest step for its product type
// (steps admin-configurable: Blood Bag / PD). Other products keep the plain
// 3-dp value. (Manual overrides are never rounded.)
// Below the step size, nearest-multiple rounding would round a genuinely
// small batch down to 0 ("no production"), which is wrong — a real 0.6 Ltr
// batch is still production. So below the step, use the exact value
// (3dp); only at/above the step does it round to the nearest multiple.
function roundBatch(v){
const n=Number(v)||0;
if(_soluType==='BB'||_soluType==='PD'){
const st=soluRoundStep();
if(st>0) return n<st ? round3(n) : Math.round(n/st)*st;
return round3(n);
}
return round3(n);
}
// Parse a fraction OR decimal string back to a number: "1/5"→0.2, "0.2"→0.2.
function parseQty(v){if(v==null)return NaN;v=String(v).trim();if(v.includes('/')){const p=v.split('/');const n=parseFloat(p[0]),d=parseFloat(p[1]);return d?n/d:NaN;}return parseFloat(v);}
// The recipe (Qty Req./100 ml) is in grams; convert the computed grams to the
// item's stock UOM — divide by 1000 for kilo/large units (KG, LTR…), else keep.
function toStockQty(grams,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?grams/1000:grams;}
// Inverse of toStockQty — used to back-solve Std Qty/100ml from a
// directly-typed Qty Req. (see backCalcStdQty()).
function fromStockQty(qty,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?qty*1000:qty;}
// Convert a solution's per-finished-unit quantity to Litres (for the batch-size calc).
function toLitres(qty,uom){const u=(uom||'').toUpperCase().trim();
if(u.startsWith('ML'))return qty/1000;
return qty;} // LTR/L/unknown → assume already Litres
// Packing/Component rows: for a liquid/length/weight component, pick the
// unit from a dropdown (data-mf="stdQtyUnit") instead of typing it — µ is
// awkward to type and free-text units are easy to misspell. Empty selection
// = "plain count" (e.g. "2" caps/unit) — no conversion applied at all.
// Three unit FAMILIES, each internally scaled ×1000 per step like the
// original Volume one — Length and Weight follow the exact same pattern
// ("all like Ltr", per the user's request), just with their own base unit
// and labels. A value is only ever auto-scaled to a unit within its OWN
// family (never e.g. mL → Kg).
const UNIT_FAMILIES={
volume: {'µL':1, 'mL':1000, 'Ltrs':1e6, 'KL':1e9},
length: {'mm':1, 'inch':25.4, 'm':1000, 'km':1e6},
weight: {'mg':1, 'gm':1000, 'Kg':1e6, 'Ton':1e9},
};
// Reverse lookup: unit label → {family, mult (into that family's base unit)}.
const UNIT_TO_FAMILY={};
Object.entries(UNIT_FAMILIES).forEach(([fam,units])=>{
Object.entries(units).forEach(([u,mult])=>{ UNIT_TO_FAMILY[u]={family:fam,mult}; });
});
// Auto-pick the best display unit within a family for a base-unit total
// (never let a converted value sit in an awkwardly huge/tiny base-unit
// number) — e.g. 10,000,000 µL → "10 Ltrs", 5,000,000 mg → "5 Kg".
function formatByFamily(totalBase,family){
const units=UNIT_FAMILIES[family]||UNIT_FAMILIES.volume;
const entries=Object.entries(units).sort((a,b)=>b[1]-a[1]); // largest multiplier first
for(const[u,mult]of entries){ if(totalBase>=mult)return{value:round3(totalBase/mult),label:u}; }
const smallest=entries[entries.length-1];
return{value:round3(totalBase/smallest[1]),label:smallest[0]};
}
const UNIT_FAMILY_LABEL={volume:'Volume',length:'Length',weight:'Weight'};
// Raw Material's "Qty Req./100 ml" and "Qty Req." are always computed on a
// grams basis internally (unchanged — nothing here touches that math or
// what gets saved/posted to SAP), but grams alone reads badly for a trace
// ingredient recipe'd in fractions of a gram — a BMR would write "5.08 mg",
// not "0.00508". Auto-scale ONLY the display using the same Weight family
// as Packing Material's own auto-scaling, so a Solution-derived raw
// material row shows mg/g/Kg/Ton automatically depending on magnitude.
// Skipped entirely for directRaw rows (Item-Group-Rules "RAW" overrides) —
// those aren't grams-recipe items at all, same Std-Qty/Unit model as Packing.
function scaleGrams(grams){ return formatByFamily((Number(grams)||0)*1000,'weight'); }
// Same conversion, but to an EXPLICITLY chosen weight unit instead of
// auto-picking the best-fit one — backs the "Qty Req./100 ml" unit dropdown
// (mirrors Packing Material's own Std Qty/Unit picker) so a user can pick
// mg/gm/Kg/Ton themselves rather than always getting the auto-scaled one.
function scaleGramsToUnit(grams,unit){
const mult=UNIT_FAMILIES.weight[unit]!=null?UNIT_FAMILIES.weight[unit]:1000;
return round3((Number(grams)||0)*1000/mult);
}
// <option> list of just the weight family's units (mg/gm/Kg/Ton) — the
// dropdown next to Raw Material's "Qty Req./100 ml" input.
function weightUnitOptionsHtml(selected){
return Object.keys(UNIT_FAMILIES.weight).map(u=>`<option value="${u}" ${selected===u?'selected':''}>${u}</option>`).join('');
}
// Inverse — given a typed number + the unit label it was displayed in,
// recover the true grams figure (family base unit is mg, so grams = mg/1000).
function unscaleGrams(value,label){
const mult=UNIT_FAMILIES.weight[label]!=null?UNIT_FAMILIES.weight[label]:1;
return (parseFloat(value)||0)*mult/1000;
}
// Admin-configured per-item SOP display factor (Admin → System Settings →
// "Raw Material SOP Display Factors") — e.g. a "60% USP" material whose SOP
// paperwork wants "Qty Req./100 ml" shown at 60% of the calculated value.
// SAP's BOM quantity already reflects the material's supplied strength, so
// the calculated figure is correct as-is — this ONLY re-scales what's shown
// for stdQty ("Qty Req./100 ml"); qtyReq ("Qty Req."), the stored data, and
// what's saved/posted to SAP are never touched. Missing item code = 1 (no
// adjustment, today's behavior).
function potencyFactor(itemCode){
const p=parseFloat(_rawPotencyFactors[itemCode]);
return (p>0)?p/100:1;
}
// Raw Material "Qty Req./100 ml" / "Qty Req." cell — scaled input + a
// read-only unit badge, matching Packing Material's Qty Req.(Units) style.
// field='stdQty' reads/writes grams directly; field='qtyReq' converts
// through the item's actual stock UOM (toStockQty/fromStockQty) so the true
// posted-to-SAP value is completely unaffected by this display layer.
function rawScaledCellHtml(r,i,field){
if(r.directRaw){
const ph=field==='stdQty'?'/100ml':'Qty Req.';
return `<input class="fi" data-mt="raw" data-mi="${i}" data-mf="${field}" value="${esc(r[field])}" placeholder="${ph}"/>`;
}
// User-chosen display unit (mg/gm/Kg/Ton), same picker style as Packing
// Material's Std Qty/Unit — defaults to the auto-scaled best-fit unit
// until the user picks their own, then remembers that choice on the row.
// field='stdQty' reads/writes grams directly; field='qtyReq' converts
// through the item's actual stock UOM (toStockQty/fromStockQty) so the
// true posted-to-SAP value is completely unaffected by this display layer.
const dispField=field==='stdQty'?'stdQtyDispUnit':'qtyReqDispUnit';
const grams=field==='stdQty'?(parseFloat(r.stdQty)||0)*potencyFactor(r.itemCode):fromStockQty(parseFloat(r.qtyReq)||0,r.uom);
const unit=r[dispField]||scaleGrams(grams).label;
const value=scaleGramsToUnit(grams,unit);
return `<div style="display:flex;align-items:center;gap:3px">
<input class="fi" data-mt="raw" data-mi="${i}" data-mf="${field}" data-scaled="1" data-scalelabel="${unit}" value="${value}" style="min-width:0;width:140px"/>
<select class="fi" data-mt="raw" data-mi="${i}" data-mf="${dispField}" style="width:64px;flex-shrink:0;padding:2px 3px">${weightUnitOptionsHtml(unit)}</select>
</div>`;
}
// Read-only version for View/Print/PDF — same scaling, plain "value label"
// text instead of an editable cell. Pass noUnit=true to get just the number
// (used for Qty Req./100 ml, whose unit is instead shown in the table's own
// UOM column via rawScaledUnit() below, matching this site's BMR-style
// report layout — Qty Req. keeps its unit inline as before).
// srcField lets a caller display a DIFFERENT row value (e.g. the persisted
// "qtyIssued") using the same unit/scaling conventions as 'field' would
// normally use (e.g. 'qtyReq''s own chosen display unit) — used by the Qty
// Issued column for Raw Material rows, since that value is never itself a
// dispUnit-carrying field.
function rawScaledDisp(r,field,noUnit,srcField){
const vf=srcField||field;
if(r.directRaw)return esc(r[vf]);
const dispField=field==='stdQty'?'stdQtyDispUnit':'qtyReqDispUnit';
const raw=parseFloat(r[vf])||0;
const grams=field==='stdQty'?raw*potencyFactor(r.itemCode):fromStockQty(raw,r.uom);
const unit=r[dispField]||scaleGrams(grams).label;
const value=scaleGramsToUnit(grams,unit);
return noUnit?String(value):`${value} ${esc(unit)}`;
}
// Scaled unit label alone, for the Raw Material table's UOM column — shows
// Qty Req./100 ml's unit (the user's own chosen one, if set) instead of the
// item's static SAP stock UOM. directRaw rows have no scaling concept, so
// this falls back to the item's actual r.uom (unchanged from before this
// feature).
function rawScaledUnit(r){
if(r.directRaw)return esc(r.uom);
const grams=(parseFloat(r.stdQty)||0)*potencyFactor(r.itemCode);
return esc(r.stdQtyDispUnit||scaleGrams(grams).label);
}
// Re-derive a Raw Material scaled cell's {value,label} from the row's
// current (unchanged) internal data and patch the DOM directly — no full
// render(), matching this app's established pattern for two-way fields
// (avoids the scroll-to-top a full render() on input would otherwise cause).
function refreshRawScaledCell(i,field){
const r=_raw[i];if(!r||r.directRaw)return;
const dispField=field==='stdQty'?'stdQtyDispUnit':'qtyReqDispUnit';
const grams=field==='stdQty'?(parseFloat(r.stdQty)||0)*potencyFactor(r.itemCode):fromStockQty(parseFloat(r.qtyReq)||0,r.uom);
const unit=r[dispField]||scaleGrams(grams).label;
const value=scaleGramsToUnit(grams,unit);
const el=document.querySelector(`[data-mt="raw"][data-mi="${i}"][data-mf="${field}"]`);
if(el){ if(String(el.value)!==String(value))el.value=value; el.dataset.scalelabel=unit; }
const sel=document.querySelector(`[data-mt="raw"][data-mi="${i}"][data-mf="${dispField}"]`);
if(sel&&sel.value!==unit)sel.value=unit;
}
// <option>/<optgroup> HTML for the Unit dropdown, grouped by family.
function unitOptionsHtml(selected){
// "Units" is a second plain-count option alongside "No." — same no-
// conversion math (it's deliberately absent from UNIT_FAMILIES/
// UNIT_TO_FAMILY, so it falls into the same "no family" branch as "No."
// in recalcMaterials()/backCalcStdQtyPack()) — just a different label for
// items counted as "Units" rather than "No."
let html=`<option value="" ${!selected?'selected':''}>No.</option>`
+`<option value="Units" ${selected==='Units'?'selected':''}>Units</option>`
// Admin → System Settings — same no-conversion "Units" treatment, just
// whatever extra labels the admin has configured (e.g. Box, Roll, Set).
+_customQtyUnits.map(u=>`<option value="${esc(u)}" ${selected===u?'selected':''}>${esc(u)}</option>`).join('');
Object.entries(UNIT_FAMILIES).forEach(([fam,units])=>{
html+=`<optgroup label="${UNIT_FAMILY_LABEL[fam]}">`;
Object.keys(units).forEach(u=>{ html+=`<option value="${u}" ${selected===u?'selected':''}>${u}</option>`; });
html+=`</optgroup>`;
});
return html;
}
function singleTableModeNow(){return _bomFetched&&!_raw.some(x=>(x.itemCode||'').trim());}
// Builds the Qty Req. cell's inner HTML (input + unit badge + "was X → now
// Y" note) for a pack row — shared by the initial render AND
// refreshPackQtyCell (a surgical DOM patch used after recalculation, so
// picking a Unit or editing Std Qty/Unit doesn't require a full page
// re-render that would steal focus from whatever field the user is on).
// Std Qty/Unit ↔ Qty Req.(Units) is a two-way pair (see backCalcStdQtyPack)
// — editing either updates the other live, no manual-override/reset needed.
function packQtyCellHtml(r,i){
const stm=singleTableModeNow();
const converted=r.qtyUnitLabel&&r.stdQtyUnit&&r.stdQtyUnit!==r.qtyUnitLabel;
const badge=r.qtyUnitLabel?`<span class="unit-pill${converted?' converted':''}" title="${converted?`Scaled up for readability — you entered ${esc(r.stdQtyUnit)}, this total is shown in ${esc(r.qtyUnitLabel)}`:'Unit'}">${esc(r.qtyUnitLabel)}</span>`:'';
const note=converted?`<div style="font-size:9.5px;color:#b45309;margin-top:2px">was ${esc(r.stdQtyUnit)} → now ${esc(r.qtyUnitLabel)}</div>`:'';
return `<div style="display:flex;align-items:center;gap:3px"><input class="fi" data-mt="pack" data-mi="${i}" data-mf="qtyReqUnits" value="${esc(r.qtyReqUnits)}" placeholder="${stm?'Qty Req.':'Qty Req.(Units)'}"/>${badge}</div>${note}`;
}
// Surgically refresh just one pack row's Qty Req. cell (value + badge + note)
// without a full render() — re-wires the input it just recreated, since
// innerHTML replacement drops old listeners. Only call this from a DIFFERENT
// field's handler (Std Qty/Unit, Unit, Ovg%) — never from the qtyReqUnits
// input's own handler, since that would destroy the element the user is
// actively typing into mid-keystroke.
function refreshPackQtyCell(i){
const r=_pack[i]; if(!r)return;
const cell=document.querySelector(`[data-packqtycell="${i}"]`);
if(!cell)return;
cell.innerHTML=packQtyCellHtml(r,i);
const inp=cell.querySelector('[data-mf="qtyReqUnits"]');
if(inp)inp.oninput=function(){
r.qtyReqUnits=this.value;
backCalcStdQtyPack(r,parseFloat(_wo.totalUnits)||0);
const stdEl=document.querySelector(`[data-mt="pack"][data-mi="${i}"][data-mf="stdQtyPerUnit"]`);
if(stdEl&&String(stdEl.value)!==String(r.stdQtyPerUnit))stdEl.value=r.stdQtyPerUnit;
};
if(window.lucide&&lucide.createIcons)lucide.createIcons();
}
// Short label for a solution's summary line, e.g. "SOLUTION A"→"A", "SOLUTION-B"→"B".
// Falls back to the full name when no short token pattern is found.
function solutionLabel(name){
const n=(name||'').trim();
let m=n.match(/solution\s*[-:]?\s*([A-Za-z0-9]+)$/i); if(m)return m[1].toUpperCase();
m=n.match(/^([A-Za-z0-9]+)\s*[-:]?\s*solution/i); if(m)return m[1].toUpperCase();
return n||'?';
}
// Header "Batch Size" summary: one line per distinct computed volume, grouping
// solutions that share the same value (e.g. "A+B = 520 Ltrs\nC = 5700 Ltrs").
function buildBatchSizeSummary(){
const groups=rawGroups().filter(g=>g.code&&g.batchSize!=='');
if(!groups.length)return '';
const byVal=new Map(); // value(string) → [labels]
groups.forEach(g=>{
const v=String(round3(parseFloat(g.batchSize))||0);
if(!byVal.has(v))byVal.set(v,[]);
byVal.get(v).push(solutionLabel(g.name));
});
return [...byVal.entries()].map(([v,labels])=>`${labels.join('+')} = ${v} Ltrs`).join('\n');
}
// Classification: Packing = code starts "PK"; Solution = a sub-assembly whose
// NAME contains "SOLUTION" (a product may have several SFG sub-assemblies — e.g.
// the anticoagulant solution AND the PVC bag; only the "…SOLUTION…" one counts).
function isPackingPK(code){return /^pk/i.test((code||'').trim());}
function isSolutionItem(code,name){return /solution/i.test((name||'')+' '+(code||''));}
// Admin-configurable Item Group (OITB.ItmsGrpCod) → set of tags
// ('RAW'/'PACK'/'COMPONENT', comma-separated in storage) set on the "Item
// Group Rules" settings page. RAW and PACK can BOTH be set on the same
// group — its items then land in BOTH the Raw Material and Packing Material
// tables. COMPONENT is exclusive of the other two. A configured group
// OVERRIDES the isPackingPK/isSolutionItem heuristics below for its items;
// groups left unconfigured fall back to the heuristics unchanged.
let _groupClassMap={}; // ItmsGrpCod -> 'RAW,PACK' style CSV (only configured groups)
let _itemGroupCache={}; // ItemCode -> ItmsGrpCod (or null), filled lazily per BOM level
async function loadGroupClassMap(){
try{
const r=await api('GET','/item-group-classification');
_groupClassMap={};
(r.data||[]).forEach(g=>{if(g.classification)_groupClassMap[g.code]=g.classification;});
}catch{}
}
async function ensureItemGroups(codes){
const need=[...new Set(codes)].filter(c=>c&&!(c in _itemGroupCache));
if(!need.length)return;
try{
const r=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(need.join(','))}&company=${encodeURIComponent(_co)}`);
if(r.success&&r.data)need.forEach(c=>{_itemGroupCache[c]=(r.data[c]!=null)?r.data[c]:null;});
}catch{}
need.forEach(c=>{if(!(c in _itemGroupCache))_itemGroupCache[c]=null;});
}
// Returns an array of tags, e.g. ['RAW','PACK'], ['COMPONENT'], or [] when
// the item's group isn't configured (→ caller falls back to heuristics).
function classifyByGroup(code){
const grp=_itemGroupCache[code];
if(grp==null)return[];
const cls=_groupClassMap[grp];
if(!cls)return[];
// Only RAW/PACK/COMPONENT drive Work Order BOM routing. 'SFG' is a
// separate, orthogonal axis (Batch Issuance SFG-workflow eligibility) and
// must never short-circuit the isSolutionItem()/isPackingPK() heuristics
// below — otherwise an item whose group is tagged ONLY 'SFG' (e.g. a
// Solution item like "SOLUTION CPDA IP") stops being detected as a
// Solution branch, collapsing Raw Material/Packing Material into one flat
// Components table.
return cls.split(',').filter(t=>t==='RAW'||t==='PACK'||t==='COMPONENT');
}
// Explode a BOM to its LEAF components. perUnit = component qty per 1 base-unit
// of `code`. Used on the Solution itself → perUnit is "per 1 unit of solution".
async function explodeToLeaves(code,mult,depth,visiting,out){
if(depth>8)return false;
const bom=await fetchBOM(code);
if(!bom||!bom.ProductTreeLines||!bom.ProductTreeLines.length)return false;
const base=Number(bom.Quantity)||1;
for(const l of bom.ProductTreeLines){
if(l.ItemType==='pit_Resource')continue;
const perUnit=(Number(l.Quantity)||0)/base*mult;
const cc=l.ItemCode||'', nm=l.ItemName||'', uom=l.Uom||l.InventoryUOM||'';
// WFI (SFG00090) has its own SAP BOM like any other SFG, so it would
// normally get recursed through below instead of listed itself — Admin
// → System Settings → "Show WFI in Raw Material" stops the explosion
// here for this ONE item code so it shows as its own Raw Material row.
const stopAtWfi=cc.toUpperCase()==='SFG00090'&&_woShowWfiInRawMaterial;
if(cc&&!stopAtWfi&&!visiting.has(cc)){
visiting.add(cc);
const sub=await explodeToLeaves(cc,perUnit,depth+1,visiting,out);
visiting.delete(cc);
if(sub)continue;
}
out.push({itemCode:cc,name:nm,uom,perUnit});
}
return true;
}
// Walk the finished-product BOM to collect PK packing items and locate the
// Solution sub-assembly node(s) — a product may use MORE THAN ONE solution
// (each with its own batch size), so collect {code,name} for every match
// rather than assuming just one. RULE (remembered): the separate Raw
// Material / Packing Material tables are ONLY for items that come from
// (a) inside a Solution branch (raw materials, exploded via explodeToLeaves)
// or (b) PK-coded (packing, wherever nested in the tree). EVERYTHING else —
// including semi-finished goods (SFG) sub-assemblies — shows directly in the
// Components table AS ITSELF, at its own BOM-line quantity, NOT exploded
// into its own sub-ingredients — so a non-PK/non-Solution item only gets
// its OWN Components line at the level it's first encountered (`topLevel`);
// recursing deeper than that is ONLY to keep discovering PK/Solution items
// nested inside that branch (e.g. packing tucked two levels down) — any
// OTHER plain item found while diving deeper is absorbed into its parent's
// single Components line, not listed again on its own.
//
// Item Group override (Item Group Rules settings page): before falling back
// to isPackingPK/isSolutionItem, each line's item is checked against the
// admin-configured Item Group → RAW/PACK/COMPONENT map. A configured group
// WINS over the heuristics for that item: 'PACK' behaves exactly like a
// PK-code match; 'RAW' pushes the item straight into rawDirectOut (shown as
// itself in Raw Material, NOT exploded — unlike the Solution-branch raws,
// which are exploded to leaves); 'COMPONENT' behaves like the default
// fallthrough (own Components line + keep recursing for nested overrides).
// Returns true if `code` itself has a BOM (used by the caller to mark a
// just-pushed pkOut entry as SFG — see the two `topLevel` pushes below).
async function scanTree(code,mult,depth,visiting,pkOut,solList,topLevel,rawDirectOut){
if(depth>8)return false;
const bom=await fetchBOM(code);
if(!bom||!bom.ProductTreeLines||!bom.ProductTreeLines.length)return false;
const base=Number(bom.Quantity)||1;
const lines=bom.ProductTreeLines.filter(l=>l.ItemType!=='pit_Resource');
await ensureItemGroups(lines.map(l=>l.ItemCode));
for(const l of lines){
const cc=l.ItemCode||'', nm=l.ItemName||'';
const perUnit=(Number(l.Quantity)||0)/base*mult;
const tags=classifyByGroup(cc); // [] when the item's group isn't configured
// Explicit RAW/PACK overrides win outright — an admin deliberately
// routing a group there is authoritative even over the Solution-name
// heuristic below.
if(tags.includes('PACK')||tags.includes('RAW')){
let matched=false;
if(tags.includes('PACK')){pkOut.push({itemCode:cc,name:nm,perUnit});matched=true;}
if(cc&&tags.includes('RAW')){rawDirectOut.push({itemCode:cc,name:nm,perUnit,uom:l.Uom||l.InventoryUOM||''});matched=true;}
if(matched)continue;
}
// A genuine Solution/recipe item (has "solution" in its name AND its own
// BOM to explode) must always be detected and exploded, REGARDLESS of an
// item-group classification like COMPONENT — a group tag was meant to
// control how opaque sub-assemblies display, never to block the one
// check that makes Raw Material/Batch Size calculations work at all.
// (Found live: group 128 "SEMI FINISHED" tagged COMPONENT also contains
// the product's actual Solution item — that combination used to collapse
// the WHOLE product into one flat Components table.) Only reachable when
// this item ISN'T already RAW/PACK-routed above.
if(!tags.length&&isPackingPK(cc)){pkOut.push({itemCode:cc,name:nm,perUnit});continue;}
if(!cc)continue;
if(isSolutionItem(cc,nm)){
// Name contains "solution" — but that alone doesn't mean it's a
// sub-assembly to explode. A leaf raw material can also have
// "SOLUTION" in its name (e.g. RM01646 "POVIDONE-IODINE SOLUTION…",
// a purchased ingredient with no BOM of its own). Only treat it as a
// Solution BRANCH if it actually HAS a BOM; otherwise it's just a
// normal item and falls through to the generic handling below —
// matching by name only made it vanish (explodeToLeaves found nothing
// to explode and it landed in neither table).
const subBom=await fetchBOM(cc);
if(subBom?.ProductTreeLines?.length){
const uom=l.Uom||l.InventoryUOM||'';
solList.push({code:cc,name:nm,perUnit,uom});
continue;
}
}
// raw-material leaf OR SFG — shown as itself, not exploded. `isSFG` is
// filled in AFTER recursing (an item with its own BOM = SFG), used by
// fetchBOMForWO() to optionally drop it from a real Packing Material
// table (Admin → System Settings → "Hide SFG from Packing Material").
let pushed=null;
if(topLevel){pushed={itemCode:cc,name:nm,perUnit,isSFG:false};pkOut.push(pushed);}
if(!visiting.has(cc)){
visiting.add(cc);
const hadBom=await scanTree(cc,perUnit,depth+1,visiting,pkOut,solList,false,rawDirectOut);
visiting.delete(cc);
if(pushed&&hadBom)pushed.isSFG=true;
}
}
return true;
}
async function fetchBOMForWO(){
const code=(_wo.productCode||'').trim();
if(!code){toast('Enter / select a Product Code first','err');return;}
// Does this finished product qualify for the Solution Batch Size lock, and is
// it Blood Bag or PD? (picks which admin limit applies)
_soluType=null;
_topLevelComponentGroup=false;
try{const bb=await api('GET',`/sap/solution-batch-lock-items?codes=${encodeURIComponent(code)}&company=${encodeURIComponent(_co)}`);
const hit=(bb.data||[]).find(x=>String(x.code).toUpperCase()===code.toUpperCase());
_soluType=hit?hit.type:null;
}catch(_e){}
_newItemCode='';
try{const ni=await api('GET',`/sap/lookup/new-item-codes?codes=${encodeURIComponent(code)}&company=${encodeURIComponent(_co)}`);
_newItemCode=(ni.data&&ni.data[code])||'';
}catch(_e){}
_bomLoading=true;render();
if(_forceComponentsOnly){
// Manual override (checkbox) — show the product's own direct BOM lines
// flat, exactly as SAP has them: no Raw/Packing split, no Solution
// explosion, no Item-Group-Rules routing. Reuses the existing
// singleTableMode "Components" table by leaving _raw empty. Off by
// default (even for a Without Intimation source) — the normal split
// below is the default for every source now; this is opt-in per WO.
try{
const bom=await fetchBOM(code);
const base=Number(bom?.Quantity)||1;
const lines=(bom?.ProductTreeLines||[]).filter(l=>l.ItemType!=='pit_Resource');
_pack=lines.length?lines.map(l=>Object.assign(packBlank(),{itemCode:l.ItemCode||'',packingMaterial:l.ItemName||'',ovgPercent:'',stdQtyPerUnit:String(round5((Number(l.Quantity)||0)/base))})):[packBlank()];
}catch(e){_pack=[packBlank()];}
_raw=[rawBlank()];
_bomLoading=false;_bomFetched=true;
recalcMaterials();
const nComp=_pack.filter(p=>(p.itemCode||'').trim()).length;
if(nComp)toast(`BOM loaded — ${nComp} component(s)`);
else toast('BOM loaded, but no components found','err');
render();
return;
}
const pkOut=[], solList=[], rawOut=[], rawDirectOut=[];
_itemGroupCache={}; // re-check group codes fresh each fetch (company may differ)
await loadGroupClassMap();
try{
// Producing a group-128 "SEMI FINISHED" Solution item DIRECTLY as the
// WO's own product (e.g. SFG00010 "SOLUTION CPDA USP") rather than one
// nested inside some other finished good's BOM — confirmed convention:
// 1 Total Unit = 1 Liter for these, so treat `code` itself exactly like
// a normal Solution group (solPerUnitLitres=1) and explode ITS OWN "per
// 100" recipe straight to raw-material leaves. Reuses every bit of the
// existing Solution-group machinery (Batch Size two-way sync, header
// summary, Blood-Bag/PD lock) unchanged — only scoped to items BOTH
// tagged COMPONENT (Item Group Rules) AND named like a Solution, so
// other group-128 items (e.g. "EMPTY CAPD MAIN BAG") are unaffected.
await ensureItemGroups([code]);
const topTags=classifyByGroup(code);
if(topTags.includes('COMPONENT')&&isSolutionItem(code,_wo.productName||'')){
const leaves=[];
await explodeToLeaves(code,1,0,new Set([code]),leaves);
leaves.forEach(s=>rawOut.push({...s,solCode:code,solName:_wo.productName||code,solPerUnitLitres:1}));
}else{
// Non-Solution group-128 (or other admin-configured COMPONENT-tagged
// group) product — raw materials still land flat via scanTree below
// (unchanged), but the header Batch Size still gets the same 1:1
// Total-Units-to-Ltr auto-fill (see recalcMaterials()).
if(topTags.includes('COMPONENT'))_topLevelComponentGroup=true;
await scanTree(code,1,0,new Set([code]),pkOut,solList,true,rawDirectOut);
const uniqueSol=[...new Map(solList.map(s=>[s.code,s])).values()]; // dedupe by code, keep first name
// Raw materials come from EACH Solution's OWN recipe → per 1 unit of that solution.
for(const sol of uniqueSol){
const leaves=[];
await explodeToLeaves(sol.code,1,0,new Set([sol.code]),leaves);
// Batch Size (Ltr) auto-derives from this solution's own BOM qty per 1
// finished unit (from the finished product's line) × Total Units.
const perLitres=toLitres(sol.perUnit,sol.uom);
leaves.forEach(s=>rawOut.push({...s,solCode:sol.code,solName:sol.name,solPerUnitLitres:perLitres}));
}
}
}catch(e){}
_bomLoading=false;
_bomFetched=true;
// stdQty = "Qty Req./100 ml" = perUnit × 100 (solution recipe base assumed in ml)
const raws=rawOut.map(s=>Object.assign(rawBlank(),{itemCode:s.itemCode,rawMaterial:s.name,uom:s.uom,stdQty:round(s.perUnit*100),ovg:'',solCode:s.solCode,solName:s.solName,solPerUnitLitres:s.solPerUnitLitres,solBSManual:false}));
// Item-Group-Rules "RAW" overrides: shown as themselves in Raw Material,
// NOT exploded — qty = Std Qty/Unit × Total Units × (1+Ovg%), same simple
// model as Packing/Components (no Solution/Batch-Size concept applies).
const directRaws=rawDirectOut.map(s=>Object.assign(rawBlank(),{itemCode:s.itemCode,rawMaterial:s.name,uom:s.uom,stdQty:String(round5(s.perUnit)),ovg:'',solCode:'',solName:'Other Raw Materials',directRaw:true}));
const allRaws=[...raws,...directRaws];
// Admin → System Settings → "Hide SFG from Packing Material": only applies
// when this table is genuinely Packing Material (a raw-material/Solution
// section exists) — with no raw material the table becomes the combined
// "Component" table instead, where SFG items are meant to stay.
const pkFiltered=(_woHideSfgFromPacking&&allRaws.length)?pkOut.filter(p=>!p.isSFG):pkOut;
const packs=pkFiltered.map(p=>Object.assign(packBlank(),{itemCode:p.itemCode,packingMaterial:p.name,ovgPercent:'',
stdQtyPerUnit:String(round5(p.perUnit))}));
_raw=allRaws.length?allRaws:[rawBlank()];
_pack=packs.length?packs:[packBlank()];
// Inventory UoM per raw/pack item (not carried on BOM lines) — one batch
// call covering both tables (Std Qty/Unit's UOM column included).
try{
const codes=[...new Set([...(_raw.map(r=>r.itemCode)),...(_pack.map(r=>r.itemCode))].filter(Boolean))];
if(codes.length){
const ur=await api('GET',`/sap/lookup/item-uoms?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
if(ur.success&&ur.data){
_raw.forEach(r=>{if(ur.data[r.itemCode])r.uom=ur.data[r.itemCode];});
_pack.forEach(r=>{if(ur.data[r.itemCode])r.uom=ur.data[r.itemCode];});
}
}
}catch{}
// Admin → System Settings → "Raw Material — Ovg% Override": resolve which
// raw (non-directRaw) items have a configured Ovg% (item code, else item
// group), pre-fill their Ovg. cell if still blank, and capture the
// pristine BOM Qty Req./100 ml value BEFORE any reduction — recalcMaterials()
// always re-derives the reduced figure from this, never from the
// (possibly already-reduced) current stdQty, so repeated recalcs never
// compound the subtraction.
_rmOvgMap={};
_raw.forEach(r=>{ if(!r.directRaw) r._bomStdQty=r.stdQty; });
if(_rmOvgOverrideEnabled){
try{
const codes=[...new Set(_raw.filter(r=>!r.directRaw).map(r=>(r.itemCode||'').toUpperCase()).filter(Boolean))];
if(codes.length){
const rr=await api('GET',`/rm-ovg-settings/resolve?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
if(rr.success&&rr.data){
_rmOvgMap=rr.data;
_raw.forEach(r=>{
if(r.directRaw)return;
const pct=_rmOvgMap[(r.itemCode||'').toUpperCase()];
if(pct!=null&&(r.ovg===''||r.ovg==null))r.ovg=String(pct);
});
}
}
}catch{}
}
// Batch Size (Volume, Ltr) entered on the source Batch Intimation — seed
// EVERY Solution group's starting value from it instead of the pure
// BOM-derived ratio. Back-calculate an effective solPerUnitLitres from
// srcVol ÷ (the intimation's own Total Units, already in _wo.totalUnits
// at this point) so the group's Batch Size STAYS in sync with Total Units
// going forward — leave solBSManual=false so recalcMaterials() keeps
// auto-deriving it on every Total Units edit, same as a BOM-only group.
// (Only a direct manual edit of the Batch Size input, handled elsewhere,
// should freeze it.) The Blood Bag/PD Ltr lock still applies exactly as it
// would to any other Batch Size value (see the bsLock render logic).
const srcVol=parseFloat(_srcBatchVolume);
const srcTu=parseFloat(_wo.totalUnits);
if(!isNaN(srcVol)&&srcVol>0&&!isNaN(srcTu)&&srcTu>0){
const perUnit=srcVol/srcTu;
_raw.forEach(r=>{if(r.solCode){r.solPerUnitLitres=perUnit;r.solBSManual=false;}});
}
recalcMaterials();
const nSol=uniqueSolCount(raws);
if(!allRaws.length&&!packs.length)toast('BOM loaded, but no Solution / PK items found','err');
else toast(`BOM loaded — ${nSol} solution(s), ${allRaws.length} raw, ${packs.length} packing`);
render();
}
function uniqueSolCount(rows){return new Set(rows.map(r=>r.solCode||'')).size;}
// Group _raw rows by their source Solution (solCode) for display — each group
// gets its own Batch Size input, since different solutions can have different
// sizes. Derived on-the-fly from _raw (single source of truth, no dup state).
function rawGroups(){
const map=new Map();
// NOTE: r.solBatchSize can legitimately be 0 (a tiny Total Units rounded
// down by the admin's Batch Size round-off step) — `||''` would treat that
// as "not computed" and blank the field, hiding a real answer. Only an
// actually-missing value (null/undefined) should fall back to ''.
_raw.forEach(r=>{if(!map.has(r.solCode))map.set(r.solCode,{code:r.solCode,name:r.solName||'',batchSize:r.solBatchSize!=null?r.solBatchSize:'',manual:!!r.solBSManual,direct:!!r.directRaw});});
return [...map.values()];
}
// Solution Batch Size (Ltr) = solPerUnitLitres × Total Units — auto, unless
// the user typed directly into that group's Batch Size input (solBSManual).
// Raw Qty Req. = (Qty Req./100 ml) × (Batch Size ml ÷ 100) × (1 + Ovg%/100)
// Pack Qty Req.(Units) = (Std Qty/Unit) × Total Units × (1 + Ovg%/100)
function recalcMaterials(){
const tu=parseFloat(_wo.totalUnits)||0;
// 1) auto-derive each non-manual group's Batch Size from Total Units
const perUnitByGroup={};
_raw.forEach(r=>{if(perUnitByGroup[r.solCode]==null)perUnitByGroup[r.solCode]=r.solPerUnitLitres;});
_raw.forEach(r=>{
if(r.solBSManual)return;
const pu=parseFloat(perUnitByGroup[r.solCode]);
if(!isNaN(pu))r.solBatchSize=roundBatch(pu*tu);
});
const solbsEls=[...document.querySelectorAll('[data-solbs]')];
rawGroups().forEach(g=>{const el=solbsEls.find(e=>e.dataset.solbs===g.code);if(el){el.value=g.batchSize;
// keep the Blood Bag/PD lock live as Total Units (hence batch size) changes
if(!g.manual){const lock=!_editId&&_soluType&&(parseFloat(g.batchSize)||0)>=soluMaxEdit();el.disabled=lock;el.style.background=lock?'var(--wo-surface3,#f1f4f8)':'';el.style.cursor=lock?'not-allowed':'';}
}});
// Header "Batch Size" = auto-generated multi-line summary of all solutions —
// but only when there IS a raw-material Solution BOM to derive it from.
// With no raw material (singleTableMode), _wo.batchSize is a plain editable
// field the user types into directly; don't stomp on it here. NOTE: check
// for a group with a real solCode specifically, not just rawGroups().length —
// Item-Group-Rules "RAW" overrides (r.directRaw) also live in _raw/rawGroups()
// with solCode='' and no Solution/Batch-Size concept at all, so a product
// with ONLY those (no real Solution) would otherwise wrongly count as
// "has raw material" here and stomp a manually-seeded Batch Size with an
// empty summary.
if(rawGroups().some(g=>g.code)){
_wo.batchSize=buildBatchSizeSummary();
const bsDisp=document.getElementById('disp-batchsize');if(bsDisp)bsDisp.textContent=_wo.batchSize||'—';
}else if(_topLevelComponentGroup&&_wo.totalUnits){
// Group-128 ("SEMI FINISHED", or any admin-configured COMPONENT-tagged
// group) product with no real Solution BOM of its own (e.g. SFGBL007, a
// label) — confirmed convention: Batch Size (Ltr) just tracks Total
// Units 1:1, kept live-synced the same way a Solution's would be. Only
// overwrites once Total Units actually has a value — leaves an already
// seeded Batch Size (e.g. from the source Batch Intimation's own Batch
// Size (Ltr), see pickBatch()) alone otherwise, instead of blanking it.
_wo.batchSize=`${_wo.totalUnits} Ltrs`;
const bsDisp=document.getElementById('disp-batchsize');if(bsDisp)bsDisp.textContent=_wo.batchSize||'—';
}
// 2) compute each raw row's Qty Req from its (now current) group Batch Size.
// Item-Group-Rules "RAW" rows (r.directRaw) skip the Solution/Batch-Size
// model entirely — they're not part of any solution recipe, so Qty Req is
// just Std Qty/Unit × Total Units × (1+Ovg%), same shape as Packing.
_raw.forEach((r,i)=>{if(r._manual)return;const std=parseFloat(r.stdQty);if(!isNaN(std)){
const ovg=parseFloat(r.ovg)||0;
// Admin → System Settings → "Raw Material — Ovg% Override": a "governed"
// row (this item — or its item group — has a configured Ovg%) gets its
// "Qty Req./100 ml" itself recomputed as (BOM value − BOM value×Ovg%/100)
// instead of staying the plain BOM figure, always re-derived from the
// untouched _bomStdQty captured at BOM-load time (never from the current,
// possibly already-reduced, stdQty — that would compound on every recalc).
// Qty Req. is then just that reduced figure × batch ratio, with NO further
// Ovg% multiplication (it's already baked into the /100ml figure).
// directRaw rows are never part of this — they have no /100ml concept.
const governed=!r.directRaw&&_rmOvgOverrideEnabled&&_rmOvgMap[(r.itemCode||'').toUpperCase()]!=null;
if(r.directRaw){
r.qtyReq=round3(std*tu*(1+ovg/100));
}else if(governed){
const bs=parseFloat(r.solBatchSize)||0;const rf=bs*1000/100;
const bomStd=parseFloat(r._bomStdQty!=null?r._bomStdQty:std)||0;
r.stdQty=bomStd-bomStd*ovg/100; // NOT rounded — full precision kept, unlike the plain BOM value above
const grams=r.stdQty*rf;r.qtyReq=round3(toStockQty(grams,r.uom));
}else{
const bs=parseFloat(r.solBatchSize)||0;const rf=bs*1000/100;
const grams=std*rf*(1+ovg/100);r.qtyReq=round3(toStockQty(grams,r.uom));
}
if(r.directRaw){const el=document.querySelector(`[data-mt="raw"][data-mi="${i}"][data-mf="qtyReq"]`);if(el)el.value=r.qtyReq;}
else{ if(governed)refreshRawScaledCell(i,'stdQty'); refreshRawScaledCell(i,'qtyReq'); }}});
// Roll-length auto-division was removed: it's always Std Qty/Unit × Total
// Units × (1+Ovg%) now. When a unit is picked from the Unit dropdown (any
// of Volume/Length/Weight), the total is computed in that family's base
// unit then auto-scaled to the best display unit within the SAME family
// (formatByFamily) — e.g. 500µL/unit × 20000 units = 10,000,000 µL →
// shown as "10 Ltrs", not a raw µL number. No unit picked = a discrete
// physical count (e.g. "2" caps/unit) — you can never actually need "0" of
// a listed component, so round UP, not to nearest.
_pack.forEach((r,i)=>{if(r._manual)return;
const std=parseQty(r.stdQtyPerUnit);
const fam=UNIT_TO_FAMILY[r.stdQtyUnit]||null;
if(!isNaN(std)){
const ovg=parseFloat(r.ovgPercent)||0;
if(fam){
const totalBase=std*fam.mult*tu*(1+ovg/100);
const {value,label}=formatByFamily(totalBase,fam.family);
r.qtyReqUnits=value; r.qtyUnitLabel=label;
}else{
const total=std*tu*(1+ovg/100);
r.qtyReqUnits = total>0 ? Math.ceil(total) : 0; r.qtyUnitLabel='';
}
refreshPackQtyCell(i);
}});
}
// Reverse of recalcMaterials()'s Std Qty/100ml → Qty Req. calc for ONE raw
// row — typing directly into Qty Req. back-solves Std Qty/100ml so the two
// fields stay in sync both ways (previously only Std Qty → Qty Req worked;
// editing Qty Req. just froze the row as "manual" and left Std Qty stale).
function backCalcStdQty(r,tu){
const qtyReq=parseFloat(r.qtyReq);
if(isNaN(qtyReq))return;
const ovg=parseFloat(r.ovg)||0;
if(r.directRaw){
const denom=tu*(1+ovg/100);
if(denom)r.stdQty=round5(qtyReq/denom);
}else{
const bs=parseFloat(r.solBatchSize)||0;const rf=bs*1000/100;
const denom=rf*(1+ovg/100);
if(denom){
const grams=fromStockQty(qtyReq,r.uom);
r.stdQty=round(grams/denom);
}
}
}
// Same idea as backCalcStdQty(), for the Packing Material pair (Std Qty/Unit
// ↔ Qty Req.(Units)). More involved because of unit-family auto-scaling
// (formatByFamily): the value on screen is in `qtyUnitLabel` (auto-picked
// for readability), which can differ from `stdQtyUnit` (what was actually
// typed), so it has to be converted back to the family's base unit before
// dividing out the family multiplier for stdQtyUnit itself.
function backCalcStdQtyPack(r,tu){
const qty=parseFloat(r.qtyReqUnits);
if(isNaN(qty))return;
const ovg=parseFloat(r.ovgPercent)||0;
const denomTU=tu*(1+ovg/100);
if(!denomTU)return;
const fam=UNIT_TO_FAMILY[r.stdQtyUnit]||null;
if(fam){
const labelInfo=UNIT_TO_FAMILY[r.qtyUnitLabel]||fam;
const totalBase=qty*labelInfo.mult;
r.stdQtyPerUnit=String(round5(totalBase/(fam.mult*denomTU)));
}else{
r.stdQtyPerUnit=String(round5(qty/denomTU));
}
}
// A (Product, Batch No.) pair identifies one production run — once a Work
// Order exists for it, its full quantity is captured and it can't be picked
// again. Matched against _list (all Work Orders, loaded in the background —
// see init()) by intimationId + productCode + batchNumber.
function usedBatchKey(productCode,batchNo){return `${productCode||''}|${batchNo||''}`;}
function usedBatchesForIntimation(intimationId){
const set=new Set();
(_list||[]).forEach(w=>{
if(w.isDeleted)return;
if(String(w.intimationId)!==String(intimationId))return;
set.add(usedBatchKey(w.productCode,w.batchNumber));
});
return set;
}
// Is there at least one (product, batch) pair on this Intimation that hasn't
// already been turned into a Work Order? Used to hide fully-captured
// Intimations from the "Batch Intimation" source dropdown entirely.
function intimationHasAvailableBatch(it){
const used=usedBatchesForIntimation(it.id);
return (it.products||[]).some(p=>(p.batches||[]).some(b=>!used.has(usedBatchKey(p.itemCode,b.batchNo))));
}
function selectSource(id){_srcId=id;_pickedKey='';_srcBatchVolume='';_srcNoRequirement=false;_forceComponentsOnly=false;render();}
async function pickBatch(pi,bi){
_pickedKey=`${pi}:${bi}`;
const it=_intimations.find(x=>String(x.id)===String(_srcId));if(!it)return;
const p=it.products[pi], b=p.batches[bi];
_intimationId=it.id;
_srcBatchVolume=b.batchVolume!=null&&b.batchVolume!==''?String(b.batchVolume):'';
_srcNoRequirement=!(it.refNo||'').trim();
_forceComponentsOnly=false; // always starts unchecked — user opts in explicitly per WO
_woDateFmt=null; // redetect MFG/EXP format fresh from this intimation's dates
// These fields come from the selected intimation; the rest stay manual.
_wo=Object.assign(blankHeader(),{
reference:it.docNo||'',
productCode:p.itemCode||'',
productName:p.itemName||p.itemDesc||'', // Product Name & Description (combined)
batchNumber:b.batchNo||'',
totalUnits:b.batchSize!=null?String(b.batchSize):'',
// Seed the header Batch Size from the intimation's own Batch Size (Ltr)
// too — for a product WITH a raw-material Solution BOM this gets
// overwritten by the BOM-derived summary as soon as fetchBOMForWO()
// runs (see recalcMaterials()'s `if(rawGroups().some(g=>g.code))` guard), but for
// a packing/label-only product with no Solution at all, nothing else
// ever fills this in — it would otherwise stay blank even though the
// user already typed a real value on the Batch Intimation.
batchSize:_srcBatchVolume?`${_srcBatchVolume} Ltrs`:'',
mfgDate:b.mfgDate||'',
expDate:b.expDate||'',
market:b.market||'',
});
toast('Header prefilled from '+(it.docNo||'')); render();
await fetchBOMForWO(); // auto-fetch BOM & calculate as soon as a product/batch is picked
applyDuplicatePayloadIfAny(); // no-op unless this WO originated from "Duplicate" (see above)
}
// ─────────── RENDER ───────────
function render(){
const app=q('#app');
if(_created){renderSuccess(app);lucide.createIcons();return;}
app.innerHTML=`
<div class="page-head">
<div class="page-title"><i data-lucide="file-cog"></i> Production Work Order</div>
<div class="page-sub">Generated from a Batch Issuance Intimation</div>
</div>
<div class="tabs">
${(_editId||CAN_CREATE_WO)?`<button class="tab ${_tab==='create'?'on':''}" data-t="create">${_editId?'Edit':'+ New'} Work Order</button>`:''}
<button class="tab ${_tab==='view'?'on':''}" data-t="view">View Work Orders</button>
</div>
<div id="body"></div>`;
q('#body').innerHTML=_tab==='create'?renderCreate():renderView();
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{const t=b.dataset.t;if(t==='view'){_tab='view';loadList();}else{newWO();}});
// wireCreate() assumes the real form markup exists (unguarded q('#btn-submit')
// etc.) — skip it when renderCreate() rendered the no-permission message instead.
if(_tab==='create'){ if(_editId||CAN_CREATE_WO) wireCreate(); }
else wireView();
lucide.createIcons();
}
function hf(label,key,type='text',wide,ph){return `<div class="fg${wide?' wide':''}"><label class="fl">${label}</label><input class="fi" data-h="${key}" ${type==='date'?'type="date"':''} value="${esc(_wo[key])}" placeholder="${esc(ph||label)}"/></div>`;}
function renderCreate(){
// The "+ New Work Order" tab is already hidden for users without the
// create permission (see render()'s tabs bar + wireCreate() skip below),
// so this is only reached via an edge case that shouldn't normally occur —
// silently fall back to the list instead of showing an explanatory message.
if(!_editId&&!CAN_CREATE_WO){
setTimeout(()=>{_tab='view';loadList();},0);
return '';
}
// Source prefill (hidden when editing)
let srcHtml='';
if(!_editId){
// An intimation with EVERY (product, batch) pair already turned into a
// Work Order has nothing left to generate — exclude it from the picker
// dropdown entirely (rather than show it and let the picker end up all
// locked with no usable rows).
const selectable=_intimations.filter(intimationHasAvailableBatch);
const opts=selectable.map(it=>`<option value="${it.id}" ${String(_srcId)===String(it.id)?'selected':''}>${esc(it.docNo)} — ${esc(it.refNo)}</option>`).join('');
const sel=selectable.find(x=>String(x.id)===String(_srcId));
const selFullyCaptured=!sel&&_intimations.some(x=>String(x.id)===String(_srcId));
let picker='';
if(selFullyCaptured){
picker=`<div class="empty-msg">Every batch on this Intimation already has a Work Order — nothing left to generate.</div>`;
}
if(sel){
// A (Product, Batch No.) pair from an Intimation may only ever produce
// ONE Work Order — its full quantity is captured the first time —
// so already-used pairs are locked out of the picker here.
const usedSet=usedBatchesForIntimation(sel.id);
picker=(sel.products||[]).map((p,pi)=>(p.batches||[]).map((b,bi)=>{
const used=usedSet.has(usedBatchKey(p.itemCode,b.batchNo));
return `
<div class="pick-row${_pickedKey===`${pi}:${bi}`?' sel':''}${used?' used':''}" ${used?'':`data-pick="${pi}:${bi}"`} ${used?'title="A Work Order has already been generated for this batch"':''}>
<span class="pick-code">${esc(p.itemCode)}</span><span>${esc(p.itemName||'')}</span>
<span style="margin-left:auto;font-size:11px;color:var(--wo-text3)">Batch ${esc(b.batchNo||'—')} · ${esc(b.batchSize)} units</span>
${used?'<span style="font-size:10px;font-weight:700;color:var(--wo-text3);display:flex;align-items:center;gap:3px"><i data-lucide="lock" style="width:12px;height:12px"></i> WO created</span>'
:_pickedKey===`${pi}:${bi}`?'<i data-lucide="check-circle" style="width:14px;height:14px;color:#16a34a;flex-shrink:0"></i>':''}
</div>`;
}).join('')).join('')||'<div class="empty-msg">This intimation has no batches.</div>';
}
srcHtml=`
<div class="card">
<div class="card-hd"><i data-lucide="layers" style="width:16px;height:16px"></i> Prefill from Batch Intimation</div>
<div class="card-bd">
<div class="fg" style="max-width:420px"><label class="fl">Batch Intimation</label>
<select class="fi" id="sel-src"><option value="">— Select intimation —</option>${opts}</select></div>
${(sel||selFullyCaptured)?`<div class="sec-title">Pick a product / batch</div>${picker}`:''}
</div>
</div>`;
}
// Admin → System Settings → "Weighing Balance IDs": when configured, a
// dropdown restricted to that list; empty (default) = free text, same as
// before this setting existed.
// Weighing Balance ID / AR No. are NOT editable at Work Order creation —
// they're only ever filled in later, in Verify Work Order (work_order:issue
// 'edit' step), same reasoning as the disabled-but-visible fields below.
function balanceIdField(t,i,val){
if(!_balanceIds.length)return `<input class="fi" data-mt="${t}" data-mi="${i}" data-mf="weighingBalanceId" value="${esc(val)}" placeholder="Balance ID" disabled title="Set in Verify Work Order"/>`;
const opts=_balanceIds.map(id=>`<option value="${esc(id)}" ${val===id?'selected':''}>${esc(id)}</option>`).join('');
return `<select class="fi" data-mt="${t}" data-mi="${i}" data-mf="weighingBalanceId" disabled title="Set in Verify Work Order"><option value="">— Select —</option>${opts}</select>`;
}
const rawRowHtml=(r,i)=>`
<tr>
<td style="min-width:150px"><div class="search-wrap"><input class="fi" data-search="raw:${i}" placeholder="Search code/name…" value="${esc(r.itemCode)}" autocomplete="off"/></div></td>
<td style="min-width:150px"><input class="fi" data-mt="raw" data-mi="${i}" data-mf="rawMaterial" value="${esc(r.rawMaterial)}" placeholder="Raw material"/></td>
<td><input class="fi" data-mt="raw" data-mi="${i}" data-mf="spec" value="${esc(r.spec)}" placeholder="Spec"/></td>
<td style="width:150px">${rawScaledCellHtml(r,i,'stdQty')}</td>
<td style="width:70px"><input class="fi" data-mt="raw" data-mi="${i}" data-mf="uom" value="${esc(r.uom)}" placeholder="UOM"/></td>
<td style="width:70px"><input class="fi" data-mt="raw" data-mi="${i}" data-mf="ovg" value="${esc(r.ovg)}" placeholder="Ovg."/></td>
<td style="width:150px">${rawScaledCellHtml(r,i,'qtyReq')}</td>
<td style="width:110px">${balanceIdField('raw',i,r.weighingBalanceId)}</td>
<td style="width:100px"><input class="fi" data-mt="raw" data-mi="${i}" data-mf="arNo" value="${esc(r.arNo)}" placeholder="AR No." disabled title="Set in Verify Work Order"/></td>
<td class="td-act"><button class="b-del" data-rmraw="${i}"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button></td>
</tr>`;
// No raw material found for this product (confirmed via a completed Fetch
// BOM, or inferred from a loaded saved record) → show ONE simplified
// "Components" table instead of separate Raw Material / Packing Material
// sections. Also means there's no Solution BOM to auto-derive the header
// Batch Size from, so let the user type it in directly in that case.
const singleTableMode=_bomFetched&&!_raw.some(r=>(r.itemCode||'').trim());
// One sub-table per Solution — each has its own Batch Size input, since
// different solutions used in the same product can have different sizes.
const rawSectionHtml=rawGroups().map(g=>{
const rowsHtml=_raw.map((r,i)=>r.solCode===g.code?rawRowHtml(r,i):'').join('');
return `
<div class="sol-group">
<div class="sol-hd">
<span class="sol-name"><i data-lucide="flask-conical" style="width:13px;height:13px"></i> ${g.direct?'Other Raw Materials':(g.name?esc(g.name):'Raw Materials')}</span>
${g.direct?'':(()=>{
// New Work Order + Blood Bag/PD: lock the Solution Batch Size once its
// (auto) value reaches the configured litre limit; below it, editable.
const lim=soluMaxEdit();
const bsLock=!_editId&&_soluType&&(parseFloat(g.batchSize)||0)>=lim;
const tag=g.manual?'(manual)':(bsLock?`(locked ≥ ${lim} Ltr)`:'(auto)');
return `<span class="sol-bs"><label class="fl" style="margin:0">Solution Batch Size (Ltr) <span style="text-transform:none;font-weight:400;color:var(--wo-text4)">${tag}</span></label><input class="fi" style="width:110px;height:30px${bsLock?';background:var(--wo-surface3,#f1f4f8);cursor:not-allowed':''}" data-solbs="${esc(g.code)}" value="${esc(g.batchSize)}" placeholder="e.g. 60" ${bsLock?'disabled':''} title="${bsLock?`Locked: ${_soluType==='PD'?'PD':'Blood Bag'} solution batch ≥ ${lim} Ltr uses the auto-calculated value.`:`Auto-calculated from Total Units × this solution's BOM ratio. Type here to override.`}"/></span>`;
})()}
</div>
<div class="tbl-wrap"><table class="wo-tbl">
<thead><tr><th>Item Code</th><th>Raw Materials</th><th>Spec</th><th>${g.direct?'Std Qty/Unit':'Qty Req./100 ml'}</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>Weighing Balance ID</th><th>AR No.</th><th class="td-act"></th></tr></thead>
<tbody>${rowsHtml}</tbody>
</table></div>
<button class="btn b-add" data-addrawgroup="${esc(g.code)}" style="font-size:11px"><i data-lucide="plus" style="width:13px;height:13px"></i> Add Raw Material</button>
</div>`;
}).join('');
// Still backed by the same _pack array/columns minus Artwork No.
const packRows=_pack.map((r,i)=>`
<tr>
<td style="min-width:150px"><div class="search-wrap"><input class="fi" data-search="pack:${i}" placeholder="Search code/name…" value="${esc(r.itemCode)}" autocomplete="off"/></div></td>
<td style="min-width:150px"><input class="fi" data-mt="pack" data-mi="${i}" data-mf="packingMaterial" value="${esc(r.packingMaterial)}" placeholder="${singleTableMode?'Component':'Packing material'}"/></td>
${singleTableMode?'':'<td><input class="fi" data-mt="pack" data-mi="'+i+'" data-mf="artworkNo" value="'+esc(r.artworkNo)+'" placeholder="Artwork No."/></td>'}
<td style="width:220px"><div style="display:flex;gap:3px"><input class="fi" style="min-width:0;width:${_woHideStdQtyUnitPicker?'100%':'145px'}" data-mt="pack" data-mi="${i}" data-mf="stdQtyPerUnit" value="${esc(r.stdQtyPerUnit)}" placeholder="Qty" title="Quantity per finished unit. Pick a unit alongside it for volume/length/weight, or leave it blank for a discrete count (caps, labels, etc.)."/><select class="fi" style="width:70px;flex-shrink:0;padding:0 2px${_woHideStdQtyUnitPicker?';display:none':''}" data-mt="pack" data-mi="${i}" data-mf="stdQtyUnit" title="Unit — leave blank for a plain count">${unitOptionsHtml(r.stdQtyUnit)}</select></div></td>
<td style="width:70px;color:var(--wo-text3)" title="Stock UOM from SAP">${esc(r.uom||'-')}</td>
<td style="width:80px"><input class="fi" data-mt="pack" data-mi="${i}" data-mf="ovgPercent" value="${esc(r.ovgPercent)}" placeholder="Ovg.%"/></td>
<td style="width:125px" data-packqtycell="${i}">${packQtyCellHtml(r,i)}</td>
<td style="width:100px"><input class="fi" data-mt="pack" data-mi="${i}" data-mf="arNo" value="${esc(r.arNo)}" placeholder="AR No." disabled title="Set in Verify Work Order"/></td>
<td class="td-act"><button class="b-del" data-rmpack="${i}"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button></td>
</tr>`).join('');
return `
${srcHtml}
${_editingRejected?`<div class="card" style="border-color:#f0b3b3;background:#fff5f5;margin-bottom:12px">
<div style="padding:12px 16px;font-size:12.5px;color:#9b2c2c"><strong>This Work Order was rejected.</strong>${_rejectReason?` Reason: "${esc(_rejectReason)}"`:''} Saving your changes will resubmit it — the full approval chain restarts from Prepared By QA.</div>
</div>`:''}
<div class="card">
<div class="card-hd"><i data-lucide="${_editId?'pencil':'file-cog'}" style="width:16px;height:16px"></i> ${_editId?(_editingRejected?'Edit & Resubmit Work Order':'Edit Work Order'):'Work Order Header'} ${_wo.reference?`<span style="margin-left:auto" class="ref-pill">Ref: ${esc(_wo.reference)}</span>`:''}</div>
<div class="card-bd">
<div class="grid">
${hf('Product Name & Description','productName','text',true)}
${hf('Product Code','productCode')}
${hf('Generic Name','genericName')}
${hf('Batch Number','batchNumber')}
${singleTableMode
?`<div class="fg"><label class="fl">Batch Size</label><input class="fi" data-h="batchSize" value="${esc(_wo.batchSize)}" placeholder="e.g. 60 Ltrs"/></div>`
:`<div class="fg"><label class="fl">Batch Size</label><div id="disp-batchsize" class="fi" style="white-space:pre-line;height:auto;min-height:36px;line-height:1.4;background:var(--wo-surface2);cursor:default;padding-top:8px">${esc(_wo.batchSize||'—')}</div></div>`}
${hf('Total Units','totalUnits')}
${hfDate('MFG. Date','mfgDate')}
${hfDate('EXP. Date','expDate')}
${hf('Pack Size','packSize')}
${hf('Type','type')}
${hf('Market','market')}
${hf('Reference','reference')}
</div>
<label style="display:flex;align-items:center;gap:7px;font-size:12px;color:var(--wo-text2);cursor:pointer;margin:2px 0 12px">
<input type="checkbox" id="chk-components-only" ${_forceComponentsOnly?'checked':''} ${(_bomLoading||_saving)?'disabled':''}/>
Show as a single Components table (skip the Raw Material/Packing Material split)
</label>
${_bomLoading?`
<div style="margin:14px 0 2px;display:flex;align-items:center;gap:8px;font-size:11px;color:var(--wo-text3)">
<span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span> Fetching BOM & calculating…
</div>`:''}
${singleTableMode?`
<div class="sec-title"><i data-lucide="box" style="width:14px;height:14px"></i> Components</div>
<div class="tbl-wrap"><table class="wo-tbl">
<thead><tr><th>Item Code</th><th>Components</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>AR No.</th><th class="td-act"></th></tr></thead>
<tbody>${packRows}</tbody>
</table></div>
<button class="btn b-add" id="add-pack"><i data-lucide="plus" style="width:14px;height:14px"></i> Add Component</button>
`:`
<div class="sec-title"><i data-lucide="flask-conical" style="width:14px;height:14px"></i> Production Work Order — Raw Material</div>
${rawSectionHtml}
<div class="sec-title"><i data-lucide="box" style="width:14px;height:14px"></i> Production Work Order — Packing Material</div>
<div class="tbl-wrap"><table class="wo-tbl">
<thead><tr><th>Item Code</th><th>Packing Materials</th><th>Artwork No.</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.%</th><th>Qty Req.(Units)</th><th>AR No.</th><th class="td-act"></th></tr></thead>
<tbody>${packRows}</tbody>
</table></div>
<button class="btn b-add" id="add-pack"><i data-lucide="plus" style="width:14px;height:14px"></i> Add Packing Material</button>
`}
<div class="footer-row">
<button class="btn b-cancel" id="btn-cancel">${_editId?'Cancel':'Reset'}</button>
<button class="btn b-primary" id="btn-submit" ${(_saving||_bomLoading||!_bomFetched)?'disabled':''} ${!_bomFetched&&!_bomLoading?'title="Fetch the BOM first (enter/select a Product Code) before saving"':''}>${_saving?'<span class="sp"></span> Saving…':_bomLoading?'Fetching BOM…':(_editId?(_editingRejected?'Resubmit Work Order':'Update Work Order'):'Generate Work Order')}</button>
</div>
</div>
</div>`;
}
function wireCreate(){
const selSrc=q('#sel-src');if(selSrc)selSrc.onchange=function(){selectSource(this.value);};
document.querySelectorAll('[data-pick]').forEach(el=>el.onclick=()=>{const[pi,bi]=el.dataset.pick.split(':').map(Number);pickBatch(pi,bi);});
document.querySelectorAll('[data-h]').forEach(el=>{
el.oninput=function(){_wo[this.dataset.h]=this.value;if(this.dataset.h==='totalUnits')recalcMaterials();};
// Manually typing/editing the Product Code (not via the picker) also auto-fetches its BOM once you leave the field.
if(el.dataset.h==='productCode')el.onchange=function(){if(this.value.trim())fetchBOMForWO();};
});
document.querySelectorAll('[data-hdp]').forEach(btn=>btn.onclick=function(){openDateFieldPicker(this.dataset.hdp,this);});
document.querySelectorAll('[data-hdpopen]').forEach(inp=>inp.onclick=function(){openDateFieldPicker(this.dataset.hdpopen,this);});
document.querySelectorAll('[data-hdfmt]').forEach(sel=>sel.onchange=function(){
_woDateFmt=this.value;
_wo.mfgDate=reformatDate(_wo.mfgDate,this.value);
_wo.expDate=reformatDate(_wo.expDate,this.value);
render();
});
const chkCompOnly=q('#chk-components-only');
if(chkCompOnly)chkCompOnly.onchange=function(){
_forceComponentsOnly=this.checked;
if((_wo.productCode||'').trim())fetchBOMForWO(); else render();
};
document.querySelectorAll('[data-mf]').forEach(el=>el.oninput=function(){
const t=this.dataset.mt,i=+this.dataset.mi,f=this.dataset.mf,arr=(t==='raw'?_raw:_pack);
if(this.dataset.scaled==='1'){
// stdQty/qtyReq scaled cell (mg/g/Kg/Ton badge) — the typed number is
// in whatever unit the badge currently shows, so convert back to the
// row's true internal representation (grams for stdQty; the item's
// actual stock UOM for qtyReq) before storing — the value that
// actually gets saved/posted to SAP is never in the display unit.
const grams=unscaleGrams(this.value,this.dataset.scalelabel);
arr[i][f]=(f==='qtyReq')?round3(toStockQty(grams,arr[i].uom)):grams/(t==='raw'?potencyFactor(arr[i].itemCode):1);
}else{
arr[i][f]=this.value;
}
if(f==='qtyReq'){
// Raw Material's Qty Req. is a TWO-WAY field, not a manual override —
// typing here back-solves Std Qty/100ml (backCalcStdQty) so both stay
// in sync; a later Batch Size/Total Units change will then recompute
// Qty Req. from that new Std Qty exactly like normal, no "frozen" state.
backCalcStdQty(arr[i],parseFloat(_wo.totalUnits)||0);
if(arr[i].directRaw){
const stdEl=document.querySelector(`[data-mt="raw"][data-mi="${i}"][data-mf="stdQty"]`);
if(stdEl&&String(stdEl.value)!==String(arr[i].stdQty))stdEl.value=arr[i].stdQty;
}else refreshRawScaledCell(i,'stdQty');
}
else if(f==='qtyReqUnits'){
// Packing's Qty Req.(Units) is now ALSO a two-way field, same as Raw
// Material's Qty Req. — typing here back-solves Std Qty/Unit
// (backCalcStdQtyPack) instead of freezing the row as manual.
backCalcStdQtyPack(arr[i],parseFloat(_wo.totalUnits)||0);
const stdEl=document.querySelector(`[data-mt="pack"][data-mi="${i}"][data-mf="stdQtyPerUnit"]`);
if(stdEl&&String(stdEl.value)!==String(arr[i].stdQtyPerUnit))stdEl.value=arr[i].stdQtyPerUnit;
}
else if(f==='stdQtyDispUnit'||f==='qtyReqDispUnit'){
// Just a display-unit choice for Qty Req./100 ml or Qty Req. — re-render
// that same cell in the newly picked unit; the true grams value, the
// other field, and everything that gets saved/posted are unaffected.
refreshRawScaledCell(i,f==='stdQtyDispUnit'?'stdQty':'qtyReq');
}
else if(['stdQty','ovg','uom','stdQtyPerUnit','stdQtyUnit','ovgPercent'].includes(f)){
arr[i]._manual=false;recalcMaterials();
if(t==='raw'&&f==='stdQty')refreshRawScaledCell(i,'qtyReq');
}});
// Per-solution Batch Size input is a TWO-WAY field, not a manual override —
// typing here back-solves solPerUnitLitres (v ÷ current Total Units) so it
// stays in sync, same as Raw Material's Qty Req. and Packing's Qty
// Req.(Units): a later Total Units change recomputes Batch Size from that
// new ratio instead of freezing it. Only when Total Units is blank/0 (no
// ratio to back-solve) does it fall back to a flat manual override.
document.querySelectorAll('[data-solbs]').forEach(el=>el.oninput=function(){
const code=this.dataset.solbs,v=this.value,tu=parseFloat(_wo.totalUnits);
const vNum=parseFloat(v);
_raw.forEach(r=>{if(r.solCode===code){
r.solBatchSize=v;
if(!isNaN(vNum)&&!isNaN(tu)&&tu>0){r.solPerUnitLitres=vNum/tu;r.solBSManual=false;}
else r.solBSManual=true;
}});
recalcMaterials();
});
document.querySelectorAll('[data-addrawgroup]').forEach(b=>b.onclick=function(){
const code=this.dataset.addrawgroup,existing=_raw.find(r=>r.solCode===code);
_raw.push(Object.assign(rawBlank(),{solCode:code,solName:existing?existing.solName:'',
solPerUnitLitres:existing?existing.solPerUnitLitres:'',solBatchSize:existing?existing.solBatchSize:'',solBSManual:existing?existing.solBSManual:false}));
render();
});
q('#add-pack').onclick=()=>{_pack.push(packBlank());render();};
document.querySelectorAll('[data-rmraw]').forEach(b=>b.onclick=()=>{const i=+b.dataset.rmraw,r=_raw[i];if(r&&(r.itemCode||'').trim()&&!confirm('Remove this raw material?'))return;_raw.splice(i,1);if(!_raw.length)_raw=[rawBlank()];render();});
document.querySelectorAll('[data-rmpack]').forEach(b=>b.onclick=()=>{const i=+b.dataset.rmpack,r=_pack[i];if(r&&(r.itemCode||'').trim()&&!confirm('Remove this item?'))return;_pack.splice(i,1);if(!_pack.length)_pack=[packBlank()];render();});
q('#btn-cancel').onclick=()=>{if(_editId){_editId=null;_editingRejected=false;_tab='view';loadList();}else{newWO();}};
q('#btn-submit').onclick=submit;
// Item search on material rows (floating dropdown)
document.querySelectorAll('[data-search]').forEach(inp=>{
const [type,idxs]=inp.dataset.search.split(':');const idx=+idxs;
inp.oninput=function(){
const v=this.value.trim();const arr=type==='raw'?_raw:_pack;arr[idx].itemCode=this.value;
if(v.length<2){hideDD();return;}
clearTimeout(_searchTimer[inp.dataset.search]);
_searchTimer[inp.dataset.search]=setTimeout(async()=>{
showDD(inp,'<div class="sd-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span></div>');
const items=await fetchItems(v);
if(!items.length){showDD(inp,'<div class="sd-msg">No items found</div>');return;}
const dd=getDD();dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
row.onmousedown=e=>{e.preventDefault();selectMaterial(type,idx,it,inp);hideDD();};
dd.appendChild(row);
});
positionDD(inp);
},250);
};
inp.onblur=()=>setTimeout(hideDD,150);
});
}
function selectMaterial(type,idx,it,inp){
const arr=type==='raw'?_raw:_pack;const nameField=type==='raw'?'rawMaterial':'packingMaterial';
const row=arr[idx];
const changed=row.itemCode&&row.itemCode!==it.ItemCode;
if(changed){
// Item Code actually changed to a DIFFERENT item — the old row's
// spec/qty/UOM/etc. belonged to the PREVIOUS item and must not silently
// stick around mixed in with the newly picked one. Reset everything
// item-specific, but keep the raw row's Solution grouping (solCode/
// solName/solBatchSize/…) since that describes which group this slot
// belongs to, not which item occupies it.
if(type==='raw')Object.assign(row,{spec:'',stdQty:'',uom:'',ovg:'',qtyReq:'',weighingBalanceId:'',arNo:'',_manual:false});
else Object.assign(row,{artworkNo:'',stdQtyPerUnit:'',stdQtyUnit:'',ovgPercent:'',qtyReqUnits:'',uom:'',qtyUnitLabel:'',arNo:'',_manual:false});
}
row.itemCode=it.ItemCode;
row[nameField]=it.ItemName||''; // always take the newly-picked item's own name
row.uom=it.UoM||''; // stock UOM from SAP — used by the raw-material grams→stock-unit conversion (toStockQty); pack rows use their own Unit dropdown instead (see UNIT_FAMILIES)
if(inp)inp.value=it.ItemCode;
recalcMaterials();
render();
}
// Floating dropdown helpers
function getDD(){let dd=document.getElementById('float-dd');if(!dd){dd=document.createElement('div');dd.id='float-dd';dd.className='search-dd';document.body.appendChild(dd);}return dd;}
function positionDD(inp){const dd=getDD(),r=inp.getBoundingClientRect();const z=parseFloat(getComputedStyle(document.body).zoom)||1;dd.style.left=(r.left/z)+'px';dd.style.top=((r.bottom+3)/z)+'px';dd.style.width=(Math.max(r.width,220)/z)+'px';}
function showDD(inp,html){const dd=getDD();dd.innerHTML=html;dd.style.display='block';positionDD(inp);_ddInput=inp;}
function hideDD(){const dd=document.getElementById('float-dd');if(dd)dd.style.display='none';_ddInput=null;}
window.addEventListener('scroll',()=>{if(_ddInput)positionDD(_ddInput);},true);
window.addEventListener('resize',hideDD);
// When there's no real raw material (singleTableMode — the simplified
// "Components" table), each Components row is ALSO saved as a raw material
// so downstream consumers that specifically look at rawMaterials (e.g.
// Production Order material issuance) can still find these items — the
// screen keeps showing just the one Components table; this only affects
// what gets saved. Safe to fully replace _raw's contents here since
// singleTableMode by definition means _raw has no real items anyway.
function packRowsAsRawMaterials(){
return _pack.filter(r=>(r.itemCode||'').trim()).map(r=>Object.assign(rawBlank(),{
itemCode:r.itemCode, rawMaterial:r.packingMaterial, uom:r.uom,
stdQty:r.stdQtyPerUnit, ovg:r.ovgPercent, qtyReq:r.qtyReqUnits,
}));
}
async function submit(){
if(!(_wo.productName||_wo.productCode)){toast('Enter Product Name / Code','err');return;}
// Defense in depth — the Save/Resubmit button is already disabled until
// the BOM has been fetched (see render()), but guard here too in case
// this ever gets called some other way (e.g. a stray Enter-key submit).
if(_bomLoading){toast('BOM is still being fetched — please wait','err');return;}
if(!_bomFetched){toast('Fetch the BOM first before saving','err');return;}
if(!validMfgExp(_wo.mfgDate)){toast(`Invalid MFG date "${_wo.mfgDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
if(!validMfgExp(_wo.expDate)){toast(`Invalid EXP date "${_wo.expDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
_saving=true;render();
const isComponentsOnly=singleTableModeNow();
const rawForSave=isComponentsOnly?packRowsAsRawMaterials():_raw;
const payload=Object.assign({},_wo,{intimationId:_intimationId,rawMaterials:rawForSave,packingMaterials:_pack,componentsOnly:isComponentsOnly,company:_co});
try{
if(_editId){
const r=await api('PUT','/work-orders/'+_editId,payload);
_saving=false;_editId=null;
toast('Work order '+(r.data?.woNo||'')+(_editingRejected?' resubmitted for approval':' updated'));
_editingRejected=false;_tab='view';loadList();
}else{
const r=await api('POST','/work-orders',payload);
_created=r.data;_saving=false;render();
}
}catch(e){_saving=false;render();toast(e.message,'err');}
}
function newWO(){_editId=null;_editingRejected=false;_created=null;_srcId='';_pickedKey='';_srcBatchVolume='';_srcNoRequirement=false;_forceComponentsOnly=false;_woDateFmt=null;_intimationId=null;_bomFetched=false;_wo=blankHeader();_raw=[rawBlank()];_pack=[packBlank()];_tab='create';render();loadIntimations();}
function startEdit(w){
_editId=w.id;_created=null;_tab='create';_intimationId=w.intimationId;_editingRejected=(w.status==='REJECTED');
_rejectReason=_editingRejected?[...(w.workflowLog||[])].reverse().find(l=>l.action==='rejected')?.remarks||'':'';
_woDateFmt=null; // redetect MFG/EXP format fresh from this record's saved dates
_wo={reference:w.reference||'',productName:w.productName||'',productDesc:w.productDesc||'',genericName:w.genericName||'',productCode:w.productCode||'',batchNumber:w.batchNumber||'',batchSize:w.batchSize||'',totalUnits:w.totalUnits||'',mfgDate:w.mfgDate||'',expDate:w.expDate||'',packSize:w.packSize||'',type:w.type||'',market:w.market||''};
// A rejected order saved in Components-only (flat) mode might just be a
// stale snapshot from before an Item-Group-Rules/BOM-classification fix
// (e.g. a Solution ingredient that used to get misrouted, collapsing the
// whole BOM into one flat table). Since resubmitting already means fully
// re-reviewing the order, re-fetch the BOM fresh here instead of trusting
// that old flat snapshot — a genuine Raw Material/Packing Material split
// then shows up correctly instead of staying wrongly flattened forever.
// (A non-rejected edit, or one that was never flattened, keeps loading the
// saved rows as before — no need to touch what's already correct.)
if(_editingRejected&&w.componentsOnly&&_wo.productCode){
_forceComponentsOnly=false; // try for a real split now, not the old flat snapshot
_bomFetched=false;_raw=[rawBlank()];_pack=[packBlank()];
render();window.scrollTo(0,0);
fetchBOMForWO();
return;
}
_forceComponentsOnly=!!w.componentsOnly; // reflect the loaded record's actual saved mode in the checkbox
_bomFetched=(w.rawMaterials&&w.rawMaterials.length>0)||(w.packingMaterials&&w.packingMaterials.length>0); // saved record → infer whether raw material exists
// A record saved in Components-only mode has its RAW_MATERIALS populated
// with a save-time duplicate of the Components rows (see submit()) — that's
// for downstream consumers, not for display, so don't load it into _raw
// here or singleTableModeNow() would see non-empty _raw and incorrectly
// flip the page back to the two-section Raw Material/Packing layout.
_raw=(!w.componentsOnly&&w.rawMaterials&&w.rawMaterials.length)?w.rawMaterials.map(r=>Object.assign(rawBlank(),r)):[rawBlank()];
_pack=(w.packingMaterials&&w.packingMaterials.length)?w.packingMaterials.map(r=>Object.assign(packBlank(),r)):[packBlank()];
recalcMaterials(); // regenerate header Batch Size summary from loaded solution groups
render();window.scrollTo(0,0);
}
function renderSuccess(app){
app.innerHTML=`<div class="card" style="max-width:520px;margin:40px auto"><div class="suc-box">
<div class="suc-icon"><i data-lucide="check" style="width:30px;height:30px"></i></div>
<div class="suc-title">Work Order Generated</div>
<div class="suc-sub">Prepared By QA · pending ${esc(_created.currentStep||'next approval')}</div>
<div class="ref-pill" style="font-size:16px;padding:8px 18px">${esc(_created.woNo)}</div>
<div class="footer-row" style="justify-content:center;border:none;margin-top:24px">
<button class="btn b-cancel" id="btn-view">View All</button>
<button class="btn b-primary" id="btn-another">+ New Work Order</button>
</div></div></div>`;
q('#btn-another').onclick=()=>{newWO();};
q('#btn-view').onclick=()=>{_created=null;_tab='view';loadList();};
}
// ─────────── VIEW ───────────
async function loadList(){_listLoading=true;render();try{const r=await api('GET',`/work-orders?company=${encodeURIComponent(_co)}`);_list=r.data||[];}catch(e){toast(e.message,'err');_list=[];}_listLoading=false;render();}
function stepper(w){
// A step this specific work order auto-skipped (WORK_ORDER_SKIP_QC was on
// when it advanced past it) is left out of the stepper entirely — not
// just its "who" label — and the remaining steps are renumbered STEP 1,
// 2, 3… based on what's actually shown, not their original position in
// the full 5-step chain. `cls` (done/current highlighting) still keys off
// the ORIGINAL index `i` against w.stage, since that's unaffected by display order.
const skipped=new Set((w.workflowLog||[]).filter(l=>l.action==='auto-skipped').map(l=>l.step));
const visible=STEPS.map((s,i)=>({s,i})).filter(({s})=>!skipped.has(s));
return `<div class="wf">${visible.map(({s,i},displayIdx)=>{
let cls='';
if(w.status==='REJECTED'&&i===w.stage)cls='';
else if(i<w.stage)cls='done';
else if(i===w.stage&&w.status==='IN_PROGRESS')cls='cur';
const logE=(w.workflowLog||[]).find(l=>(l.step===s||(STEP_ALIASES[s]||[]).includes(l.step))&&l.action!=='rejected'&&l.action!=='auto-skipped');
const who=logE?`${esc(logE.byName||logE.by||'')}`:'';
return `<div class="wf-step ${cls}"><div class="wf-num">STEP ${displayIdx+1}</div><div class="wf-lbl">${esc(s)}</div>${who?`<div class="wf-who">✓ ${who}</div>`:''}</div>`;
}).join('')}</div>`;
}
// Compact card only — WO No, status, and a few meta fields. No inline detail,
// no action buttons here; clicking the card opens the full detail popup
// (openWODetail) where the stepper/tables/history AND Approve/Reject/Edit/
// Delete all live instead.
// Derives a WO's coarse status ('APPROVED'/'REJECTED'/'IN_PROGRESS') for the
// status filter chips — same three buckets the status badge already uses.
function woStatusBucket(w){return w.status==='APPROVED'?'APPROVED':w.status==='REJECTED'?'REJECTED':'IN_PROGRESS';}
function filteredWOList(){
let list=_list;
if(_viewStatus)list=list.filter(w=>woStatusBucket(w)===_viewStatus);
const s=_viewSearch.trim().toUpperCase();
if(s)list=list.filter(w=>
(w.woNo||'').toUpperCase().includes(s)||
(w.productCode||'').toUpperCase().includes(s)||
(w.productName||'').toUpperCase().includes(s)||
(w.batchNumber||'').toUpperCase().includes(s)||
(w.reference||'').toUpperCase().includes(s));
return list;
}
function renderView(){
if(_listLoading)return `<div class="card"><div class="empty-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span> Loading…</div></div>`;
if(!_list.length)return `<div class="card"><div class="empty-msg">No work orders yet. Generate one from Batch Issuance or the “New Work Order” tab.</div></div>`;
const list=filteredWOList();
const chips=[['','All'],['IN_PROGRESS','Pending'],['APPROVED','Approved'],['REJECTED','Rejected']];
const bar=`<div class="vf-bar">
<div class="vf-search"><i data-lucide="search" style="width:14px;height:14px"></i><input id="vf-search" placeholder="Search WO No, product, batch, reference…" value="${esc(_viewSearch)}"/></div>
<div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}">${lbl}</button>`).join('')}</div>
<span class="vf-count">${list.length} of ${_list.length}</span>
</div>`;
const grid=list.length
? `<div class="wo-grid">${list.map(w=>{
const stCls=w.status==='APPROVED'?'st-appr':w.status==='REJECTED'?'st-rej':'st-prog';
const stLbl=w.status==='APPROVED'?'Approved':w.status==='REJECTED'?'Rejected':('Pending: '+(STEPS[w.stage]||''));
return `
<div class="wo-card" data-id="${w.id}">
<div class="wc-top">
<span class="wc-no">${esc(w.woNo)}</span>
<span class="status-badge ${stCls}">${esc(stLbl)}</span>
</div>
<div class="wc-icon"><i data-lucide="package" style="width:20px;height:20px"></i></div>
<div class="wc-prod" title="${esc(w.productName||'')}">${esc(w.productName||w.productCode)}</div>
<div class="wc-code">${esc(w.productCode)}</div>
<div class="wc-meta">
<span><i data-lucide="hash" style="width:12px;height:12px;flex-shrink:0"></i> Batch ${esc(w.batchNumber||'—')}</span>
<span><i data-lucide="link" style="width:12px;height:12px;flex-shrink:0"></i> Ref ${esc(w.reference||'—')}</span>
<span><i data-lucide="user" style="width:12px;height:12px;flex-shrink:0"></i> ${esc(w.createdByName||w.createdBy||'—')}</span>
</div>
</div>`;
}).join('')}</div>`
: `<div class="card"><div class="empty-msg">No work orders match your search/filter.</div></div>`;
return bar+grid;
}
function wireView(){
document.querySelectorAll('.wo-card').forEach(c=>c.onclick=()=>openWODetail(+c.dataset.id));
const s=q('#vf-search');
if(s){
s.oninput=function(){
_viewSearch=this.value;
const pos=this.selectionStart;
q('#body').innerHTML=renderView();
wireView();
const s2=q('#vf-search');
if(s2){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons();
};
}
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();});
}
// ─────────── DETAIL / ACTION POPUP ───────────
function closeWODetail(){document.getElementById('wo-detail-modal').classList.remove('show');}
// ── Reject reason modal (replaces the browser's native prompt()) ──
let _rejectTargetId=null, _rejectReasonVal='';
function openRejectModal(id){
_rejectTargetId=id;_rejectReasonVal='';
renderRejectModal();
const modal=document.getElementById('reject-modal');
modal.classList.add('show');
setTimeout(()=>document.getElementById('reject-reason-inp')?.focus(),50);
}
function closeRejectModal(){document.getElementById('reject-modal').classList.remove('show');_rejectTargetId=null;}
function renderRejectModal(){
const inner=document.getElementById('reject-modal-inner');
const blank=!_rejectReasonVal.trim();
inner.innerHTML=`
<div class="wo-modal-hd"><i data-lucide="x-circle" style="width:16px;height:16px;color:var(--wo-red)"></i> Reject Work Order
<button class="wo-modal-close" id="reject-modal-x"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="wo-modal-bd">
<label class="fl" style="display:block;margin-bottom:6px">Reason for rejection <span style="color:var(--wo-red)">*</span></label>
<textarea id="reject-reason-inp" class="fi" rows="4" placeholder="Explain what needs to be corrected…" style="width:100%;resize:vertical;font-family:inherit">${esc(_rejectReasonVal)}</textarea>
<div style="font-size:11px;color:var(--wo-text3);margin-top:6px">This is shown to whoever prepared the Work Order, so they know what to fix before resubmitting.</div>
</div>
<div class="wo-modal-ft">
<button class="wc-btn" id="reject-modal-cancel">Cancel</button>
<button class="wc-btn del" id="reject-modal-confirm" ${blank?'disabled':''}><i data-lucide="x" style="width:13px;height:13px"></i> Reject Work Order</button>
</div>`;
if(window.lucide&&lucide.createIcons)lucide.createIcons();
document.getElementById('reject-modal-x').onclick=closeRejectModal;
document.getElementById('reject-modal-cancel').onclick=closeRejectModal;
const ta=document.getElementById('reject-reason-inp');
ta.oninput=function(){
_rejectReasonVal=this.value;
document.getElementById('reject-modal-confirm').disabled=!this.value.trim();
};
document.getElementById('reject-modal-confirm').onclick=()=>{
if(!_rejectReasonVal.trim())return;
const id=_rejectTargetId;
closeRejectModal();
doAction(id,'reject',_rejectReasonVal.trim());
};
}
// Formats an issuance-info signer {name,user,at} as "Name · date", or '—'.
function issSigDisp(sig){ return sig&&sig.name ? `${esc(sig.name)}<div style="font-size:9.5px;color:var(--wo-text4)">${fmtDT(sig.at)}</div>` : '<span style="color:var(--wo-text4)">—</span>'; }
function num3wo(v){ if(v==null||v==='')return '—'; const n=Number(v); if(!isFinite(n)||n===0)return '—'; return String(Math.round(n*1000)/1000); }
async function openWODetail(id){
const w=_list.find(x=>x.id===id);
if(!w){toast('Work order not found','err');return;}
const modal=document.getElementById('wo-detail-modal'),inner=document.getElementById('wo-modal-inner');
modal.classList.add('show');
inner.innerHTML='<div class="wo-modal-bd" style="text-align:center;padding:40px"><span class="sp"></span></div>';
// Qty issued stays SAP-derived per item (linked Production Order's live
// issued qty). Issued By/Date, Received By, Verified By are now PER-ROW
// one-click stamps (see /verify-work-order) — different items can be
// issued/received/verified on different days by different people, so each
// row carries its own signer/date rather than one shared value.
let iss={qtyByItem:{}};
try{const r=await api('GET',`/work-orders/${id}/issuance-info?company=${encodeURIComponent(_co)}`);iss=r.data||iss;}catch(_e){}
const rowSig=(row,which)=>{const at=row[which+'At'];if(!at)return null;return{name:row[which+'ByName']||row[which+'By'],at};};
// U_NewItemCode (OITM) for the header Product Code only — shown as "(code)"
// right after it, when SAP has a value.
let newItemCode='';
try{const ni=await api('GET',`/sap/lookup/new-item-codes?codes=${encodeURIComponent(w.productCode||'')}&company=${encodeURIComponent(_co)}`);
newItemCode=(ni.data&&ni.data[w.productCode])||'';
}catch(_e){}
// Linked SAP Production Order's own physical stage (Planned → Released →
// Issued → Received → Transferred → Closed) — a SEPARATE lifecycle from
// this Work Order's own 4-step QA approval chain above. Shown here in the
// View popup only, not Print/PDF (those are the paperwork/BMR record, not
// a live SAP status feed).
let pwoStageLbl='';
try{
const pr=await api('GET',`/production-orders?workOrderId=${id}&company=${encodeURIComponent(_co)}`);
const po=(pr.data||[]).find(p=>!p.isDeleted);
if(po){
const PO_STAGE_LABELS=['PWO is Planned','PWO is Released','Issued to Production','Received from Production','Transferred to Finished Goods'];
pwoStageLbl=po.status==='CLOSED'?'PWO is Closed':po.status==='REJECTED'?'PWO Rejected':(PO_STAGE_LABELS[po.stage]||'PWO Completed');
}
}catch(_e){}
const stCls=w.status==='APPROVED'?'st-appr':w.status==='REJECTED'?'st-rej':'st-prog';
const stLbl=w.status==='APPROVED'?'Approved':w.status==='REJECTED'?'Rejected':('Pending: '+(STEPS[w.stage]||''));
const issThs='<th>Qty Issued</th><th>Issued By /Date</th><th>Received By</th><th>Verified By</th>';
// Raw Material items are never SAP Production Order lines themselves (they're
// the exploded recipe of a Solution/SFG item, which IS the PO line) — so
// their Qty Issued always comes from the server-PERSISTED row.qtyIssued
// (computed either way per Admin → System Settings → "Raw Material Qty
// Issued Source" — see routes/sap.js's /issue-production and
// routes/workOrders.js's /mark). Packing Material/Components rows ARE real
// PO lines, so under the default 'issue_for_production' they read live off
// iss.qtyByItem as before; only under 'mark_issued' do they ALSO switch to
// the persisted row.qtyIssued, matching Raw Material's behavior.
const issCells=(row,isRaw)=>{
const usePersisted=isRaw||_woQtyIssuedSource==='mark_issued';
const qty=usePersisted
?`<td>${row.qtyIssued?(isRaw?rawScaledDisp(row,'qtyReq',false,'qtyIssued'):num3wo(row.qtyIssued)):''}</td>`
:`<td>${num3wo(iss.qtyByItem&&iss.qtyByItem[(row.itemCode||'').trim()])}</td>`;
return qty+`<td>${issSigDisp(rowSig(row,'issued'))}</td><td>${issSigDisp(rowSig(row,'received'))}</td><td>${issSigDisp(rowSig(row,'verified'))}</td>`;
};
// Group saved raw materials by Solution for display, mirroring the entry form.
const rawGroupMap=new Map();
(w.rawMaterials||[]).forEach(r=>{const k=r.solCode||'';if(!rawGroupMap.has(k))rawGroupMap.set(k,{name:r.solName||'',batchSize:r.solBatchSize||'',rows:[]});rawGroupMap.get(k).rows.push(r);});
const raw=[...rawGroupMap.values()].map(g=>{
return `
<div style="font-size:11.5px;font-weight:700;color:var(--wo-text2);margin:8px 0 3px">${g.name?esc(g.name):'Raw Materials'}${g.batchSize?` · Batch Size ${esc(g.batchSize)} Ltr`:''}</div>
<table class="dv-tbl"><thead><tr><th>Item Code</th><th>Raw Materials</th><th>Spec</th><th>Qty Req./100 ml</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>Weighing Balance ID</th><th>AR No.</th>${issThs}</tr></thead>
<tbody>${g.rows.map((r)=>`<tr><td style="font-family:var(--wo-mono)">${esc(r.itemCode)}</td><td>${esc(r.rawMaterial)}</td><td>${esc(r.spec)}</td><td>${rawScaledDisp(r,'stdQty',true)}</td><td>${rawScaledUnit(r)}</td><td>${esc(r.ovg)}</td><td>${rawScaledDisp(r,'qtyReq')}</td><td>${esc(r.weighingBalanceId)}</td><td>${esc(r.arNo)}</td>${issCells(r,true)}</tr>`).join('')}</tbody></table>`;
}).join('');
// Authoritative flag, not inferred from rawMaterials.length — a
// componentsOnly save also writes a legacy-compatible copy of the same
// components INTO rawMaterials (see packRowsAsRawMaterials()'s doc comment
// in wireCreate()), so rawMaterials is never actually empty for these
// orders; checking its length here would wrongly render the split view.
const singleTableMode=!!w.componentsOnly;
const packRows=w.packingMaterials||[];
const pack=packRows.map((r)=>{
const stdDisp=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qtyDisp=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
return singleTableMode
?`<tr><td style="font-family:var(--wo-mono)">${esc(r.itemCode)}</td><td>${esc(r.packingMaterial)}</td><td>${stdDisp}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qtyDisp}</td><td>${esc(r.arNo)}</td>${issCells(r)}</tr>`
:`<tr><td style="font-family:var(--wo-mono)">${esc(r.itemCode)}</td><td>${esc(r.packingMaterial)}</td><td>${esc(r.artworkNo)}</td><td>${stdDisp}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qtyDisp}</td><td>${esc(r.arNo)}</td>${issCells(r)}</tr>`;
}).join('');
const canAct=w.status==='IN_PROGRESS';
// Mirror the server's exact checks (routes/workOrders.js) so buttons a
// user can't actually use never even render. 'approve' (performing the
// sign-off/check at this stage) is a DIFFERENT capability from 'edit'
// (changing the record's own fields) — someone can be trusted to check a
// stage off without being able to alter the underlying data.
const curStepKey=STEP_KEYS[w.stage];
const canApproveHere=canAct&&curStepKey&&hasStepPerm(`work_order:${curStepKey}`,'approve');
// A REJECTED order is ALSO editable — as a combined edit+resubmit that
// restarts the approval chain from step 1, gated on step 1's own 'edit'
// permission (matches routes/workOrders.js's PUT /:id REJECTED branch).
const canEditHere=w.status==='REJECTED'?hasStepPerm(`work_order:${STEP_KEYS[0]}`,'edit'):(canAct&&curStepKey&&hasStepPerm(`work_order:${curStepKey}`,'edit'));
const canDeleteHere=hasStepPerm(`work_order:${curStepKey||'prepared_qa'}`,'delete');
// Admin-only override: recall a FULLY APPROVED order back to QA for
// editing (no approval step required — an admin action, not a normal
// in-flight rejection). Re-uses the REJECTED status, so once sent back it
// shows up with the existing "Edit & Resubmit" button, exactly like a
// normal rejection would.
const canSendToQa=w.status==='APPROVED'&&_user&&(_user.role==='admin'||_user.role==='system_admin');
const footerBtns=`
<button class="wc-btn" onclick="window.open('/work-order-print?id=${w.id}','_blank')"><i data-lucide="printer" style="width:13px;height:13px"></i> Print</button>
<button class="wc-btn" onclick="downloadWoPdf(${w.id},'${esc(w.woNo)}')"><i data-lucide="file-down" style="width:13px;height:13px"></i> Download PDF</button>
${CAN_DUPLICATE_WO?`<button class="wc-btn" data-dup="${w.id}" title="Create a new Work Order — same item selection, fresh batch, starts at Step 1"><i data-lucide="copy" style="width:13px;height:13px"></i> Duplicate</button>`:''}
${canApproveHere?`<button class="wc-btn" data-appr="${w.id}" style="border-color:#b3dab3;color:#16a34a"><i data-lucide="check" style="width:13px;height:13px"></i> Approve step</button>
<button class="wc-btn del" data-rej="${w.id}"><i data-lucide="x" style="width:13px;height:13px"></i> Reject</button>`:''}
${canSendToQa?`<button class="wc-btn" data-sendqa="${w.id}" style="border-color:#fbd38d;color:#b45309" title="Admin: recall this fully approved Work Order back to QA for editing"><i data-lucide="undo-2" style="width:13px;height:13px"></i> Send to QA</button>`:''}
${canEditHere?`<button class="wc-btn" data-edit="${w.id}"><i data-lucide="pencil" style="width:13px;height:13px"></i> ${w.status==='REJECTED'?'Edit & Resubmit':'Edit'}</button>`:''}
${canDeleteHere?`<button class="wc-btn del" data-del="${w.id}"><i data-lucide="trash-2" style="width:13px;height:13px"></i> Delete</button>`:''}`;
inner.innerHTML=`
<div class="wo-modal-hd">
<span class="wc-no">${esc(w.woNo)}</span>
<span class="status-badge ${stCls}">${esc(stLbl)}</span>
${pwoStageLbl?`<span class="status-badge" style="background:var(--wo-surface3);color:var(--wo-text2);border:1px solid var(--wo-border2)">${esc(pwoStageLbl)}</span>`:''}
<button class="wo-modal-close" id="wo-modal-close"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="wo-modal-bd">
${stepper(w)}
<div class="wc-meta" style="margin-top:2px">
<span><i data-lucide="package" style="width:13px;height:13px;vertical-align:-2px"></i> ${esc(w.productCode)}${newItemCode&&newItemCode.trim().toUpperCase()!==(w.productCode||'').trim().toUpperCase()?` (${esc(newItemCode)})`:''} ${esc(w.productName||'')}</span>
<span><i data-lucide="link" style="width:13px;height:13px;vertical-align:-2px"></i> Ref ${esc(w.reference||'—')}</span>
<span style="color:var(--wo-text4)">${esc(w.createdByName||w.createdBy||'')}</span>
</div>
<div class="kv">
<div><div class="k">Generic Name</div>${esc(w.genericName||'—')}</div>
<div><div class="k">Batch Size</div><div style="white-space:pre-line">${esc(w.batchSize||'—')}</div></div>
<div><div class="k">Total Units</div>${esc(w.totalUnits||'—')}</div>
<div><div class="k">MFG. Date</div>${esc(w.mfgDate||'—')}</div>
<div><div class="k">EXP. Date</div>${esc(w.expDate||'—')}</div>
<div><div class="k">Pack Size</div>${esc(w.packSize||'—')}</div>
<div><div class="k">Type</div>${esc(w.type||'—')}</div>
<div><div class="k">Market</div>${esc(w.market||'—')}</div>
</div>
${singleTableMode?`
<div class="sec-title" style="margin-top:6px">Components</div>
<table class="dv-tbl"><thead><tr><th>Item Code</th><th>Components</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>AR No.</th>${issThs}</tr></thead><tbody>${pack||'<tr><td colspan="11" style="color:var(--wo-text3)">—</td></tr>'}</tbody></table>
`:`
<div class="sec-title" style="margin-top:6px">Raw Material</div>
${raw||'<div style="color:var(--wo-text3);font-size:12px">—</div>'}
<div class="sec-title">Packing Material</div>
<table class="dv-tbl"><thead><tr><th>Item Code</th><th>Packing Materials</th><th>Artwork No.</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.%</th><th>Qty Req.(Units)</th><th>AR No.</th>${issThs}</tr></thead><tbody>${pack||'<tr><td colspan="12" style="color:var(--wo-text3)">—</td></tr>'}</tbody></table>
`}
${(()=>{const visibleLog=(w.workflowLog||[]).filter(l=>l.action!=='auto-skipped');return visibleLog.length?`<div class="sec-title">Workflow History</div>${visibleLog.map(l=>`<div style="font-size:11.5px;color:var(--wo-text3);margin-bottom:2px">${l.action==='rejected'?'✗':'✓'} <strong style="color:var(--wo-text2)">${esc(l.step)}</strong> — ${esc(l.byName||l.by||'')} · ${fmtDT(l.at)}${l.remarks?` · ${esc(l.remarks)}`:''}</div>`).join('')}`:'';})()}
</div>
${footerBtns.trim()?`<div class="wo-modal-ft">${footerBtns}</div>`:''}
`;
document.getElementById('wo-modal-close').onclick=closeWODetail;
document.querySelectorAll('[data-appr]').forEach(b=>b.onclick=()=>doAction(+b.dataset.appr,'approve'));
document.querySelectorAll('[data-rej]').forEach(b=>b.onclick=()=>openRejectModal(+b.dataset.rej));
document.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{closeWODetail();const ww=_list.find(x=>x.id===+b.dataset.edit);if(ww)startEdit(ww);});
document.querySelectorAll('[data-del]').forEach(b=>b.onclick=()=>{const ww=_list.find(x=>x.id===+b.dataset.del);if(ww&&confirm('Delete work order '+ww.woNo+'?')){closeWODetail();deleteWO(ww.id,ww.woNo);}});
document.querySelectorAll('[data-dup]').forEach(b=>b.onclick=()=>{const ww=_list.find(x=>x.id===+b.dataset.dup);if(ww)duplicateWO(ww);});
document.querySelectorAll('[data-sendqa]').forEach(b=>b.onclick=()=>sendWoBackToQa(+b.dataset.sendqa));
modal.classList.add('show');
if(window.lucide&&lucide.createIcons)lucide.createIcons();
}
async function doAction(id,action,remarks){
try{const r=await api('PATCH','/work-orders/'+id+'/action',{action,remarks:remarks||''});
const w=r.data;toast('Work order '+(w.status==='APPROVED'?'fully approved':w.status==='REJECTED'?'rejected':'advanced'));closeWODetail();loadList();}
catch(e){toast(e.message,'err');}
}
async function deleteWO(id,no){try{await api('DELETE','/work-orders/'+id);toast('Work order '+no+' deleted');closeWODetail();loadList();}catch(e){toast(e.message,'err');}}
// Admin-only: recall a fully approved Work Order back to QA for editing —
// see routes/workOrders.js's POST /:id/send-to-qa.
async function sendWoBackToQa(id){
const remarks=prompt('Send this Work Order back to QA for editing?\n\nOptional remarks (why):','');
if(remarks===null)return; // cancelled
try{
await api('POST','/work-orders/'+id+'/send-to-qa',{remarks});
toast('Sent back to QA — it now shows as Rejected, ready to Edit & Resubmit');
closeWODetail();loadList();
}catch(e){toast(e.message,'err');}
}
// ─────────── DUPLICATE WORK ORDER ───────────
// Copies the item SELECTION only (which Raw Material/Packing Material items,
// and their ratios) into a brand-new Work Order — batch-specific facts
// (Batch No., MFG/EXP, every issuance stamp, qtyIssued) never carry over,
// since those genuinely belong to a batch that hasn't happened yet. Because
// a Work Order must trace to a Batch Intimation, this hands off through
// Batch Issuance's own "Without Intimation" tab first (pre-filled with the
// product/qty) so the user still confirms real per-run facts there, then
// lands back here pre-filled with the item selection — see
// public/batch-issuance.html's own half of this hand-off.
function stripDuplicateRow(r){
const c={...r};
delete c.qtyIssued; delete c.issuedAt; delete c.issuedBy; delete c.issuedByName;
delete c.receivedAt; delete c.receivedBy; delete c.receivedByName;
delete c.verifiedAt; delete c.verifiedBy; delete c.verifiedByName;
c.arNo=''; // Analytical Report No. — issued fresh per real batch, never reused
return c;
}
function duplicateWO(w){
if(!confirm(`Duplicate ${w.woNo}?\n\nThis copies its item selection into a new Batch Intimation + Work Order — you'll confirm Batch No., Issue Date and MFG/EXP on the next screen. The new Work Order starts fresh at Step 1.`))return;
const isComponentsOnly=!!w.componentsOnly;
const payload={
productCode:w.productCode||'', productName:w.productName||'', productDesc:w.productDesc||'',
genericName:w.genericName||'', packSize:w.packSize||'', type:w.type||'', market:w.market||'',
totalUnits:w.totalUnits||'', componentsOnly:isComponentsOnly,
rawMaterials:isComponentsOnly?[]:(w.rawMaterials||[]).map(stripDuplicateRow),
packingMaterials:(w.packingMaterials||[]).map(stripDuplicateRow),
};
try{sessionStorage.setItem('wo_duplicate_payload',JSON.stringify(payload));}catch(_e){toast('Could not start duplicate — storage unavailable','err');return;}
window.location.href='/batch-issuance?duplicate=1';
}
// Consumes the sessionStorage payload set by duplicateWO() above, once the
// user has picked the fresh Intimation batch this new WO is generated from
// (pickBatch() calls this right after its own header prefill). Single-use —
// removed as soon as read, so it can never leak into an unrelated later WO.
function applyDuplicatePayloadIfAny(){
let raw;try{raw=sessionStorage.getItem('wo_duplicate_payload');}catch(_e){raw=null;}
if(!raw)return;
try{sessionStorage.removeItem('wo_duplicate_payload');}catch(_e){}
let d;try{d=JSON.parse(raw);}catch(_e){return;}
if(!d||(d.productCode||'').trim().toUpperCase()!==(_wo.productCode||'').trim().toUpperCase())return; // picked a different product than the duplicate started from — don't apply
// qtyReq/qtyReqUnits are recalculated fresh below via recalcMaterials() —
// only the ratios/selection (stdQty, ovg, solCode, artworkNo, …) carry over.
const clearRaw=(r)=>({...r,qtyReq:''});
const clearPack=(r)=>({...r,qtyReqUnits:'',qtyUnitLabel:''});
if(d.componentsOnly){
_raw=[rawBlank()];
_pack=(d.packingMaterials&&d.packingMaterials.length)?d.packingMaterials.map(clearPack):[packBlank()];
}else{
_raw=(d.rawMaterials&&d.rawMaterials.length)?d.rawMaterials.map(clearRaw):[rawBlank()];
_pack=(d.packingMaterials&&d.packingMaterials.length)?d.packingMaterials.map(clearPack):[packBlank()];
}
_bomFetched=true;
recalcMaterials();
toast('Item selection copied from the source Work Order — review before submitting');
render();
}
// ─────────── INIT ───────────
(function init(){
// Load the Solution-Batch-Size edit thresholds (admin-configurable, per type).
(async()=>{try{const r=await api('GET','/config');if(r){
if(r.woSolutionBatchMaxEditLtr!=null)_soluBatchMaxBB=parseFloat(r.woSolutionBatchMaxEditLtr)||200;
if(r.woSolutionBatchMaxEditLtrPD!=null)_soluBatchMaxPD=parseFloat(r.woSolutionBatchMaxEditLtrPD)||200;
if(r.woSolutionBatchRoundLtr!=null)_soluRoundBB=parseFloat(r.woSolutionBatchRoundLtr)||10;
if(r.woSolutionBatchRoundLtrPD!=null)_soluRoundPD=parseFloat(r.woSolutionBatchRoundLtrPD)||50;
if(typeof r.woHideSfgFromPacking==='boolean')_woHideSfgFromPacking=r.woHideSfgFromPacking;
if(typeof r.woShowWfiInRawMaterial==='boolean')_woShowWfiInRawMaterial=r.woShowWfiInRawMaterial;
if(typeof r.woHideStdQtyUnitPicker==='boolean')_woHideStdQtyUnitPicker=r.woHideStdQtyUnitPicker;
if(typeof r.rmOvgOverrideEnabled==='boolean')_rmOvgOverrideEnabled=r.rmOvgOverrideEnabled;
if(Array.isArray(r.woWeighingBalanceIds))_balanceIds=r.woWeighingBalanceIds;
if(Array.isArray(r.woCustomQtyUnits))_customQtyUnits=r.woCustomQtyUnits;
if(r.woRawPotencyFactors&&typeof r.woRawPotencyFactors==='object')_rawPotencyFactors=r.woRawPotencyFactors;
if(r.woQtyIssuedSource)_woQtyIssuedSource=r.woQtyIssuedSource;
}}catch(_e){}})();
const params=new URLSearchParams(window.location.search);
const from=params.get('from');
if(from)_srcId=from;
// Defensive: a "Duplicate" hand-off (see duplicateWO()) that got abandoned
// before reaching this page (browser back, closed tab, etc.) must never
// silently apply itself to an unrelated later Work Order — only a fresh
// ?from= arrival is allowed to consume it (see the loadIntimations().then()
// below and pickBatch()'s applyDuplicatePayloadIfAny()).
if(!from){try{sessionStorage.removeItem('wo_duplicate_payload');}catch(_e){}}
// Deep link from the sidebar "Pending Actions" bell (?open=<id>) — jump
// straight to that one Work Order's detail popup instead of the create form.
const openId=params.get('open');
if(openId){
_tab='view';
loadList().then(()=>{const wid=parseInt(openId);if(_list.some(w=>w.id===wid))openWODetail(wid);});
}else{
loadList(); // background — needed by usedBatchesForIntimation() to lock already-used batches in the picker
// Coming from "Duplicate" (see duplicateWO()): the freshly-created
// Intimation has exactly one product/one batch, so auto-select it
// instead of making the user click it manually too.
let dupPending=false;try{dupPending=from&&!!sessionStorage.getItem('wo_duplicate_payload');}catch(_e){}
loadIntimations().then(()=>{
if(!dupPending)return;
const it=_intimations.find(x=>String(x.id)===String(from));
if(it&&Array.isArray(it.products)&&it.products.length===1&&Array.isArray(it.products[0].batches)&&it.products[0].batches.length===1){
selectSource(from);
pickBatch(0,0);
}
});
}
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>