2118 lines
135 KiB
HTML
2118 lines
135 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
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>
|